Files
TelegramTwitterMediaBot/BILIBILI_PLAN.md
T
YoursFunny 0eb4e5c78d fix(sites): request the opus serialization so bilibili posts keep their text
An image/text post fetched without `features=itemOpusStyle` comes back in
bilibili's legacy shape, where the post's body and headline are gone
completely — `desc: null`, no `major.opus` — so `title` (and `{title}`)
stayed empty for exactly the posts that do have content
(`opus/1248857553488576532`: legacy `desc: null`, flagged
`major.opus.summary.text = "[doge_金箍]黑白搭配"`). The same flag also
moves the pictures to `major.opus.pics` (key `url`, not `src`).

Text now falls back opus (headline + body) → `desc.text` → archive card
title; media falls back `opus.pics` → `draw.items` → archive cover, so
the legacy shapes keep working if the flag is ever retired.

Verified live: the reported link now yields title "[doge_金箍]黑白搭配"
with its picture; AV dynamics keep their card title; forwards keep
`desc.text` and gain `//@` composition unchanged.
2026-09-17 22:34:11 +08:00

13 KiB
Raw Blame History

Bilibili 动态支持:研究与实现记录

状态:已实现(crates/x-media/src/site/bilibili/)。本文记录上游调研、实测数据与最终设计; 长期契约以 AGENTS.md 为准。

范围:只发动态里的图片与动图。动态内嵌视频不发流,降级为封面图;b23.tv 短链不匹配; 视频页 / 番剧 / 直播间 / 专栏 / 音频均不支持。


1. 上游实现研究

1.1 nazurinnazurin/sites/bilibili/4 个文件 ~6 KB

  • 入口正则:t\.bilibili\.com/(\d+)t\.bilibili\.com/h5/dynamic/detail/(\d+)bilibili\.com/opus/(\d+)
  • 请求:GET https://api.bilibili.com/x/polymer/web-dynamic/v1/detail?id={id},仅加 Referer: https://t.bilibili.com/{id}无 cookie、无 WBI 签名、无 build 参数
  • 错误:code == 4101147 → not foundcode != 0 或缺 data → 报错。
  • 媒体:只取 item.modules.module_dynamic.major.draw.items[].src;缩略图 src + "@518w.jpg" size 字段单位是 KBmajor 为空或 draw.items 为空 → "No image found"。 忽略视频、转发(forward)与纯文字动态
  • caption"#" + module_author.name + module_dynamic.desc.text,链接写死 https://www.bilibili.com/opus/{id}

1.2 telegram-bili-feed-helperbiliparser/provider/bilibili/9 个文件 ~57 KB

  • 9 个策略类(Video/Opus/Live/Audio/Read + Feed 基类 + Credential + api 工具):门禁正则 bilibili\.com|b23\.tv|BV\w{10}|av\d+,再分流,兜底 client.head(url) 跟随重定向后按子串分流。
  • 动态:GET /x/polymer/web-dynamic/desktop/v1/detail?id={id}&build=11605单条,无分页); 客户端带桌面 UA、随机 buvid3={uuid}infoc;登录态用 bilibili-api-pythonCredential Redis 持久化 SESSDATA/bili_jct/buvid3/buvid4/ac_time_value/DedeUserID,扫码登录)。
  • 同样没有 WBI 签名 / appkey 签名playurl 用的是非 WBI 的 /x/player/playurl
  • 媒体:major.type 分派 —— DRAW 取全部 items[].srcARCHIVE/PGC/ARTICLE/MUSIC/COMMON/LIVE 只取一张 cover;FORWARD 取原动态作者/正文并递归进 orig 找媒体。
  • 视频:仅独立 video 策略解析(qn 720P→480P→360P 试 durl,再退 DASH + ffmpeg 合并); 动态内嵌视频只发封面
  • 错误:要求 status==200 && code==0;风控 -352/-412 无特殊处理。

1.3 取舍

维度 nazurin bff 本仓库
接口 v1/detail?id= desktop/v1/detail?id=&build= v1/detail?id=(实测可用)
认证 buvid3 + SESSDATA 默认匿名;可选 BILIBILI_COOKIE
WBI 不实现(无需求)
图片 major.draw.items 同 + forward 递归 同,加 orig 递归、http→https.gif → Animated
视频 完全忽略 动态内嵌视频发封面 发封面(不发流)
短链 不匹配 跟随重定向 不匹配(多数短链是视频,会让"静默忽略"变成失败提示)

2. 实测验证(2026-09-17,真实请求)

验证项 结果
v1/detail?id=(无 cookie、UA Mozilla/5.0、带 Referer 200 {"code":0}
同上,不带 cookie 也不带 Referer 200 {"code":0} (无强制鉴权)
bff 的 bilibili_pc/…Electron/22.3.27 UA code:-352 不要抄它的 UA
desktop/v1/detail?build=11605 code:-352
feed/space?host_mid=(用户时间线) 首次成功、随后 -352,也见过 HTTP 412 → 不碰
不存在 / 已删除的动态 code:500 "Cannot read property 'only_fans' of undefined"nazurin 的 4101147 已失效)
非数字 id code:-400 param parsing failed
图片 i0.hdslb.com/bfs/new_dyn/*.jpg HEAD 200 image/jpeg,带/不带 Referer 均可;+@518w.jpg → 2542 KB
t.bilibili.com/h5/dynamic/detail/<id> 200
m.bilibili.com/dynamic/<id> 302 → t.bilibili.com/<id>
www.bilibili.com/opus/<id> 200,转发动态 302 → t.bilibili.com/<id>
b23.tv/BV1JTtt6JEZu 302 → www.bilibili.com/video/BV…(视频)
b23.tv/<无效码> HTTP 200 + {"code":-404} ⚠️ 短链判定不能只看状态码
playurl(仅调研用,未采用) fnval=1 匿名给 durl720P=9.18 MiB / 360P=2.97 MiBfnval=4048 匿名 DASH 上限仅 480P
dyn_archive 字段 aid/bvid/cover/title/duration_text没有 cid(所以发流要再来一次 view 请求)
风控阶梯(同一 IP 连续请求后实测) ① 无 cookie → -352;② 仅 buvid3 → 仍 -352;③ buvid3+buvid4(取自匿名 /x/frontend/finger/spi)→ code:0 恢复;④ 继续高频请求后 → 连同 buvid 一起 -352(此时只有登录 cookie 或换 IP)
正文位置(24 条真实动态逐条审计) 有正文的动态都在 module_dynamic.desc.text(图文/转发/纯文字,含 34–193 字样本);AV(视频投稿)动态 desc 恒为 null,内容在 major.archive.title / .desc 卡片里 → 已做 title 回退
features=itemOpusStyle 的效果 同一端点带此参数后,图文帖改为 major.opus 形态:pics[](图,key 是 url)、summary.text(正文,未截断,实测 307 字整段)、title(可选标题);不带参数则是 legacy major.draw + desc,而 opus 图文帖的 descnull、正文与标题完全丢失opus/1248857553488576532legacy desc:null,带参数 summary.text="[doge_金箍]黑白搭配")。AV / 转发帖不受该参数影响 → 适配器改为请求时带参数,并保留 legacy 形态兜底
feed 与 detail 的差异 feed/space 的 item 会把 desc.text 挖空,只有 detail 有正文 → 排查时不要用 feed 数据判断正文缺失
不存在的 19 位 id 4101105 请求数据发生错误(提示可重试,但只出现在不可能存在的 id 上)→ 仍归入永久错误,见 code_error 注释

测试样本(live 测试用):

样本 id 期望
图片动态(2 图 + 话题) 1245284537985925159 2 个 Illustration{tags} = ALin出道20周年快乐
转发动态 1248982077447077907 媒体来自 orig1 图),正文可含 //@
视频动态 1248717597691609105 封面 1 张 Illustration
纯文字动态 1246767523595026450 media 为空

关键字段路径:

data.item.id_str
data.item.modules.module_author.{name,mid}
data.item.modules.module_dynamic.desc.text
data.item.modules.module_dynamic.topic.{id,name}            # 单话题,{tags} 来源
data.item.modules.module_dynamic.major.{draw.items[].src, archive.cover}
data.item.orig                                              # 转发时存在,结构与 item 相同

3. 实现

crates/x-media/src/site/bilibili/mod.rs        # re-export
crates/x-media/src/site/bilibili/interface.rs  # PATTERN / cache_key / enabled / is_retryable /
                                               # media_headers / BilibiliSite / fetch / code_error /
                                               # From<Item> for Fetched / caption / 12 单测 + 2 live
crates/x-media/src/site/bilibili/model.rs      # 纯 Deserialize DTO(全 Option
  • 正则(同时用于分发、抽 id、缓存键,一个正则三用): ^(?:https?://)?(?:www|t|m)\.bilibili\.com/(?:opus/|dynamic/|h5/dynamic/detail/)?(\d+)
  • 缓存键bilibili:<动态 id>source_url 统一 https://www.bilibili.com/opus/{id}
  • 请求GET /x/polymer/web-dynamic/v1/detail?id= + Referer: https://www.bilibili.com/ Cookie 头按优先级取:BILIBILI_COOKIE → 缓存的设备 cookieGET /x/frontend/finger/spibuvid3/buvid4, 进程内缓存一次;取不到就不带 cookie,仅 debug 日志)→ 无。指纹接口本身失败让抓取失败。 走共享 CLIENTUA Mozilla/5.030s 超时,TELOXIDE_PROXY 透传)。
  • 错误映射0 → 成功;-352/-412 与 HTTP 412 → Transient(可重试,队列退避;首次记一条 warn 提示 BILIBILI_COOKIE);500/4101147NotFound(永久);其他 code → Site(永久)。
  • 媒体
    • major.opus.pics[](带 features=itemOpusStyle 时的图文帖形态,字段名是 url)→ 每张一张图; 其次 major.draw.items[]legacy,字段名 src)→ 同样逐张;http:// / //https://,非 https 开头直接丢弃。 .gifMedia::Animatedthumbnail_url 留空,Telegram 自己取首帧——@518w.jpg 只对 jpg/webp 实测过), 其余 → Media::Illustrationthumbnail_url = url + "@518w.jpg",兼作超大时的降级 URL)。
    • major.archive.cover → 1 张 Illustration(视频不发流)。
    • 转发且自身无媒体 → 递归取 orig 的媒体;正文拼 //@{原作者}:\n{原文}
    • 其他 majorPGC/ARTICLE/MUSIC/LIVE/COMMON)不建模 → 无媒体,走既有 "No media found"。
  • 正文 / title(按信息量从多到少回退):major.opus.title + major.opus.summary.textmodule_dynamic.desc.textmajor.archive.title。三者分别对应:图文文档(标题+正文)、 legacy/转发帖正文、视频投稿卡片标题。开头结尾空白做 trim;整体再由既有 truncate_caption 截断。
  • caption(与 misskey 同形):{opus 链接}\n<a href="space.bilibili.com/{mid}">{name}</a>: {正文} RenderData{tags} 来自话题名;正文由既有 truncate_caption 截断。
  • 注册表SITES 末尾追加 → /set_format 白名单、链接缓存、启动校验、日志前缀全部自动生效。
  • bot 侧仅文案handlers/commands.rs 三处站点清单字符串 + state.rs/handlers/mod.rs 注释。

与原计划的偏差(及原因)

原计划 实际 原因
x/web-interface/view + playurl 发视频 不做 需求收窄为图片/动图;视频只发封面
site/mod.rsMAX_MEDIA_UPLOAD_BYTES 常量 不加 没有视频尺寸决策就不需要该常量,避免跨 crate 耦合
b23.tv 短链(跟随重定向) 不匹配 多数短链指向视频,匹配后会把"静默忽略"变成用户的 "Failed to fetch media"
validate() 校验 cookie 不做 匿名可用,cookie 失效不致命;校验要额外请求一个端点,收益低
media_headers 给 hdslb 加 Referer 返回 None 实测图片与 durl 均无需 Referer(注释里记了这条验证)
计划阶段认为设备 cookie 是 YAGNI,不实现 实现buvid3+buvid4 计划之后做了对照实验:同一 IP 上"无 cookie → -352、只有 buvid3 → -352、buvid3+buvid4 → code:0",说明这是对本适配器主要失败模式的直接修复,而不是冗余保险
只用不带参数的 v1/detail features=itemOpusStyle 用户实测反馈"有内容的动态没有 title":不带参数时 opus 图文帖返回 legacy 形态,descnull,正文与标题整个丢失。带参数后同一 ID 返回 major.opus.summary.text / title / pics。AV / 转发帖不受影响,legacy 形态仍保留为兜底

4. 测试与验证

  • 单元(13):正则匹配/拒绝/忽略短链、缓存键归一、图片映射(https 归一 + 缩略图 + .gif → Animated)、 封面、转发取 orig 媒体与正文拼接、纯文字无媒体、caption 转义、业务 code 分类(可重试性)、URL 归一、 设备 cookie 拼装。
  • live3#[ignore = "live network: …"]):设备 cookie 可取、图片动态 2 图、纯文字动态无媒体。 CI 的 live job 已覆盖。动态接口被风控时这两条 live 测试打印 skipping: 并提前返回(与 pixiv 的 token 门控同款约定),设备 cookie 那条仍会真实执行。
  • 实测命令: cargo run -p x-media --example fetch -- https://www.bilibili.com/opus/1245284537985925159 (输出 2 张 https://i0.hdslb.com/…jpg + @518w.jpg 缩略图 + 话题 tags)。
  • 全套:cargo fmt --checkcargo clippy --workspace --all-targets -- -D warningscargo test --workspace 全绿。

5. 已知限制

  • 风控按 IP/请求量漂移,阶梯见 §2 最后一行:轻度靠设备 cookie 自愈,重度需 BILIBILI_COOKIE 或换 IP。 被拦时按可重试失败处理(队列退避)+ 一条 warn,不会静默丢帖。
  • 接口 schema 会漂移(module_dynamic.major 实测可为 null 而正文留在 desc);DTO 全 Option, 未知形态降级为"无媒体",不 panic。
  • 动态内嵌视频只发封面图(与 bff 同策略),不下载流。
  • 纯文字动态复用既有 "No media found" 回复。
  • b23.tv 短链不被匹配(见上表)。