Files
YoursFunny 24cbfc2f27 chore(deploy): track docker-compose.yml and read instance values from .env
- `docker-compose.yml.example` becomes `docker-compose.yml`, committed as the
  real deployment file (the log caps came along) and no longer gitignored.
  Every instance value is now `${VAR}` with a `${VAR:-default}` fallback, so
  compose reads it from the gitignored `.env` beside the file and the committed
  composition needs no per-deployment edit; a variable not listed in a
  service's `environment:` never reaches that container. Two knobs the docs
  promised but the file lacked are now wired: `DEFAULT_HOST` on nginx-proxy and
  `BILIBILI_COOKIE`/`CAPTION_QUOTE_TEXT_CHARS` on the bot, and the healthcheck
  follows `WEBHOOK_PORT` instead of a hardcoded 8443. `TELOXIDE_PROXY` is
  deliberately *not* passed — a loopback proxy inside a container is the
  container itself, and teloxide panics on a blank value — so the compose
  comment and both READMEs explain when to add it by hand.
- `BILIBILI_PLAN.md` moves to `docs/BILIBILI_PLAN.md` (nothing referenced it).
- Docs synced: AGENTS.md (the deployment-file row, and `.gitignore` no longer
  lists the compose file) and both READMEs (deployment and webhook steps now
  say "set it in .env" rather than "edit the compose", plus the proxy trap in
  the env table).
2026-09-20 22:07:27 +08:00

13 KiB
Raw Permalink 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 短链不被匹配(见上表)。