feat(sites): add bilibili dynamic support (images and animated images)

Fetch `t.bilibili.com/<id>`, `www.bilibili.com/opus/<id>`,
`t.bilibili.com/h5/dynamic/detail/<id>` and `m.bilibili.com/dynamic/<id>`
through the anonymous `/x/polymer/web-dynamic/v1/detail` endpoint (no
cookie, no WBI signature; the site adds the device cookies
`/x/frontend/finger/spi` hands out, which is what lifts bilibili's
`-352` risk control).

Media: the `major.draw` grid (`.gif` sources become animations, the rest
photos with a downscaled `@518w.jpg` thumbnail used both as preview and
as the oversized fallback), an attached video's cover, and the quoted
dynamic's media for forwards. The video stream itself is not resolved;
`b23.tv` short links stay unmatched (they mostly point at videos, so
matching them would turn a silently ignored link into a failure reply).

`-352`/`-412` map to a retryable error so the queue backs off instead of
dropping the post; a removed dynamic (`500`) is permanent.

Registry-driven, so no bot-side code changes beyond the site lists in the
command replies; found while researching nazurin and
telegram-bili-feed-helper (see BILIBILI_PLAN.md).
This commit is contained in:
2026-09-17 20:48:15 +08:00
parent 1bb6968108
commit c1f5d3ca54
12 changed files with 1128 additions and 32 deletions
@@ -0,0 +1,788 @@
//! Site adapter for Bilibili dynamics (图片 / 动图): URL pattern, API fetch and
//! normalization into [`Fetched`] (see [`crate::site::Site`]).
//!
//! Scope: the *media images* of a dynamic — the `major.draw` image grid and
//! the cover of an attached video. Animated (`.gif`) pictures become
//! [`Media::Animated`], everything else a photo. The video stream itself is
//! deliberately **not** resolved: `dyn_archive` carries no `cid`, so playing
//! it would mean a second `x/web-interface/view` round trip plus
//! `x/player/playurl` and its size/quality chasing — the cover plus the post
//! link is what the operator asked for.
//!
//! `b23.tv` short links are not matched: most of them point at videos, which
//! this adapter does not handle, and matching them would turn a silently
//! ignored link into the bot's "Failed to fetch media" reply.
//!
//! Verified live (2026-09-17): the detail endpoint answers **anonymously**
//! (no cookie, no WBI signature) to a browser-ish `User-Agent` + `Referer`;
//! `build`-taking variants and bilibili's own `bilibili_pc/…` UA got `-352`.
//! Risk control escalates with request volume from one IP — first a plain
//! request starts answering `-352`, then adding the anonymous device cookies
//! (`buvid3` + `buvid4`, fetched from [`SPI_URL`] and sent by [`cookie`])
//! restores `code: 0`, and a heavily flagged IP stays blocked until
//! `BILIBILI_COOKIE` supplies a logged-in session.
use super::model;
use crate::media::Media;
use crate::site::{FetchError, Fetched, RenderData, Site, SiteFuture};
use html_escape::{encode_double_quoted_attribute, encode_text};
use regex::Regex;
use std::sync::LazyLock;
use std::sync::atomic::{AtomicBool, Ordering};
const API_URL: &str = "https://api.bilibili.com/x/polymer/web-dynamic/v1/detail";
/// Anonymous device-fingerprint endpoint handing out `buvid3`/`buvid4`.
const SPI_URL: &str = "https://api.bilibili.com/x/frontend/finger/spi";
/// Sent with every API request: requests without it are the ones bilibili
/// risk-controls (`code -352`, HTTP 412).
const REFERER: &str = "https://www.bilibili.com/";
/// hdslb image variant used as thumbnail (and as the oversized fallback): a
/// downscaled still of the same image, ~30 KB instead of ~570 KB. Verified
/// live for `.jpg` and `.webp` sources. Not applied to `.gif` (unverified for
/// animated sources, and Telegram generates its own frame preview).
const THUMB_SUFFIX: &str = "@518w.jpg";
/// Optional `Cookie` header value (`SESSDATA=…; bili_jct=…`) for deployments
/// that need a logged-in session. Unset by default: the adapter fetches
/// bilibili's anonymous device cookies itself (see [`cookie`]) and works
/// without any operator setup, so unlike pixiv the site stays enabled.
static COOKIE: LazyLock<Option<String>> = LazyLock::new(|| {
std::env::var("BILIBILI_COOKIE")
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
});
/// Cached `buvid3`/`buvid4` header value from [`SPI_URL`], or `None` when the
/// fingerprint endpoint was unavailable (requests then go out without a
/// cookie, as before).
static BUVID: LazyLock<tokio::sync::Mutex<Option<String>>> = LazyLock::new(Default::default);
/// Registry entry for the bilibili adapter (see [`crate::site::Site`]).
pub struct BilibiliSite;
impl Site for BilibiliSite {
fn id(&self) -> &'static str {
"bilibili"
}
fn pattern(&self) -> &'static Regex {
&PATTERN
}
fn cache_key(&self, url: &str) -> Option<String> {
cache_key(url)
}
fn fetch_from_url<'a>(&'a self, url: &'a str) -> SiteFuture<'a, Fetched> {
Box::pin(async move { fetch_from_url(url).await })
}
}
/// Every supported link form, with the dynamic id in group 1: the direct
/// dynamic (web / mobile / h5 share) and opus URLs.
pub static PATTERN: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"^(?:https?://)?(?:www|t|m)\.bilibili\.com/(?:opus/|dynamic/|h5/dynamic/detail/)?(\d+)",
)
.unwrap()
});
pub fn enabled() -> bool {
true
}
pub async fn fetch_from_url(url: &str) -> Result<Fetched, FetchError> {
let dynamic_id = PATTERN
.captures(url)
.and_then(|caps| caps.get(1))
.ok_or(FetchError::NotFound)?
.as_str();
let item = fetch(dynamic_id).await?;
Ok(item.into())
}
/// Cache key for a bilibili URL: `"bilibili:<dynamic id>"`. The prefix is the
/// site id used for caption-format lookup and link-cache keys.
pub fn cache_key(url: &str) -> Option<String> {
PATTERN
.captures(url)
.map(|caps| format!("bilibili:{}", &caps[1]))
}
/// Bilibili's fetch-retry policy: transient classes only. Not-found, blocked
/// and parse failures are permanent.
pub fn is_retryable(err: &FetchError) -> bool {
matches!(err, FetchError::Http(_) | FetchError::Transient(_))
}
/// hdslb media serves without a `Referer` (verified live 2026-09-17 on
/// `i0.hdslb.com` image URLs, requested both with and without one), so no
/// extra headers.
pub fn media_headers(_url: &str) -> Option<Vec<(&'static str, String)>> {
None
}
/// `Cookie` header for bilibili requests: the operator's `BILIBILI_COOKIE`
/// when set, otherwise the anonymous device cookies.
///
/// Device cookies are the adapter's own fix for risk control, not merely
/// insurance — verified 2026-09-17 on an IP bilibili had flagged: every
/// request answered `-352` without them and `code: 0` with `buvid3` +
/// `buvid4`. A logged-in `BILIBILI_COOKIE` carries its own device cookies,
/// hence precedence rather than concatenation.
async fn cookie() -> Option<String> {
if let Some(cookie) = COOKIE.as_deref() {
return Some(cookie.to_string());
}
// ponytail: cached for the process lifetime. Refetching after a `-352`
// would mint a new device id for the same flagged IP — the escalation
// path is BILIBILI_COOKIE.
let mut cached = BUVID.lock().await;
if cached.is_none() {
*cached = match fetch_buvid().await {
Ok(cookie) => cookie,
Err(e) => {
log::debug!("bilibili fingerprint unavailable: {e}");
None
}
};
}
cached.clone()
}
/// Fetches the device cookies bilibili hands to any visitor. The result is
/// deliberately not an error: an unavailable fingerprint endpoint just means
/// requests go out without a cookie.
async fn fetch_buvid() -> Result<Option<String>, FetchError> {
let response = crate::site::CLIENT.get(SPI_URL).send().await?;
let fingerprint: model::Fingerprint = response.json().await.map_err(|e| FetchError::Site {
site: "bilibili",
error: Box::new(e),
})?;
Ok(buvid_cookie(&fingerprint))
}
/// `buvid3=B; buvid4=B` from a fingerprint response; `None` when it carried
/// no device ids.
fn buvid_cookie(fingerprint: &model::Fingerprint) -> Option<String> {
let data = fingerprint.data.as_ref()?;
if data.buvid3.is_empty() && data.buvid4.is_empty() {
return None;
}
Some(format!("buvid3={}; buvid4={}", data.buvid3, data.buvid4))
}
async fn request(url: &str) -> reqwest::RequestBuilder {
let request = crate::site::CLIENT.get(url).header("Referer", REFERER);
match cookie().await {
Some(cookie) => request.header("Cookie", cookie),
None => request,
}
}
/// Fetches one dynamic by id and returns its item.
///
/// The API answers client-level failures with HTTP 200 + a business `code`
/// (see [`code_error`]); HTTP 412 is bilibili's risk-control page.
pub async fn fetch(dynamic_id: &str) -> Result<model::Item, FetchError> {
let response = request(API_URL)
.await
.query(&[("id", dynamic_id)])
.send()
.await?;
let status = response.status();
if !status.is_success() {
return Err(match status.as_u16() {
412 => risk_control("412"),
_ => FetchError::Transient(format!("bilibili status {status}")),
});
}
let detail: model::Detail = response.json().await.map_err(|e| FetchError::Site {
site: "bilibili",
error: Box::new(e),
})?;
if let Some(err) = code_error(detail.code, detail.message.as_deref().unwrap_or_default()) {
return Err(err);
}
detail
.data
.and_then(|data| data.item)
.map(|item| *item)
.ok_or(FetchError::NotFound)
}
/// Maps the API's business code to an error; `None` means success.
///
/// `-352`/`-412` are bilibili's risk control and are mapped to a *transient*
/// error on purpose: the queue retries them with backoff instead of dropping
/// the post. A removed or nonexistent dynamic answers `500` (verified
/// 2026-09-17; the older `4101147` is kept because nazurin still documents
/// it) — permanent, so a dead link is not retried.
fn code_error(code: i64, message: &str) -> Option<FetchError> {
match code {
0 => None,
-352 | -412 => Some(risk_control(&code.to_string())),
500 | 4101147 => Some(FetchError::NotFound),
_ => Some(FetchError::Site {
site: "bilibili",
error: format!("code {code}: {message}").into(),
}),
}
}
/// Risk control: retryable, but retrying rarely helps on its own — the
/// operator's lever is `BILIBILI_COOKIE`, hence the hint. Logged once so a
/// blocked deployment does not flood the log with one line per link.
fn risk_control(code: &str) -> FetchError {
if !RISK_CONTROL_LOGGED.swap(true, Ordering::Relaxed) {
log::warn!("bilibili risk control ({code}); set BILIBILI_COOKIE if this persists");
}
FetchError::Transient(format!("bilibili risk control ({code})"))
}
static RISK_CONTROL_LOGGED: AtomicBool = AtomicBool::new(false);
impl From<model::Item> for Fetched {
fn from(item: model::Item) -> Self {
let url = format!("https://www.bilibili.com/opus/{}", item.id_str);
let author = author_name(&item).to_string();
let author_url = author_url(&item).unwrap_or_else(|| url.clone());
let text = text_of(&item);
let tags = topic_name(&item).to_string();
let caption = caption(&url, &author_url, &author, &text);
let media = media_of(&item);
Fetched {
source_url: url.clone(),
caption,
title: text.clone(),
media,
sensitive: false,
site_id: "bilibili",
render_data: Some(RenderData {
url,
author: encode_text(&author).into_owned(),
author_url,
title: encode_text(&text).into_owned(),
tags: encode_text(&tags).into_owned(),
}),
_keep_alive: None,
}
}
}
fn author_name(item: &model::Item) -> &str {
item.modules
.as_ref()
.and_then(|modules| modules.module_author.as_ref())
.map(|author| author.name.as_str())
.unwrap_or_default()
}
fn author_url(item: &model::Item) -> Option<String> {
item.modules
.as_ref()
.and_then(|modules| modules.module_author.as_ref())
.and_then(|author| author.mid)
.map(|mid| format!("https://space.bilibili.com/{mid}"))
}
fn desc_text(item: &model::Item) -> &str {
item.modules
.as_ref()
.and_then(|modules| modules.module_dynamic.as_ref())
.and_then(|dynamic| dynamic.desc.as_ref())
.map(|desc| desc.text.as_str())
.unwrap_or_default()
}
fn topic_name(item: &model::Item) -> &str {
item.modules
.as_ref()
.and_then(|modules| modules.module_dynamic.as_ref())
.and_then(|dynamic| dynamic.topic.as_ref())
.map(|topic| topic.name.as_str())
.unwrap_or_default()
}
/// The post's text: its own plus the quoted original's when it is a forward,
/// marked the way bilibili's web UI does (`//@author:`).
fn text_of(item: &model::Item) -> String {
let own = desc_text(item);
let Some(orig) = item.orig.as_deref() else {
return own.to_string();
};
let orig_text = desc_text(orig);
if orig_text.is_empty() {
return own.to_string();
}
let name = author_name(orig);
let mut text = own.to_string();
if !text.is_empty() {
text.push('\n');
}
if name.is_empty() {
text.push_str(orig_text);
} else {
text.push_str(&format!("//@{name}:\n{orig_text}"));
}
text
}
/// The dynamic's media: its own grid (or video cover), falling back to the
/// quoted original's when a forward shell has none (mirrors misskey's
/// `effective()` handling of renotes).
fn media_of(item: &model::Item) -> Vec<Media> {
let own = own_media(item);
if !own.is_empty() {
return own;
}
item.orig.as_deref().map(own_media).unwrap_or_default()
}
fn own_media(item: &model::Item) -> Vec<Media> {
let Some(major) = item
.modules
.as_ref()
.and_then(|modules| modules.module_dynamic.as_ref())
.and_then(|dynamic| dynamic.major.as_ref())
else {
return Vec::new();
};
if let Some(draw) = major.draw.as_ref() {
return draw
.items
.iter()
.filter_map(|pic| image(&pic.src))
.collect();
}
major
.archive
.as_ref()
.and_then(|archive| archive.cover.as_deref())
.and_then(image)
.into_iter()
.collect()
}
/// Maps one image URL: bilibili serves it as `http://`, and a `.gif` source
/// is an animation rather than a photo.
fn image(url: &str) -> Option<Media> {
let url = to_https(url);
if !url.starts_with("https://") {
return None;
}
Some(if url.ends_with(".gif") {
Media::Animated {
title: None,
url,
// Left empty on purpose: the `@518w.jpg` variant is unverified for
// animated sources, and Telegram generates a frame preview itself.
thumbnail_url: String::new(),
}
} else {
Media::Illustration {
title: None,
// Written before `url` moves so the formatting borrows it.
thumbnail_url: Some(format!("{url}{THUMB_SUFFIX}")),
url,
fallback_url: None,
}
})
}
/// Bilibili serves media as `http://` (and sometimes protocol-relative
/// `//host/…`); Telegram only accepts `https://`.
fn to_https(url: &str) -> String {
if let Some(rest) = url.strip_prefix("http://") {
format!("https://{rest}")
} else if let Some(rest) = url.strip_prefix("//") {
format!("https://{rest}")
} else {
url.to_string()
}
}
fn caption(url: &str, author_url: &str, author: &str, text: &str) -> String {
let url = encode_double_quoted_attribute(url);
let author_url = encode_double_quoted_attribute(author_url);
let author = encode_text(author);
if text.is_empty() {
return format!("{url}\n<a href=\"{author_url}\">{author}</a>");
}
format!(
"{url}\n<a href=\"{author_url}\">{author}</a>: {}",
encode_text(text)
)
}
#[cfg(test)]
mod tests {
use super::*;
fn item_json(major: serde_json::Value, text: &str) -> serde_json::Value {
serde_json::json!({
"id_str": "1245284537985925159",
"modules": {
"module_author": { "name": "索尼音乐中国", "mid": 486906719 },
"module_dynamic": {
"desc": { "text": text },
"major": major,
"topic": { "id": 1347638, "name": "音乐" },
},
},
})
}
fn parse(json: serde_json::Value) -> Fetched {
serde_json::from_value::<model::Item>(json).unwrap().into()
}
fn draw_item(src: &str) -> serde_json::Value {
serde_json::json!({
"type": "MAJOR_TYPE_DRAW",
"draw": { "items": [{ "src": src, "width": 2304, "height": 2880, "size": 566.7 }] },
})
}
#[test]
fn pattern_matches_supported_forms() {
for url in [
"https://t.bilibili.com/1245284537985925159",
"t.bilibili.com/1245284537985925159",
"https://t.bilibili.com/h5/dynamic/detail/1245284537985925159",
"https://www.bilibili.com/opus/1245284537985925159",
"https://m.bilibili.com/dynamic/1245284537985925159",
"https://www.bilibili.com/opus/1245284537985925159?share_source=copy_web",
] {
assert!(PATTERN.is_match(url), "{url}");
}
}
#[test]
fn pattern_rejects_other_bilibili_pages() {
for url in [
"https://www.bilibili.com/video/BV1JTtt6JEZu",
"https://www.bilibili.com/",
"https://space.bilibili.com/486906719/dynamic",
"https://live.bilibili.com/22632424",
"https://www.bilibili.com/read/cv123456",
"https://t.bilibili.com/",
"https://x.com/user/status/1234567890",
] {
assert!(!PATTERN.is_match(url), "{url}");
}
}
/// Short links stay unmatched on purpose (most point at videos) so the
/// bot keeps ignoring them instead of answering with a failure.
#[test]
fn pattern_ignores_short_links() {
assert!(!PATTERN.is_match("https://b23.tv/abc123"));
assert_eq!(cache_key("https://b23.tv/abc123"), None);
}
#[test]
fn cache_key_normalizes_direct_forms() {
for url in [
"https://t.bilibili.com/1245284537985925159",
"https://t.bilibili.com/h5/dynamic/detail/1245284537985925159?utm_source=share",
"https://www.bilibili.com/opus/1245284537985925159",
"https://m.bilibili.com/dynamic/1245284537985925159",
] {
assert_eq!(
cache_key(url),
Some("bilibili:1245284537985925159".to_string()),
"{url}"
);
}
}
#[test]
fn from_item_maps_draw_images_and_topic() {
let fetched = parse(item_json(
draw_item("http://i0.hdslb.com/bfs/new_dyn/a.jpg"),
"新歌上线",
));
assert_eq!(
fetched.source_url,
"https://www.bilibili.com/opus/1245284537985925159"
);
assert_eq!(fetched.site_id, "bilibili");
assert_eq!(fetched.title, "新歌上线");
assert!(!fetched.sensitive);
assert_eq!(fetched.media.len(), 1);
match &fetched.media[0] {
Media::Illustration {
url,
thumbnail_url,
fallback_url,
..
} => {
assert_eq!(url, "https://i0.hdslb.com/bfs/new_dyn/a.jpg");
assert_eq!(
thumbnail_url.as_deref(),
Some("https://i0.hdslb.com/bfs/new_dyn/a.jpg@518w.jpg")
);
assert_eq!(*fallback_url, None);
}
other => panic!("{other:?}"),
}
let caption = &fetched.caption;
assert!(
caption.starts_with("https://www.bilibili.com/opus/1245284537985925159\n"),
"{caption}"
);
assert!(
caption.contains("https://space.bilibili.com/486906719"),
"{caption}"
);
assert!(caption.contains("索尼音乐中国"), "{caption}");
assert!(caption.ends_with(": 新歌上线"), "{caption}");
assert_eq!(
fetched.render_fields(),
Some((
"索尼音乐中国",
"https://space.bilibili.com/486906719",
"新歌上线",
"音乐"
))
);
}
/// `.gif` sources are animations; they must not be sent as photos, and
/// their thumbnail is left for Telegram to generate.
#[test]
fn from_item_maps_gif_as_animation() {
let fetched = parse(item_json(
draw_item("http://i0.hdslb.com/bfs/new_dyn/a.gif"),
"",
));
match &fetched.media[0] {
Media::Animated {
url, thumbnail_url, ..
} => {
assert_eq!(url, "https://i0.hdslb.com/bfs/new_dyn/a.gif");
assert_eq!(thumbnail_url, "");
}
other => panic!("{other:?}"),
}
// No text: the caption is the link plus the author line only.
assert_eq!(
fetched.caption,
"https://www.bilibili.com/opus/1245284537985925159\n<a href=\"https://space.bilibili.com/486906719\">索尼音乐中国</a>"
);
}
/// Bilibili's media URLs arrive as `http://` or protocol-relative; both
/// must become `https://` before they reach Telegram.
#[test]
fn media_urls_are_normalized_to_https() {
let fetched = parse(item_json(draw_item("//i0.hdslb.com/bfs/new_dyn/p.jpg"), ""));
assert_eq!(
fetched.media[0].url(),
"https://i0.hdslb.com/bfs/new_dyn/p.jpg"
);
let fetched = parse(item_json(draw_item("not-a-url"), ""));
assert!(fetched.media.is_empty());
}
/// The video stream is out of scope; an AV dynamic still yields its cover.
#[test]
fn from_item_maps_archive_cover() {
let major = serde_json::json!({
"type": "MAJOR_TYPE_ARCHIVE",
"archive": {
"aid": 117189055087158_i64,
"bvid": "BV1JTtt6JEZu",
"cover": "http://i0.hdslb.com/bfs/archive/c.jpg",
"title": "Supersubmarina",
"duration_text": "03:45",
},
});
let fetched = parse(item_json(major, "投稿了视频"));
assert_eq!(fetched.media.len(), 1);
assert_eq!(
fetched.media[0].url(),
"https://i0.hdslb.com/bfs/archive/c.jpg"
);
assert!(matches!(fetched.media[0], Media::Illustration { .. }));
}
/// A forward shell carries the quote's text and, when it has no media of
/// its own, the quote's images.
#[test]
fn from_item_forward_uses_orig_media_and_text() {
let mut json = item_json(serde_json::Value::Null, "转发理由");
json["orig"] = serde_json::json!({
"id_str": "1246767523595026450",
"modules": {
"module_author": { "name": "A-SOUL_Official", "mid": 703007996 },
"module_dynamic": {
"desc": { "text": "原动态正文" },
"major": draw_item("http://i0.hdslb.com/bfs/new_dyn/o.jpg"),
},
},
});
let fetched = parse(json);
assert_eq!(fetched.media.len(), 1);
assert_eq!(
fetched.media[0].url(),
"https://i0.hdslb.com/bfs/new_dyn/o.jpg"
);
assert_eq!(fetched.title, "转发理由\n//@A-SOUL_Official:\n原动态正文");
// The forwarder stays the author; the quote appears in the text.
assert!(
fetched.caption.contains("索尼音乐中国"),
"{}",
fetched.caption
);
assert!(
fetched.caption.contains("原动态正文"),
"{}",
fetched.caption
);
}
/// A text-only dynamic has no media — the bot replies "No media found".
#[test]
fn from_item_without_major_has_no_media() {
let fetched = parse(item_json(serde_json::Value::Null, "只有文字"));
assert!(fetched.media.is_empty());
assert_eq!(fetched.title, "只有文字");
}
#[test]
fn caption_escapes_site_text() {
let fetched = parse(item_json(
draw_item("http://i0.hdslb.com/bfs/new_dyn/a.jpg"),
"<b>\"x\" & y</b>",
));
assert_eq!(fetched.title, "<b>\"x\" & y</b>");
// `encode_text` escapes markup only; a bare quote is text, not an
// attribute delimiter, and stays as-is.
assert!(
fetched.caption.contains("&lt;b&gt;\"x\" &amp; y&lt;/b&gt;"),
"{}",
fetched.caption
);
let (_, _, title, _) = fetched.render_fields().unwrap();
assert_eq!(title, "&lt;b&gt;\"x\" &amp; y&lt;/b&gt;");
}
#[test]
fn code_error_classifies_api_codes() {
assert!(code_error(0, "0").is_none());
// Risk control must be retryable so the queue backs off instead of
// dropping the post.
for code in [-352, -412] {
let err = code_error(code, "-352").unwrap();
assert!(is_retryable(&err), "{err}");
}
// A removed dynamic is permanent.
assert!(matches!(code_error(500, ""), Some(FetchError::NotFound)));
assert!(matches!(
code_error(4101147, ""),
Some(FetchError::NotFound)
));
let err = code_error(-400, "param parsing failed").unwrap();
assert!(!is_retryable(&err), "{err}");
assert!(err.to_string().contains("-400"), "{err}");
}
#[test]
fn buvid_cookie_needs_device_ids() {
let fingerprint: model::Fingerprint = serde_json::from_value(serde_json::json!({
"code": 0,
"data": { "b_3": "ABCinfoc", "b_4": "DEF-Au1eCYnrGyhSrD" },
}))
.unwrap();
assert_eq!(
buvid_cookie(&fingerprint).as_deref(),
Some("buvid3=ABCinfoc; buvid4=DEF-Au1eCYnrGyhSrD")
);
// A response without device ids must not produce a `Cookie` header
// with empty values.
for json in [
serde_json::json!({ "code": 0, "data": { "b_3": "", "b_4": "" } }),
serde_json::json!({ "code": -352 }),
] {
let fingerprint: model::Fingerprint = serde_json::from_value(json).unwrap();
assert_eq!(buvid_cookie(&fingerprint), None);
}
}
/// The device-cookie half of the adapter: the fingerprint endpoint keeps
/// answering even when the dynamic endpoint risk-controls this IP, so it
/// stays a meaningful live check on its own.
#[tokio::test]
#[ignore = "live network: requires outbound HTTPS to api.bilibili.com"]
async fn live_fingerprint_yields_device_cookies() {
let cookie = fetch_buvid().await.unwrap();
let cookie = cookie.expect("fingerprint endpoint returned no device ids");
assert!(cookie.contains("buvid3="), "{cookie}");
assert!(cookie.contains("buvid4="), "{cookie}");
}
#[tokio::test]
#[ignore = "live network: requires outbound HTTPS to api.bilibili.com"]
async fn live_fetch_draw_dynamic() {
// 索尼音乐中国, a two-picture dynamic.
let Some(fetched) = live_fetch("https://www.bilibili.com/opus/1245284537985925159").await
else {
return;
};
assert_eq!(fetched.site_id, "bilibili");
assert_eq!(
fetched.source_url,
"https://www.bilibili.com/opus/1245284537985925159"
);
let urls: Vec<&str> = fetched.media.iter().map(|m| m.url()).collect();
assert_eq!(urls.len(), 2, "{urls:?}");
assert!(
urls.iter().all(|u| u.starts_with("https://i0.hdslb.com/")),
"{urls:?}"
);
assert!(
fetched.caption.contains("space.bilibili.com"),
"{}",
fetched.caption
);
}
#[tokio::test]
#[ignore = "live network: requires outbound HTTPS to api.bilibili.com"]
async fn live_fetch_text_only_dynamic() {
// A text-only dynamic: no media, so the bot answers "No media found".
let Some(fetched) = live_fetch("https://t.bilibili.com/1246767523595026450").await else {
return;
};
assert!(fetched.media.is_empty());
assert!(!fetched.title.trim().is_empty());
}
/// Fetches a live dynamic, skipping the assertion when bilibili
/// risk-controls this IP (the site blocks datacenter/over-used addresses
/// with `-352` regardless of cookies — a real failure would surface as a
/// parse error or a not-found instead). Mirrors the token-gated pixiv
/// tests' "skipping: …" convention.
async fn live_fetch(url: &str) -> Option<Fetched> {
match fetch_from_url(url).await {
Ok(fetched) => Some(fetched),
Err(e) if e.to_string().contains("risk control") => {
eprintln!("skipping: {e}");
None
}
Err(e) => panic!("{e}"),
}
}
}
+6
View File
@@ -0,0 +1,6 @@
mod interface;
mod model;
pub use interface::{
BilibiliSite, PATTERN, cache_key, enabled, fetch_from_url, is_retryable, media_headers,
};
+124
View File
@@ -0,0 +1,124 @@
//! Serde DTOs for the Bilibili dynamic detail endpoint
//! (`/x/polymer/web-dynamic/v1/detail`), mirroring live responses
//! (field paths verified 2026-09-17). Every field is optional so an API
//! shape change degrades to "no media" instead of a parse failure.
use serde::Deserialize;
#[derive(Deserialize, Debug)]
pub(crate) struct Detail {
/// Business code: `0` = OK, `-352`/`-412` = risk control, `500`/`4101147`
/// = gone.
pub(crate) code: i64,
#[serde(default)]
pub(crate) message: Option<String>,
#[serde(default)]
pub(crate) data: Option<Data>,
}
#[derive(Deserialize, Debug)]
pub(crate) struct Data {
#[serde(default)]
pub(crate) item: Option<Box<Item>>,
}
#[derive(Deserialize, Debug)]
pub(crate) struct Item {
/// The dynamic id, same numeric id as in the URL.
#[serde(default)]
pub(crate) id_str: String,
#[serde(default)]
pub(crate) modules: Option<Modules>,
/// The quoted dynamic when this item is a forward. A forward shell often
/// carries no media of its own — the original holds it.
#[serde(default)]
pub(crate) orig: Option<Box<Item>>,
}
#[derive(Deserialize, Debug)]
pub(crate) struct Modules {
#[serde(default)]
pub(crate) module_author: Option<Author>,
#[serde(default)]
pub(crate) module_dynamic: Option<Dynamic>,
}
#[derive(Deserialize, Debug)]
pub(crate) struct Author {
#[serde(default)]
pub(crate) name: String,
#[serde(default)]
pub(crate) mid: Option<i64>,
}
#[derive(Deserialize, Debug)]
pub(crate) struct Dynamic {
#[serde(default)]
pub(crate) desc: Option<Desc>,
#[serde(default)]
pub(crate) major: Option<Major>,
/// A single topic (`{"id":…,"name":…}`), the dynamic's only tag source.
#[serde(default)]
pub(crate) topic: Option<Topic>,
}
#[derive(Deserialize, Debug)]
pub(crate) struct Desc {
#[serde(default)]
pub(crate) text: String,
}
/// `major` is a tagged union: `type` (`MAJOR_TYPE_DRAW` / `_ARCHIVE` / …)
/// plus one payload object per type. Only the two payloads this adapter reads
/// are modeled; an unknown major simply yields no media.
#[derive(Deserialize, Debug)]
pub(crate) struct Major {
#[serde(default)]
pub(crate) draw: Option<Draw>,
#[serde(default)]
pub(crate) archive: Option<Archive>,
}
#[derive(Deserialize, Debug)]
pub(crate) struct Draw {
#[serde(default)]
pub(crate) items: Vec<Pic>,
}
#[derive(Deserialize, Debug)]
pub(crate) struct Pic {
/// Image URL, served as `http://` — normalized to https by the adapter.
pub(crate) src: String,
}
#[derive(Deserialize, Debug)]
pub(crate) struct Archive {
/// The attached video's cover — the only image an AV dynamic has (the
/// video itself is deliberately not resolved, see the module docs).
#[serde(default)]
pub(crate) cover: Option<String>,
}
#[derive(Deserialize, Debug)]
pub(crate) struct Topic {
#[serde(default)]
pub(crate) name: String,
}
/// Response of the anonymous fingerprint endpoint (`/x/frontend/finger/spi`),
/// the source of the adapter's device cookies.
#[derive(Deserialize, Debug)]
pub(crate) struct Fingerprint {
#[serde(default)]
pub(crate) data: Option<FingerprintData>,
}
#[derive(Deserialize, Debug)]
pub(crate) struct FingerprintData {
/// Sent as the `buvid3` cookie.
#[serde(default, rename = "b_3")]
pub(crate) buvid3: String,
/// Sent as the `buvid4` cookie.
#[serde(default, rename = "b_4")]
pub(crate) buvid4: String,
}
+24 -12
View File
@@ -1,8 +1,8 @@
//! Site fetching dispatcher and unified result types.
//!
//! Dispatch order: twitter → bsky → misskey → pixiv. Each site module
//! exports a `PATTERN`, `enabled()` and `fetch_from_url()`; a future site
//! plugs in by adding one guarded entry in `SITES`.
//! Dispatch order: twitter → bsky → misskey → pixiv → bilibili. Each site
//! module exports a `PATTERN`, `enabled()` and `fetch_from_url()`; a future
//! site plugs in by adding one guarded entry in `SITES`.
use std::future::Future;
use std::pin::Pin;
@@ -13,6 +13,7 @@ use std::time::Duration;
use regex::Regex;
use thiserror::Error;
pub mod bilibili;
pub mod bsky;
pub mod misskey;
pub mod pixiv;
@@ -26,7 +27,8 @@ pub use pixiv::PixivError;
pub struct Fetched {
/// Canonical URL: `x.com/{author}/status/{id}` |
/// `https://www.pixiv.net/artworks/{id}` |
/// `https://bsky.app/profile/{handle}/post/{rkey}`
/// `https://bsky.app/profile/{handle}/post/{rkey}` |
/// `https://www.bilibili.com/opus/{id}`
pub source_url: String,
/// The exact HTML produced by the site's caption().
pub caption: String,
@@ -35,7 +37,7 @@ pub struct Fetched {
pub media: Vec<crate::media::Media>,
/// Spoiler flag for all media of this post.
pub sensitive: bool,
/// Site id (`"twitter"` / `"bsky"` / `"pixiv"`): the single source of
/// Site id (`"twitter"` / `"bsky"` / `"pixiv"` / `"bilibili"`): the single source of
/// truth for site identity — caption-format lookup, cache-key prefix and
/// the SetFormat whitelist all derive from it. Set by the producing site.
pub site_id: &'static str,
@@ -283,7 +285,7 @@ pub(crate) fn log_once_ffmpeg_missing() {
}
/// Site adapter: one impl per supported site (twitter / bsky / misskey /
/// pixiv), registered in `SITES`. All site-specific knowledge — URL pattern,
/// pixiv / bilibili), registered in `SITES`. All site-specific knowledge — URL pattern,
/// cache-key format, fetch, retry policy, media-host headers, startup
/// validation — lives in the site module; the central dispatcher only
/// iterates the registry.
@@ -294,9 +296,9 @@ pub(crate) fn log_once_ffmpeg_missing() {
/// site structs are stateless unit structs, so the boxed futures never
/// borrow from `self` beyond the call's scope.
pub trait Site: Send + Sync {
/// Stable site id (`"twitter"` / `"bsky"` / `"misskey"` / `"pixiv"`):
/// caption-format lookup, cache-key prefixes and the SetFormat whitelist
/// derive from it.
/// Stable site id (`"twitter"` / `"bsky"` / `"misskey"` / `"pixiv"` /
/// `"bilibili"`): caption-format lookup, cache-key prefixes and the
/// SetFormat whitelist derive from it.
fn id(&self) -> &'static str;
/// URL pattern; the dispatcher's first match wins (dispatch order).
fn pattern(&self) -> &'static Regex;
@@ -332,14 +334,15 @@ pub trait Site: Send + Sync {
type SiteFuture<'a, T, E = FetchError> = Pin<Box<dyn Future<Output = Result<T, E>> + Send + 'a>>;
/// The one registry of supported sites, in dispatch order (twitter → bsky →
/// misskey → pixiv). Adding a site = new module + one `Box::new(...)` entry
/// here; the bot crate never lists sites itself.
/// misskey → pixiv → bilibili). Adding a site = new module + one
/// `Box::new(...)` entry here; the bot crate never lists sites itself.
static SITES: LazyLock<Vec<Box<dyn Site>>> = LazyLock::new(|| {
vec![
Box::new(twitter::TwitterSite),
Box::new(bsky::BskySite),
Box::new(misskey::MisskeySite),
Box::new(pixiv::PixivSite),
Box::new(bilibili::BilibiliSite),
]
});
@@ -541,6 +544,10 @@ mod tests {
cache_key("https://bsky.app/profile/handle.example/post/3lorem"),
Some("bsky:handle.example/3lorem".into())
);
assert_eq!(
cache_key("https://t.bilibili.com/1245284537985925159"),
Some("bilibili:1245284537985925159".into())
);
assert_eq!(cache_key("https://example.com/not-a-post"), None);
}
@@ -549,16 +556,21 @@ mod tests {
assert_eq!(site_id_from_key("twitter:123"), "twitter");
assert_eq!(site_id_from_key("pixiv:123"), "pixiv");
assert_eq!(site_id_from_key("bsky:handle.example/3lorem"), "bsky");
assert_eq!(site_id_from_key("bilibili:123"), "bilibili");
assert_eq!(site_id_from_key("unknown:1"), "unknown");
assert_eq!(site_id_from_key("no-colon"), "unknown");
}
#[test]
fn registry_lists_all_sites_in_dispatch_order() {
assert_eq!(site_ids(), vec!["twitter", "bsky", "misskey", "pixiv"]);
assert_eq!(
site_ids(),
vec!["twitter", "bsky", "misskey", "pixiv", "bilibili"]
);
// Enabled sites dispatch; unsupported URLs never match.
assert!(find_site("https://x.com/u/status/1").is_some());
assert!(find_site("https://misskey.io/notes/abc").is_some());
assert!(find_site("https://t.bilibili.com/1245284537985925159").is_some());
assert!(find_site("https://example.com/x").is_none());
// Cache keys are pattern-driven, independent of the enabled() gate
// (pixiv is disabled in tests without PIXIV_REFRESH_TOKEN).
+2 -1
View File
@@ -1,5 +1,6 @@
//! Central env handling. The only other places that read env are
//! `Bot::from_env` (TELOXIDE_TOKEN) and x-media (PIXIV_REFRESH_TOKEN).
//! `Bot::from_env` (TELOXIDE_TOKEN) and x-media (PIXIV_REFRESH_TOKEN,
//! TWITTER_AUTH_TOKEN, BILIBILI_COOKIE).
use std::env;
use std::net::IpAddr;
+4 -4
View File
@@ -279,7 +279,7 @@ pub(crate) async fn execute_command(
bot,
message.chat.id.0,
message.id,
"Unknown site. Use twitter, bsky, pixiv or misskey.",
"Unknown site. Use twitter, bsky, pixiv, misskey or bilibili.",
)
.await?;
return Ok(());
@@ -320,7 +320,7 @@ pub(crate) async fn execute_command(
bot,
message.chat.id.0,
message.id,
"Unrecognized link. Use a twitter/x, pixiv, bsky or misskey post URL.",
"Unrecognized link. Use a twitter/x, pixiv, bsky, misskey or bilibili post URL.",
)
.await?;
return Ok(());
@@ -358,7 +358,7 @@ pub(crate) async fn execute_command(
bot,
message.chat.id.0,
message.id,
"No enabled site matches this link (twitter/x, pixiv, bsky or misskey).",
"No enabled site matches this link (twitter/x, pixiv, bsky, misskey or bilibili).",
)
.await?;
return Ok(());
@@ -400,7 +400,7 @@ pub(crate) async fn execute_command(
bot,
message.chat.id.0,
message.id,
"No enabled site matches this link (twitter/x, pixiv, bsky or misskey).",
"No enabled site matches this link (twitter/x, pixiv, bsky, misskey or bilibili).",
)
.await?;
}
+2 -2
View File
@@ -60,8 +60,8 @@ pub(crate) async fn reply_html(
/// Log prefix tying the whole lifecycle of one link (fetch → send → cache →
/// forward) together: the normalized cache key (`twitter:123…`, `pixiv:123`,
/// `bsky:handle/rkey`) instead of the raw URL, so logs stay short and do not
/// echo full user-submitted URLs at info level.
/// `bsky:handle/rkey`, `bilibili:123…`) instead of the raw URL, so logs stay
/// short and do not echo full user-submitted URLs at info level.
pub fn log_key(url: &str) -> String {
x_media::site::cache_key(url).unwrap_or_else(|| "<unsupported>".to_string())
}
+1 -1
View File
@@ -17,7 +17,7 @@ pub struct ChatData {
pub edit_message: HashMap<i64, EditMessage>,
/// name -> HTML template containing "[]"
pub template: HashMap<String, String>,
/// site name (twitter/bsky/misskey/pixiv) -> user-supplied caption format
/// site name (twitter/bsky/misskey/pixiv/bilibili) -> user-supplied caption format
/// with {url} {author} {author_url} {title} {tags} placeholders.
pub message_format: HashMap<String, String>,
}