From b3e3a3c87c7c3a1d15fb1d7743526a37f9e50f8b Mon Sep 17 00:00:00 2001 From: felis Date: Sun, 19 Jul 2026 22:31:47 -0700 Subject: [PATCH] refactor: reorganize API and WebSocket modules Split client, endpoint, and WebSocket implementations into domain-focused modules while preserving the public API.\n\nImprove crate and API documentation to describe responsibilities, authentication boundaries, and response compatibility. --- src/client/builder.rs | 90 ++ src/client/mod.rs | 33 + src/{client.rs => client/request.rs} | 151 +- src/client/response.rs | 47 + src/endpoints.rs | 628 -------- src/endpoints/comment.rs | 100 ++ src/endpoints/live.rs | 254 +++ src/endpoints/mod.rs | 24 + src/endpoints/search.rs | 58 + src/endpoints/user.rs | 110 ++ src/endpoints/video/content.rs | 43 + src/endpoints/video/interaction.rs | 66 + src/endpoints/video/mod.rs | 25 + src/endpoints/video/player.rs | 25 + src/lib.rs | 17 +- src/websocket.rs | 1935 ----------------------- src/websocket/command/mod.rs | 73 + src/websocket/command/models/danmu.rs | 140 ++ src/websocket/command/models/gift.rs | 147 ++ src/websocket/command/models/like.rs | 119 ++ src/websocket/command/models/mod.rs | 14 + src/websocket/command/models/status.rs | 182 +++ src/websocket/command/parse/common.rs | 70 + src/websocket/command/parse/danmu.rs | 135 ++ src/websocket/command/parse/dispatch.rs | 146 ++ src/websocket/command/parse/gift.rs | 212 +++ src/websocket/command/parse/like.rs | 217 +++ src/websocket/command/parse/mod.rs | 13 + src/websocket/command/parse/status.rs | 89 ++ src/websocket/connection.rs | 130 ++ src/websocket/mod.rs | 20 + src/websocket/packet.rs | 124 ++ src/websocket/tests.rs | 231 +++ 33 files changed, 2956 insertions(+), 2712 deletions(-) create mode 100644 src/client/builder.rs create mode 100644 src/client/mod.rs rename src/{client.rs => client/request.rs} (52%) create mode 100644 src/client/response.rs delete mode 100644 src/endpoints.rs create mode 100644 src/endpoints/comment.rs create mode 100644 src/endpoints/live.rs create mode 100644 src/endpoints/mod.rs create mode 100644 src/endpoints/search.rs create mode 100644 src/endpoints/user.rs create mode 100644 src/endpoints/video/content.rs create mode 100644 src/endpoints/video/interaction.rs create mode 100644 src/endpoints/video/mod.rs create mode 100644 src/endpoints/video/player.rs delete mode 100644 src/websocket.rs create mode 100644 src/websocket/command/mod.rs create mode 100644 src/websocket/command/models/danmu.rs create mode 100644 src/websocket/command/models/gift.rs create mode 100644 src/websocket/command/models/like.rs create mode 100644 src/websocket/command/models/mod.rs create mode 100644 src/websocket/command/models/status.rs create mode 100644 src/websocket/command/parse/common.rs create mode 100644 src/websocket/command/parse/danmu.rs create mode 100644 src/websocket/command/parse/dispatch.rs create mode 100644 src/websocket/command/parse/gift.rs create mode 100644 src/websocket/command/parse/like.rs create mode 100644 src/websocket/command/parse/mod.rs create mode 100644 src/websocket/command/parse/status.rs create mode 100644 src/websocket/connection.rs create mode 100644 src/websocket/mod.rs create mode 100644 src/websocket/packet.rs create mode 100644 src/websocket/tests.rs diff --git a/src/client/builder.rs b/src/client/builder.rs new file mode 100644 index 0000000..226bfd0 --- /dev/null +++ b/src/client/builder.rs @@ -0,0 +1,90 @@ +//! 客户端构建器及客户端创建入口。 + +use super::Client; +use crate::{Credentials, Error, Result}; +use reqwest::header::{ + HeaderMap, HeaderValue, ACCEPT, ACCEPT_LANGUAGE, COOKIE, ORIGIN, REFERER, USER_AGENT, +}; +use std::sync::{Arc, RwLock}; + +const WEB_ORIGIN: &str = "https://www.bilibili.com"; + +/// [`Client`] 的构建器,用于设置凭据和 User-Agent。 +#[derive(Clone, Debug)] +pub struct ClientBuilder { + credentials: Credentials, + user_agent: String, +} + +impl Default for ClientBuilder { + fn default() -> Self { + Self { + credentials: Credentials::default(), + user_agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36".into(), + } + } +} + +impl ClientBuilder { + /// 设置客户端使用的浏览器会话凭据。 + pub fn credentials(mut self, credentials: Credentials) -> Self { + self.credentials = credentials; + self + } + /// 设置请求的 User-Agent。 + pub fn user_agent(mut self, user_agent: impl Into) -> Self { + self.user_agent = user_agent.into(); + self + } + /// 创建客户端及其 HTTP 连接池。 + pub fn build(self) -> Result { + let mut headers = HeaderMap::new(); + headers.insert( + USER_AGENT, + HeaderValue::from_str(&self.user_agent) + .map_err(|_| Error::InvalidPacket("invalid user agent".into()))?, + ); + headers.insert(ORIGIN, HeaderValue::from_static(WEB_ORIGIN)); + headers.insert( + ACCEPT, + HeaderValue::from_static("application/json, text/plain, */*"), + ); + headers.insert( + ACCEPT_LANGUAGE, + HeaderValue::from_static("zh-CN,zh;q=0.9,en;q=0.8"), + ); + headers.insert( + REFERER, + HeaderValue::from_static("https://www.bilibili.com/"), + ); + if let Some(cookie) = self.credentials.cookie_header() { + headers.insert( + COOKIE, + HeaderValue::from_str(&cookie) + .map_err(|_| Error::InvalidPacket("invalid cookie header".into()))?, + ); + } + Ok(Client { + http: reqwest::Client::builder() + .default_headers(headers) + .build()?, + credentials: self.credentials, + wbi_key: Arc::new(RwLock::new(None)), + }) + } +} + +impl Client { + /// 创建客户端构建器。 + pub fn builder() -> ClientBuilder { + ClientBuilder::default() + } + /// 创建不含登录凭据的匿名客户端。 + pub fn anonymous() -> Result { + Self::builder().build() + } + /// 使用给定凭据创建客户端。 + pub fn with_credentials(credentials: Credentials) -> Result { + Self::builder().credentials(credentials).build() + } +} diff --git a/src/client/mod.rs b/src/client/mod.rs new file mode 100644 index 0000000..8f9428c --- /dev/null +++ b/src/client/mod.rs @@ -0,0 +1,33 @@ +//! Bilibili HTTP 客户端与请求执行基础设施。 +//! +//! [Client] 管理连接池、会话凭据及 WBI 签名密钥缓存。具体业务端点由 +//! crate 根目录导出的领域 API 门面提供。 + +mod builder; +mod request; +mod response; + +use crate::{Credentials, WbiKey}; +use std::sync::{Arc, RwLock}; + +pub use builder::ClientBuilder; +pub use response::Response; + +/// Bilibili Web API 的异步客户端。 +/// +/// 客户端可廉价克隆;克隆实例共享 HTTP 连接池以及已缓存的 WBI 签名密钥。凭据不会 +/// 通过 Debug 输出公开。 +#[derive(Clone)] +pub struct Client { + pub(crate) http: reqwest::Client, + pub(crate) credentials: Credentials, + pub(crate) wbi_key: Arc>>, +} + +impl std::fmt::Debug for Client { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Client") + .field("credentials", &"[redacted]") + .finish_non_exhaustive() + } +} diff --git a/src/client.rs b/src/client/request.rs similarity index 52% rename from src/client.rs rename to src/client/request.rs index 0eb0084..9701531 100644 --- a/src/client.rs +++ b/src/client/request.rs @@ -1,157 +1,18 @@ -use crate::{wbi, ApiError, Credentials, Error, Result, WbiKey}; +//! 通用 HTTP 请求、WBI 签名与领域 API 入口。 + +use super::{Client, Response}; +use crate::{wbi, Credentials, Error, Result, WbiKey}; use reqwest::{ - header::{ - HeaderMap, HeaderValue, ACCEPT, ACCEPT_LANGUAGE, COOKIE, ORIGIN, REFERER, USER_AGENT, - }, + header::{ORIGIN, REFERER}, Method, }; use serde::de::DeserializeOwned; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; use serde_json::Value; -use std::sync::{Arc, RwLock}; -const WEB_ORIGIN: &str = "https://www.bilibili.com"; const LIVE_ORIGIN: &str = "https://live.bilibili.com"; -/// Bilibili HTTP API 客户端。 -/// -/// 客户端可廉价克隆,克隆后共享连接池与已缓存的 WBI key。 -#[derive(Clone)] -pub struct Client { - pub(crate) http: reqwest::Client, - pub(crate) credentials: Credentials, - wbi_key: Arc>>, -} - -impl std::fmt::Debug for Client { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("Client") - .field("credentials", &"[redacted]") - .finish_non_exhaustive() - } -} - -/// [`Client`] 的构建器,用于设置凭据和 User-Agent。 -#[derive(Clone, Debug)] -pub struct ClientBuilder { - credentials: Credentials, - user_agent: String, -} - -impl Default for ClientBuilder { - fn default() -> Self { - Self { - credentials: Credentials::default(), - user_agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36".into(), - } - } -} - -impl ClientBuilder { - /// 设置客户端使用的浏览器会话凭据。 - pub fn credentials(mut self, credentials: Credentials) -> Self { - self.credentials = credentials; - self - } - /// 设置请求的 User-Agent。 - pub fn user_agent(mut self, user_agent: impl Into) -> Self { - self.user_agent = user_agent.into(); - self - } - /// 创建客户端及其 HTTP 连接池。 - pub fn build(self) -> Result { - let mut headers = HeaderMap::new(); - headers.insert( - USER_AGENT, - HeaderValue::from_str(&self.user_agent) - .map_err(|_| Error::InvalidPacket("invalid user agent".into()))?, - ); - headers.insert(ORIGIN, HeaderValue::from_static(WEB_ORIGIN)); - headers.insert( - ACCEPT, - HeaderValue::from_static("application/json, text/plain, */*"), - ); - headers.insert( - ACCEPT_LANGUAGE, - HeaderValue::from_static("zh-CN,zh;q=0.9,en;q=0.8"), - ); - headers.insert( - REFERER, - HeaderValue::from_static("https://www.bilibili.com/"), - ); - if let Some(cookie) = self.credentials.cookie_header() { - headers.insert( - COOKIE, - HeaderValue::from_str(&cookie) - .map_err(|_| Error::InvalidPacket("invalid cookie header".into()))?, - ); - } - Ok(Client { - http: reqwest::Client::builder() - .default_headers(headers) - .build()?, - credentials: self.credentials, - wbi_key: Arc::new(RwLock::new(None)), - }) - } -} - -/// Bilibili 常见的 JSON 响应包裹结构。 -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct Response { - /// 业务状态码;`0` 表示成功。 - pub code: i64, - /// 业务错误消息。 - #[serde(default, alias = "msg")] - pub message: String, - /// Bilibili 缓存控制字段。 - #[serde(default)] - pub ttl: i64, - /// 实际业务数据。 - /// - /// 非成功响应通常不包含该字段。 - #[serde(default)] - pub data: Option, -} - -impl Response { - /// 在 `code == 0` 时取出 `data`,否则转换为 [`ApiError`]。 - pub fn into_data(self) -> Result { - if self.code == 0 { - self.data.ok_or_else(|| { - ApiError { - code: self.code, - message: if self.message.is_empty() { - "成功响应缺少 data 字段".into() - } else { - self.message - }, - } - .into() - }) - } else { - Err(ApiError { - code: self.code, - message: self.message, - } - .into()) - } - } -} - impl Client { - /// 创建客户端构建器。 - pub fn builder() -> ClientBuilder { - ClientBuilder::default() - } - /// 创建不含登录凭据的匿名客户端。 - pub fn anonymous() -> Result { - Self::builder().build() - } - /// 使用给定凭据创建客户端。 - pub fn with_credentials(credentials: Credentials) -> Result { - Self::builder().credentials(credentials).build() - } /// 获取客户端保存的凭据引用。 pub fn credentials(&self) -> &Credentials { &self.credentials diff --git a/src/client/response.rs b/src/client/response.rs new file mode 100644 index 0000000..972e878 --- /dev/null +++ b/src/client/response.rs @@ -0,0 +1,47 @@ +//! Bilibili JSON 响应包裹的通用类型。 + +use crate::{ApiError, Result}; +use serde::{Deserialize, Serialize}; + +/// Bilibili 常见的 JSON 响应包裹结构。 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Response { + /// 业务状态码;`0` 表示成功。 + pub code: i64, + /// 业务错误消息。 + #[serde(default, alias = "msg")] + pub message: String, + /// Bilibili 缓存控制字段。 + #[serde(default)] + pub ttl: i64, + /// 实际业务数据。 + /// + /// 非成功响应通常不包含该字段。 + #[serde(default)] + pub data: Option, +} + +impl Response { + /// 在 `code == 0` 时取出 `data`,否则转换为 [`ApiError`]。 + pub fn into_data(self) -> Result { + if self.code == 0 { + self.data.ok_or_else(|| { + ApiError { + code: self.code, + message: if self.message.is_empty() { + "成功响应缺少 data 字段".into() + } else { + self.message + }, + } + .into() + }) + } else { + Err(ApiError { + code: self.code, + message: self.message, + } + .into()) + } + } +} diff --git a/src/endpoints.rs b/src/endpoints.rs deleted file mode 100644 index e2ff8f4..0000000 --- a/src/endpoints.rs +++ /dev/null @@ -1,628 +0,0 @@ -//! 高层接口分组。 -//! -//! 所有快捷方法均返回 `serde_json::Value`:Bilibili 的响应字段变化频繁,这能在无需 -//! 发布新版本时保留新增字段。尚未提供快捷方法的端点可使用 [`crate::Client::get`]、 -//! [`crate::Client::get_wbi`] 与 [`crate::Client::post_form`] 调用。 - -use crate::{Client, Result}; -use serde_json::Value; - -fn q(items: impl IntoIterator) -> Vec<(String, String)> { - items - .into_iter() - .map(|(key, value)| (key.to_owned(), value)) - .collect() -} - -/// 直播间、分区、礼物与粉丝勋章接口组。 -pub struct LiveApi<'a> { - client: &'a Client, -} -impl<'a> LiveApi<'a> { - pub(crate) fn new(client: &'a Client) -> Self { - Self { client } - } - /// 解析直播间 ID,并返回真实房间号、主播 UID 与开播状态。 - pub async fn room_init(&self, room_id: u64) -> Result { - self.client - .get( - "https://api.live.bilibili.com/room/v1/Room/room_init", - q([("id", room_id.to_string())]), - ) - .await - } - /// 获取直播间基础信息。 - pub async fn room_info(&self, room_id: u64) -> Result { - self.client - .get( - "https://api.live.bilibili.com/room/v1/Room/get_info", - q([("room_id", room_id.to_string())]), - ) - .await - } - /// 根据主播 UID 查询其直播间信息。 - pub async fn room_info_by_uid(&self, uid: u64) -> Result { - self.client - .get( - "https://api.live.bilibili.com/room/v1/Room/getRoomInfoOld", - q([("mid", uid.to_string())]), - ) - .await - } - /// 批量获取直播间基础信息。 - pub async fn room_base_info(&self, room_ids: &[u64]) -> Result { - let ids = room_ids - .iter() - .map(u64::to_string) - .collect::>() - .join(","); - self.client - .get( - "https://api.live.bilibili.com/xlive/web-room/v1/index/getRoomBaseInfo", - q([("room_ids", ids), ("req_biz", "web_room_componet".into())]), - ) - .await - } - /// 通过 WBI 签名获取直播间聚合详情。 - pub async fn room_detail(&self, room_id: u64) -> Result { - self.client - .get_wbi( - "https://api.live.bilibili.com/xlive/web-room/v1/index/getInfoByRoom", - q([ - ("room_id", room_id.to_string()), - ("web_location", "444.8".into()), - ]), - ) - .await - } - /// 获取弹幕 WebSocket 的 token 和可用服务器列表。 - pub async fn danmu_info(&self, room_id: u64) -> Result { - self.client - .get_wbi( - "https://api.live.bilibili.com/xlive/web-room/v1/index/getDanmuInfo", - q([ - ("id", room_id.to_string()), - ("type", "0".into()), - ("web_location", "444.8".into()), - ]), - ) - .await - } - /// 获取直播流播放信息与 CDN 候选地址。 - /// - /// `quality` 是期望清晰度,例如 `10000` 表示原画。 - pub async fn play_info(&self, room_id: u64, quality: u32) -> Result { - self.client - .get( - "https://api.live.bilibili.com/xlive/web-room/v2/index/getRoomPlayInfo", - q([ - ("room_id", room_id.to_string()), - ("protocol", "0,1".into()), - ("format", "0,1,2".into()), - ("codec", "0,1,2".into()), - ("qn", quality.to_string()), - ("platform", "web".into()), - ("ptype", "8".into()), - ]), - ) - .await - } - /// 获取全部直播分区。 - pub async fn areas(&self) -> Result { - self.client - .get( - "https://api.live.bilibili.com/xlive/web-interface/v1/index/getWebAreaList", - q([("source_id", "2".into())]), - ) - .await - } - /// 获取指定直播分区内的房间分页列表。 - pub async fn area_rooms( - &self, - parent_area_id: u64, - area_id: u64, - page: u32, - page_size: u32, - ) -> Result { - self.client - .get( - "https://api.live.bilibili.com/room/v3/area/getRoomList", - q([ - ("parent_area_id", parent_area_id.to_string()), - ("area_id", area_id.to_string()), - ("page", page.to_string()), - ("page_size", page_size.to_string()), - ("platform", "web".into()), - ]), - ) - .await - } - /// 获取直播首页推荐列表。 - pub async fn homepage(&self) -> Result { - self.client - .get( - "https://api.live.bilibili.com/xlive/web-interface/v1/webMain/getList", - q([("platform", "web".into())]), - ) - .await - } - /// 获取直播间礼物面板配置。 - pub async fn room_gift_config(&self, room_id: u64) -> Result { - self.client - .get( - "https://api.live.bilibili.com/xlive/web-room/v1/giftPanel/roomGiftConfig", - q([ - ("room_id", room_id.to_string()), - ("platform", "pc".into()), - ("source", "live".into()), - ]), - ) - .await - } - /// 获取直播间可展示的礼物列表。 - pub async fn room_gifts(&self, room_id: u64) -> Result { - self.client - .get( - "https://api.live.bilibili.com/xlive/web-room/v1/giftPanel/roomGiftList", - q([("room_id", room_id.to_string()), ("platform", "pc".into())]), - ) - .await - } - /// 获取直播间贡献榜或在线榜。 - pub async fn contribution_rank( - &self, - room_id: u64, - anchor_uid: u64, - page: u32, - page_size: u32, - ) -> Result { - self.client.get("https://api.live.bilibili.com/xlive/general-interface/v1/rank/queryContributionRank", q([ - ("room_id", room_id.to_string()), ("ruid", anchor_uid.to_string()), ("page", page.to_string()), - ("page_size", page_size.to_string()), ("type", "online_rank".into()), - ])).await - } - /// 获取直播间历史弹幕。 - pub async fn danmu_history(&self, room_id: u64) -> Result { - self.client - .get( - "https://api.live.bilibili.com/xlive/web-room/v1/dM/gethistory", - q([("roomid", room_id.to_string()), ("room_type", "0".into())]), - ) - .await - } - /// 获取直播间主播资料面板。 - pub async fn anchor_in_room(&self, room_id: u64) -> Result { - self.client - .get( - "https://api.live.bilibili.com/live_user/v1/UserInfo/get_anchor_in_room", - q([("roomid", room_id.to_string())]), - ) - .await - } - /// 获取当前登录用户的粉丝勋章列表。 - pub async fn my_medals(&self, page: u32, page_size: u32) -> Result { - self.client - .get( - "https://api.live.bilibili.com/xlive/app-ucenter/v1/user/GetMyMedals", - q([ - ("page", page.to_string()), - ("page_size", page_size.to_string()), - ]), - ) - .await - } - /// 佩戴指定粉丝勋章。 - /// - /// 此操作会改变账号状态,需要登录 Cookie 与 CSRF token。 - pub async fn wear_medal(&self, medal_id: u64) -> Result { - self.client - .post_form( - "https://api.live.bilibili.com/xlive/web-room/v1/fansMedal/wear", - q([("medal_id", medal_id.to_string())]), - ) - .await - } - /// 卸下当前佩戴的粉丝勋章。 - /// - /// 此操作会改变账号状态,需要登录 Cookie 与 CSRF token。 - pub async fn take_off_medal(&self) -> Result { - self.client - .post_form( - "https://api.live.bilibili.com/xlive/web-room/v1/fansMedal/take_off", - Vec::new(), - ) - .await - } - /// 向直播间发送弹幕。 - /// - /// 此操作会改变账号状态,需要登录 Cookie 与 CSRF token。 - pub async fn send_danmu(&self, room_id: u64, message: impl Into) -> Result { - self.client - .post_form( - "https://api.live.bilibili.com/msg/send", - q([ - ("roomid", room_id.to_string()), - ("msg", message.into()), - ("color", "16777215".into()), - ("fontsize", "25".into()), - ("mode", "1".into()), - ( - "rnd", - (std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs()) - .to_string(), - ), - ]), - ) - .await - } -} - -/// 视频详情、播放辅助与互动接口组。 -pub struct VideoApi<'a> { - client: &'a Client, -} -impl<'a> VideoApi<'a> { - pub(crate) fn new(client: &'a Client) -> Self { - Self { client } - } - /// 根据 BV 号获取视频详情。 - pub async fn view_by_bvid(&self, bvid: impl Into) -> Result { - self.client - .get( - "https://api.bilibili.com/x/web-interface/view", - q([("bvid", bvid.into())]), - ) - .await - } - /// 根据 AV 号获取视频详情。 - pub async fn view_by_aid(&self, aid: u64) -> Result { - self.client - .get( - "https://api.bilibili.com/x/web-interface/view", - q([("aid", aid.to_string())]), - ) - .await - } - /// 获取视频的分 P 列表。 - pub async fn pages(&self, bvid: impl Into) -> Result { - self.client - .get( - "https://api.bilibili.com/x/player/pagelist", - q([("bvid", bvid.into())]), - ) - .await - } - /// 获取视频当前在线观看人数。 - pub async fn online_total(&self, aid: u64, cid: u64) -> Result { - self.client - .get( - "https://api.bilibili.com/x/player/online/total", - q([("aid", aid.to_string()), ("cid", cid.to_string())]), - ) - .await - } - /// 获取当前用户与视频的互动关系,例如点赞和收藏状态。 - pub async fn archive_relation(&self, aid: u64) -> Result { - self.client - .get( - "https://api.bilibili.com/x/web-interface/archive/relation", - q([("aid", aid.to_string())]), - ) - .await - } - /// 获取视频标签。 - pub async fn tags(&self, aid: u64) -> Result { - self.client - .get( - "https://api.bilibili.com/x/tag/archive/tags", - q([("aid", aid.to_string())]), - ) - .await - } - /// 获取指定分 P 的字幕元数据。 - pub async fn subtitles(&self, aid: u64, cid: u64) -> Result { - self.client - .get( - "https://api.bilibili.com/x/v2/subtitle/web/view", - q([("aid", aid.to_string()), ("cid", cid.to_string())]), - ) - .await - } - /// 点赞或取消点赞视频。 - /// - /// `like` 为 `true` 时点赞,为 `false` 时取消点赞;需要登录 Cookie 与 CSRF token。 - pub async fn like(&self, aid: u64, like: bool) -> Result { - self.client - .post_form( - "https://api.bilibili.com/x/web-interface/archive/like", - q([ - ("aid", aid.to_string()), - ("like", if like { "1" } else { "2" }.into()), - ]), - ) - .await - } - /// 为视频投币。 - /// - /// `count` 为投币数量,`select_like` 表示是否同时点赞;需要登录 Cookie 与 CSRF token。 - pub async fn coin(&self, aid: u64, count: u8, select_like: bool) -> Result { - self.client - .post_form( - "https://api.bilibili.com/x/web-interface/coin/add", - q([ - ("aid", aid.to_string()), - ("multiply", count.to_string()), - ("select_like", if select_like { "1" } else { "0" }.into()), - ]), - ) - .await - } - /// 修改视频收藏夹归属。 - /// - /// 两个 ID 参数均使用逗号分隔的收藏夹 ID;需要登录 Cookie 与 CSRF token。 - pub async fn favourite( - &self, - aid: u64, - add_media_ids: impl Into, - del_media_ids: impl Into, - ) -> Result { - self.client - .post_form( - "https://api.bilibili.com/x/v3/fav/resource/deal", - q([ - ("rid", aid.to_string()), - ("type", "2".into()), - ("add_media_ids", add_media_ids.into()), - ("del_media_ids", del_media_ids.into()), - ]), - ) - .await - } -} - -/// 用户资料、空间、关系和动态接口组。 -pub struct UserApi<'a> { - client: &'a Client, -} -impl<'a> UserApi<'a> { - pub(crate) fn new(client: &'a Client) -> Self { - Self { client } - } - /// 获取当前登录状态及导航栏用户信息。 - pub async fn nav(&self) -> Result { - self.client - .get("https://api.bilibili.com/x/web-interface/nav", Vec::new()) - .await - } - /// 获取用户基础资料。 - pub async fn profile(&self, uid: u64) -> Result { - self.client - .get( - "https://api.bilibili.com/x/space/acc/info", - q([("mid", uid.to_string())]), - ) - .await - } - /// 通过 WBI 签名获取用户空间投稿列表。 - pub async fn space_videos(&self, uid: u64, page: u32, page_size: u32) -> Result { - self.client - .get_wbi( - "https://api.bilibili.com/x/space/wbi/arc/search", - q([ - ("mid", uid.to_string()), - ("pn", page.to_string()), - ("ps", page_size.to_string()), - ]), - ) - .await - } - /// 获取用户关注列表。 - pub async fn following(&self, uid: u64, page: u32, page_size: u32) -> Result { - self.client - .get( - "https://api.bilibili.com/x/relation/followings", - q([ - ("vmid", uid.to_string()), - ("pn", page.to_string()), - ("ps", page_size.to_string()), - ]), - ) - .await - } - /// 获取用户粉丝列表。 - pub async fn followers(&self, uid: u64, page: u32, page_size: u32) -> Result { - self.client - .get( - "https://api.bilibili.com/x/relation/fans", - q([ - ("vmid", uid.to_string()), - ("pn", page.to_string()), - ("ps", page_size.to_string()), - ]), - ) - .await - } - /// 修改与用户的关系。 - /// - /// `act` 的具体含义由 Bilibili 接口定义;需要登录 Cookie 与 CSRF token。 - pub async fn modify_relation(&self, uid: u64, act: u8) -> Result { - self.client - .post_form( - "https://api.bilibili.com/x/relation/modify", - q([("fid", uid.to_string()), ("act", act.to_string())]), - ) - .await - } - /// 获取用户空间动态列表。 - /// - /// `offset` 应使用上一次响应给出的分页游标;首次查询可传空字符串。 - pub async fn dynamic_space(&self, uid: u64, offset: impl Into) -> Result { - self.client - .get( - "https://api.bilibili.com/x/polymer/web-dynamic/v1/feed/space", - q([ - ("host_mid", uid.to_string()), - ("offset", offset.into()), - ("timezone_offset", "-480".into()), - ]), - ) - .await - } - /// 获取单条动态详情。 - pub async fn dynamic_detail(&self, dynamic_id: impl Into) -> Result { - self.client - .get( - "https://api.bilibili.com/x/polymer/web-dynamic/v1/detail", - q([ - ("id", dynamic_id.into()), - ("timezone_offset", "-480".into()), - ]), - ) - .await - } -} - -/// 视频评论与评论互动接口组。 -pub struct CommentApi<'a> { - client: &'a Client, -} -impl<'a> CommentApi<'a> { - pub(crate) fn new(client: &'a Client) -> Self { - Self { client } - } - /// 获取视频评论分页列表。 - /// - /// `oid` 为视频 AV 号,`sort` 使用 Bilibili 定义的排序值。 - pub async fn list(&self, oid: u64, page: u32, page_size: u32, sort: u8) -> Result { - self.client - .get( - "https://api.bilibili.com/x/v2/reply", - q([ - ("type", "1".into()), - ("oid", oid.to_string()), - ("pn", page.to_string()), - ("ps", page_size.to_string()), - ("sort", sort.to_string()), - ]), - ) - .await - } - /// 通过 WBI 签名获取新版评论主楼列表。 - pub async fn list_wbi(&self, oid: u64, mode: u8) -> Result { - self.client - .get_wbi( - "https://api.bilibili.com/x/v2/reply/wbi/main", - q([ - ("type", "1".into()), - ("oid", oid.to_string()), - ("mode", mode.to_string()), - ]), - ) - .await - } - /// 获取某条主评论下的回复列表。 - pub async fn replies(&self, oid: u64, root_reply_id: u64, page: u32) -> Result { - self.client - .get( - "https://api.bilibili.com/x/v2/reply/reply", - q([ - ("type", "1".into()), - ("oid", oid.to_string()), - ("root", root_reply_id.to_string()), - ("pn", page.to_string()), - ("ps", "20".into()), - ]), - ) - .await - } - /// 发布评论或回复。 - /// - /// 传入 `root_reply_id` 时发布回复;需要登录 Cookie 与 CSRF token。 - pub async fn add( - &self, - oid: u64, - message: impl Into, - root_reply_id: Option, - ) -> Result { - let mut form = q([ - ("type", "1".into()), - ("oid", oid.to_string()), - ("message", message.into()), - ]); - if let Some(root) = root_reply_id { - form.push(("root".into(), root.to_string())); - } - self.client - .post_form("https://api.bilibili.com/x/v2/reply/add", form) - .await - } - /// 对评论执行点赞或取消点赞等动作。 - /// - /// `action` 的具体含义由 Bilibili 接口定义;需要登录 Cookie 与 CSRF token。 - pub async fn action(&self, oid: u64, reply_id: u64, action: u8) -> Result { - self.client - .post_form( - "https://api.bilibili.com/x/v2/reply/action", - q([ - ("type", "1".into()), - ("oid", oid.to_string()), - ("rpid", reply_id.to_string()), - ("action", action.to_string()), - ]), - ) - .await - } -} - -/// 全站搜索和搜索建议接口组。 -pub struct SearchApi<'a> { - client: &'a Client, -} -impl<'a> SearchApi<'a> { - pub(crate) fn new(client: &'a Client) -> Self { - Self { client } - } - /// 通过 WBI 签名进行综合搜索。 - pub async fn all(&self, keyword: impl Into, page: u32) -> Result { - self.client - .get_wbi( - "https://api.bilibili.com/x/web-interface/wbi/search/all/v2", - q([("keyword", keyword.into()), ("page", page.to_string())]), - ) - .await - } - /// 通过 WBI 签名搜索视频。 - pub async fn video(&self, keyword: impl Into, page: u32) -> Result { - self.client - .get_wbi( - "https://api.bilibili.com/x/web-interface/wbi/search/type", - q([ - ("search_type", "video".into()), - ("keyword", keyword.into()), - ("page", page.to_string()), - ]), - ) - .await - } - /// 获取关键词联想建议。 - pub async fn suggest(&self, keyword: impl Into) -> Result { - self.client - .get( - "https://api.bilibili.com/x/web-interface/suggest", - q([("term", keyword.into())]), - ) - .await - } - /// 通过 WBI 签名获取搜索默认词。 - pub async fn default_word(&self) -> Result { - self.client - .get_wbi( - "https://api.bilibili.com/x/web-interface/wbi/search/default", - Vec::new(), - ) - .await - } -} diff --git a/src/endpoints/comment.rs b/src/endpoints/comment.rs new file mode 100644 index 0000000..a930e84 --- /dev/null +++ b/src/endpoints/comment.rs @@ -0,0 +1,100 @@ +//! 评论领域的高层 API。 + +use super::q; +use crate::{Client, Result}; +use serde_json::Value; + +/// 内容评论与评论互动领域的高层接口门面。 +/// +/// 用于读取评论树、分页游标及评论互动状态。发布、删除或变更互动状态的方法需要 +/// 有效会话凭据和 CSRF token。 +pub struct CommentApi<'a> { + client: &'a Client, +} +impl<'a> CommentApi<'a> { + pub(crate) fn new(client: &'a Client) -> Self { + Self { client } + } + /// 获取视频评论分页列表。 + /// + /// `oid` 为视频 AV 号,`sort` 使用 Bilibili 定义的排序值。 + pub async fn list(&self, oid: u64, page: u32, page_size: u32, sort: u8) -> Result { + self.client + .get( + "https://api.bilibili.com/x/v2/reply", + q([ + ("type", "1".into()), + ("oid", oid.to_string()), + ("pn", page.to_string()), + ("ps", page_size.to_string()), + ("sort", sort.to_string()), + ]), + ) + .await + } + /// 通过 WBI 签名获取新版评论主楼列表。 + pub async fn list_wbi(&self, oid: u64, mode: u8) -> Result { + self.client + .get_wbi( + "https://api.bilibili.com/x/v2/reply/wbi/main", + q([ + ("type", "1".into()), + ("oid", oid.to_string()), + ("mode", mode.to_string()), + ]), + ) + .await + } + /// 获取某条主评论下的回复列表。 + pub async fn replies(&self, oid: u64, root_reply_id: u64, page: u32) -> Result { + self.client + .get( + "https://api.bilibili.com/x/v2/reply/reply", + q([ + ("type", "1".into()), + ("oid", oid.to_string()), + ("root", root_reply_id.to_string()), + ("pn", page.to_string()), + ("ps", "20".into()), + ]), + ) + .await + } + /// 发布评论或回复。 + /// + /// 传入 `root_reply_id` 时发布回复;需要登录 Cookie 与 CSRF token。 + pub async fn add( + &self, + oid: u64, + message: impl Into, + root_reply_id: Option, + ) -> Result { + let mut form = q([ + ("type", "1".into()), + ("oid", oid.to_string()), + ("message", message.into()), + ]); + if let Some(root) = root_reply_id { + form.push(("root".into(), root.to_string())); + } + self.client + .post_form("https://api.bilibili.com/x/v2/reply/add", form) + .await + } + /// 对评论执行点赞或取消点赞等动作。 + /// + /// `action` 的具体含义由 Bilibili 接口定义;需要登录 Cookie 与 CSRF token。 + pub async fn action(&self, oid: u64, reply_id: u64, action: u8) -> Result { + self.client + .post_form( + "https://api.bilibili.com/x/v2/reply/action", + q([ + ("type", "1".into()), + ("oid", oid.to_string()), + ("rpid", reply_id.to_string()), + ("action", action.to_string()), + ]), + ) + .await + } +} diff --git a/src/endpoints/live.rs b/src/endpoints/live.rs new file mode 100644 index 0000000..186a079 --- /dev/null +++ b/src/endpoints/live.rs @@ -0,0 +1,254 @@ +//! 直播领域的高层 API。 + +use super::q; +use crate::{Client, Result}; +use serde_json::Value; + +/// 直播领域的高层接口门面。 +/// +/// 覆盖直播间解析与资料、播放信息、分区发现、礼物与粉丝勋章,以及需要认证的直播 +/// 互动操作。方法保留服务端的原始 JSON 响应,避免因字段扩展而丢失数据。 +pub struct LiveApi<'a> { + client: &'a Client, +} +impl<'a> LiveApi<'a> { + pub(crate) fn new(client: &'a Client) -> Self { + Self { client } + } + /// 解析直播间 ID,并返回真实房间号、主播 UID 与开播状态。 + pub async fn room_init(&self, room_id: u64) -> Result { + self.client + .get( + "https://api.live.bilibili.com/room/v1/Room/room_init", + q([("id", room_id.to_string())]), + ) + .await + } + /// 获取直播间基础信息。 + pub async fn room_info(&self, room_id: u64) -> Result { + self.client + .get( + "https://api.live.bilibili.com/room/v1/Room/get_info", + q([("room_id", room_id.to_string())]), + ) + .await + } + /// 根据主播 UID 查询其直播间信息。 + pub async fn room_info_by_uid(&self, uid: u64) -> Result { + self.client + .get( + "https://api.live.bilibili.com/room/v1/Room/getRoomInfoOld", + q([("mid", uid.to_string())]), + ) + .await + } + /// 批量获取直播间基础信息。 + pub async fn room_base_info(&self, room_ids: &[u64]) -> Result { + let ids = room_ids + .iter() + .map(u64::to_string) + .collect::>() + .join(","); + self.client + .get( + "https://api.live.bilibili.com/xlive/web-room/v1/index/getRoomBaseInfo", + q([("room_ids", ids), ("req_biz", "web_room_componet".into())]), + ) + .await + } + /// 通过 WBI 签名获取直播间聚合详情。 + pub async fn room_detail(&self, room_id: u64) -> Result { + self.client + .get_wbi( + "https://api.live.bilibili.com/xlive/web-room/v1/index/getInfoByRoom", + q([ + ("room_id", room_id.to_string()), + ("web_location", "444.8".into()), + ]), + ) + .await + } + /// 获取弹幕 WebSocket 的 token 和可用服务器列表。 + pub async fn danmu_info(&self, room_id: u64) -> Result { + self.client + .get_wbi( + "https://api.live.bilibili.com/xlive/web-room/v1/index/getDanmuInfo", + q([ + ("id", room_id.to_string()), + ("type", "0".into()), + ("web_location", "444.8".into()), + ]), + ) + .await + } + /// 获取直播流播放信息与 CDN 候选地址。 + /// + /// `quality` 是期望清晰度,例如 `10000` 表示原画。 + pub async fn play_info(&self, room_id: u64, quality: u32) -> Result { + self.client + .get( + "https://api.live.bilibili.com/xlive/web-room/v2/index/getRoomPlayInfo", + q([ + ("room_id", room_id.to_string()), + ("protocol", "0,1".into()), + ("format", "0,1,2".into()), + ("codec", "0,1,2".into()), + ("qn", quality.to_string()), + ("platform", "web".into()), + ("ptype", "8".into()), + ]), + ) + .await + } + /// 获取全部直播分区。 + pub async fn areas(&self) -> Result { + self.client + .get( + "https://api.live.bilibili.com/xlive/web-interface/v1/index/getWebAreaList", + q([("source_id", "2".into())]), + ) + .await + } + /// 获取指定直播分区内的房间分页列表。 + pub async fn area_rooms( + &self, + parent_area_id: u64, + area_id: u64, + page: u32, + page_size: u32, + ) -> Result { + self.client + .get( + "https://api.live.bilibili.com/room/v3/area/getRoomList", + q([ + ("parent_area_id", parent_area_id.to_string()), + ("area_id", area_id.to_string()), + ("page", page.to_string()), + ("page_size", page_size.to_string()), + ("platform", "web".into()), + ]), + ) + .await + } + /// 获取直播首页推荐列表。 + pub async fn homepage(&self) -> Result { + self.client + .get( + "https://api.live.bilibili.com/xlive/web-interface/v1/webMain/getList", + q([("platform", "web".into())]), + ) + .await + } + /// 获取直播间礼物面板配置。 + pub async fn room_gift_config(&self, room_id: u64) -> Result { + self.client + .get( + "https://api.live.bilibili.com/xlive/web-room/v1/giftPanel/roomGiftConfig", + q([ + ("room_id", room_id.to_string()), + ("platform", "pc".into()), + ("source", "live".into()), + ]), + ) + .await + } + /// 获取直播间可展示的礼物列表。 + pub async fn room_gifts(&self, room_id: u64) -> Result { + self.client + .get( + "https://api.live.bilibili.com/xlive/web-room/v1/giftPanel/roomGiftList", + q([("room_id", room_id.to_string()), ("platform", "pc".into())]), + ) + .await + } + /// 获取直播间贡献榜或在线榜。 + pub async fn contribution_rank( + &self, + room_id: u64, + anchor_uid: u64, + page: u32, + page_size: u32, + ) -> Result { + self.client.get("https://api.live.bilibili.com/xlive/general-interface/v1/rank/queryContributionRank", q([ + ("room_id", room_id.to_string()), ("ruid", anchor_uid.to_string()), ("page", page.to_string()), + ("page_size", page_size.to_string()), ("type", "online_rank".into()), + ])).await + } + /// 获取直播间历史弹幕。 + pub async fn danmu_history(&self, room_id: u64) -> Result { + self.client + .get( + "https://api.live.bilibili.com/xlive/web-room/v1/dM/gethistory", + q([("roomid", room_id.to_string()), ("room_type", "0".into())]), + ) + .await + } + /// 获取直播间主播资料面板。 + pub async fn anchor_in_room(&self, room_id: u64) -> Result { + self.client + .get( + "https://api.live.bilibili.com/live_user/v1/UserInfo/get_anchor_in_room", + q([("roomid", room_id.to_string())]), + ) + .await + } + /// 获取当前登录用户的粉丝勋章列表。 + pub async fn my_medals(&self, page: u32, page_size: u32) -> Result { + self.client + .get( + "https://api.live.bilibili.com/xlive/app-ucenter/v1/user/GetMyMedals", + q([ + ("page", page.to_string()), + ("page_size", page_size.to_string()), + ]), + ) + .await + } + /// 佩戴指定粉丝勋章。 + /// + /// 此操作会改变账号状态,需要登录 Cookie 与 CSRF token。 + pub async fn wear_medal(&self, medal_id: u64) -> Result { + self.client + .post_form( + "https://api.live.bilibili.com/xlive/web-room/v1/fansMedal/wear", + q([("medal_id", medal_id.to_string())]), + ) + .await + } + /// 卸下当前佩戴的粉丝勋章。 + /// + /// 此操作会改变账号状态,需要登录 Cookie 与 CSRF token。 + pub async fn take_off_medal(&self) -> Result { + self.client + .post_form( + "https://api.live.bilibili.com/xlive/web-room/v1/fansMedal/take_off", + Vec::new(), + ) + .await + } + /// 向直播间发送弹幕。 + /// + /// 此操作会改变账号状态,需要登录 Cookie 与 CSRF token。 + pub async fn send_danmu(&self, room_id: u64, message: impl Into) -> Result { + self.client + .post_form( + "https://api.live.bilibili.com/msg/send", + q([ + ("roomid", room_id.to_string()), + ("msg", message.into()), + ("color", "16777215".into()), + ("fontsize", "25".into()), + ("mode", "1".into()), + ( + "rnd", + (std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs()) + .to_string(), + ), + ]), + ) + .await + } +} diff --git a/src/endpoints/mod.rs b/src/endpoints/mod.rs new file mode 100644 index 0000000..73b5a45 --- /dev/null +++ b/src/endpoints/mod.rs @@ -0,0 +1,24 @@ +//! Bilibili Web API 的高层领域接口。 +//! +//! 各领域门面以稳定的方法签名封装常用端点,返回原始 JSON 值, +//! 以兼容服务端持续演进的响应模式。对于尚未封装的端点,请使用 +//! [Client](crate::Client) 的通用请求方法。 + +mod comment; +mod live; +mod search; +mod user; +mod video; + +pub(crate) fn q(items: impl IntoIterator) -> Vec<(String, String)> { + items + .into_iter() + .map(|(key, value)| (key.to_owned(), value)) + .collect() +} + +pub use comment::CommentApi; +pub use live::LiveApi; +pub use search::SearchApi; +pub use user::UserApi; +pub use video::VideoApi; diff --git a/src/endpoints/search.rs b/src/endpoints/search.rs new file mode 100644 index 0000000..826641d --- /dev/null +++ b/src/endpoints/search.rs @@ -0,0 +1,58 @@ +//! 搜索领域的高层 API。 + +use super::q; +use crate::{Client, Result}; +use serde_json::Value; + +/// 全站检索与搜索建议领域的高层接口门面。 +/// +/// 该门面提供关键词建议和跨内容类型检索;具体筛选项与响应字段由服务端定义并可能 +/// 随时扩展,因此方法返回原始 JSON 数据。 +pub struct SearchApi<'a> { + client: &'a Client, +} +impl<'a> SearchApi<'a> { + pub(crate) fn new(client: &'a Client) -> Self { + Self { client } + } + /// 通过 WBI 签名进行综合搜索。 + pub async fn all(&self, keyword: impl Into, page: u32) -> Result { + self.client + .get_wbi( + "https://api.bilibili.com/x/web-interface/wbi/search/all/v2", + q([("keyword", keyword.into()), ("page", page.to_string())]), + ) + .await + } + /// 通过 WBI 签名搜索视频。 + pub async fn video(&self, keyword: impl Into, page: u32) -> Result { + self.client + .get_wbi( + "https://api.bilibili.com/x/web-interface/wbi/search/type", + q([ + ("search_type", "video".into()), + ("keyword", keyword.into()), + ("page", page.to_string()), + ]), + ) + .await + } + /// 获取关键词联想建议。 + pub async fn suggest(&self, keyword: impl Into) -> Result { + self.client + .get( + "https://api.bilibili.com/x/web-interface/suggest", + q([("term", keyword.into())]), + ) + .await + } + /// 通过 WBI 签名获取搜索默认词。 + pub async fn default_word(&self) -> Result { + self.client + .get_wbi( + "https://api.bilibili.com/x/web-interface/wbi/search/default", + Vec::new(), + ) + .await + } +} diff --git a/src/endpoints/user.rs b/src/endpoints/user.rs new file mode 100644 index 0000000..15e5510 --- /dev/null +++ b/src/endpoints/user.rs @@ -0,0 +1,110 @@ +//! 用户与社交领域的高层 API。 + +use super::q; +use crate::{Client, Result}; +use serde_json::Value; + +/// 用户、空间、关系与动态领域的高层接口门面。 +/// +/// 该门面封装公开资料查询、关系数据、空间内容与动态流等常用接口;涉及用户关系 +/// 修改的操作需要有效会话凭据和 CSRF token。 +pub struct UserApi<'a> { + client: &'a Client, +} +impl<'a> UserApi<'a> { + pub(crate) fn new(client: &'a Client) -> Self { + Self { client } + } + /// 获取当前登录状态及导航栏用户信息。 + pub async fn nav(&self) -> Result { + self.client + .get("https://api.bilibili.com/x/web-interface/nav", Vec::new()) + .await + } + /// 获取用户基础资料。 + pub async fn profile(&self, uid: u64) -> Result { + self.client + .get( + "https://api.bilibili.com/x/space/acc/info", + q([("mid", uid.to_string())]), + ) + .await + } + /// 通过 WBI 签名获取用户空间投稿列表。 + pub async fn space_videos(&self, uid: u64, page: u32, page_size: u32) -> Result { + self.client + .get_wbi( + "https://api.bilibili.com/x/space/wbi/arc/search", + q([ + ("mid", uid.to_string()), + ("pn", page.to_string()), + ("ps", page_size.to_string()), + ]), + ) + .await + } + /// 获取用户关注列表。 + pub async fn following(&self, uid: u64, page: u32, page_size: u32) -> Result { + self.client + .get( + "https://api.bilibili.com/x/relation/followings", + q([ + ("vmid", uid.to_string()), + ("pn", page.to_string()), + ("ps", page_size.to_string()), + ]), + ) + .await + } + /// 获取用户粉丝列表。 + pub async fn followers(&self, uid: u64, page: u32, page_size: u32) -> Result { + self.client + .get( + "https://api.bilibili.com/x/relation/fans", + q([ + ("vmid", uid.to_string()), + ("pn", page.to_string()), + ("ps", page_size.to_string()), + ]), + ) + .await + } + /// 修改与用户的关系。 + /// + /// `act` 的具体含义由 Bilibili 接口定义;需要登录 Cookie 与 CSRF token。 + pub async fn modify_relation(&self, uid: u64, act: u8) -> Result { + self.client + .post_form( + "https://api.bilibili.com/x/relation/modify", + q([("fid", uid.to_string()), ("act", act.to_string())]), + ) + .await + } + /// 获取用户空间动态列表。 + /// + /// `offset` 应使用上一次响应给出的分页游标;首次查询可传空字符串。 + pub async fn dynamic_space(&self, uid: u64, offset: impl Into) -> Result { + self.client + .get( + "https://api.bilibili.com/x/polymer/web-dynamic/v1/feed/space", + q([ + ("host_mid", uid.to_string()), + ("offset", offset.into()), + ("timezone_offset", "-480".into()), + ]), + ) + .await + } + /// 获取单条动态详情。 + pub async fn dynamic_detail(&self, dynamic_id: impl Into) -> Result { + self.client + .get( + "https://api.bilibili.com/x/polymer/web-dynamic/v1/detail", + q([ + ("id", dynamic_id.into()), + ("timezone_offset", "-480".into()), + ]), + ) + .await + } +} diff --git a/src/endpoints/video/content.rs b/src/endpoints/video/content.rs new file mode 100644 index 0000000..6ef2c3e --- /dev/null +++ b/src/endpoints/video/content.rs @@ -0,0 +1,43 @@ +//! 视频资源元数据与分类信息端点。 + +use super::*; + +impl<'a> VideoApi<'a> { + /// 根据 BV 号获取视频详情。 + pub async fn view_by_bvid(&self, bvid: impl Into) -> Result { + self.client + .get( + "https://api.bilibili.com/x/web-interface/view", + q([("bvid", bvid.into())]), + ) + .await + } + /// 根据 AV 号获取视频详情。 + pub async fn view_by_aid(&self, aid: u64) -> Result { + self.client + .get( + "https://api.bilibili.com/x/web-interface/view", + q([("aid", aid.to_string())]), + ) + .await + } + /// 获取视频的分 P 列表。 + pub async fn pages(&self, bvid: impl Into) -> Result { + self.client + .get( + "https://api.bilibili.com/x/player/pagelist", + q([("bvid", bvid.into())]), + ) + .await + } + + /// 获取视频标签。 + pub async fn tags(&self, aid: u64) -> Result { + self.client + .get( + "https://api.bilibili.com/x/tag/archive/tags", + q([("aid", aid.to_string())]), + ) + .await + } +} diff --git a/src/endpoints/video/interaction.rs b/src/endpoints/video/interaction.rs new file mode 100644 index 0000000..5f93328 --- /dev/null +++ b/src/endpoints/video/interaction.rs @@ -0,0 +1,66 @@ +//! 视频互动关系与状态变更端点。 + +use super::*; + +impl<'a> VideoApi<'a> { + /// 获取当前用户与视频的互动关系,例如点赞和收藏状态。 + pub async fn archive_relation(&self, aid: u64) -> Result { + self.client + .get( + "https://api.bilibili.com/x/web-interface/archive/relation", + q([("aid", aid.to_string())]), + ) + .await + } + + /// 点赞或取消点赞视频。 + /// + /// `like` 为 `true` 时点赞,为 `false` 时取消点赞;需要登录 Cookie 与 CSRF token。 + pub async fn like(&self, aid: u64, like: bool) -> Result { + self.client + .post_form( + "https://api.bilibili.com/x/web-interface/archive/like", + q([ + ("aid", aid.to_string()), + ("like", if like { "1" } else { "2" }.into()), + ]), + ) + .await + } + /// 为视频投币。 + /// + /// `count` 为投币数量,`select_like` 表示是否同时点赞;需要登录 Cookie 与 CSRF token。 + pub async fn coin(&self, aid: u64, count: u8, select_like: bool) -> Result { + self.client + .post_form( + "https://api.bilibili.com/x/web-interface/coin/add", + q([ + ("aid", aid.to_string()), + ("multiply", count.to_string()), + ("select_like", if select_like { "1" } else { "0" }.into()), + ]), + ) + .await + } + /// 修改视频收藏夹归属。 + /// + /// 两个 ID 参数均使用逗号分隔的收藏夹 ID;需要登录 Cookie 与 CSRF token。 + pub async fn favourite( + &self, + aid: u64, + add_media_ids: impl Into, + del_media_ids: impl Into, + ) -> Result { + self.client + .post_form( + "https://api.bilibili.com/x/v3/fav/resource/deal", + q([ + ("rid", aid.to_string()), + ("type", "2".into()), + ("add_media_ids", add_media_ids.into()), + ("del_media_ids", del_media_ids.into()), + ]), + ) + .await + } +} diff --git a/src/endpoints/video/mod.rs b/src/endpoints/video/mod.rs new file mode 100644 index 0000000..7609c25 --- /dev/null +++ b/src/endpoints/video/mod.rs @@ -0,0 +1,25 @@ +//! 视频领域的高层 API。 +//! +//! 本模块将资源元数据、播放器辅助信息和会改变用户状态的互动操作分开实现; +//! 它们仍通过统一的 [VideoApi] 门面访问。 + +mod content; +mod interaction; +mod player; + +use super::q; +use crate::{Client, Result}; +use serde_json::Value; + +/// 视频领域的高层接口门面。 +/// +/// 资源元数据、播放器辅助信息和互动操作由内部子模块实现,但均通过此类型访问。 +/// 返回值保留服务端的原始 JSON 结构;会改变用户状态的方法需要有效会话凭据。 +pub struct VideoApi<'a> { + client: &'a Client, +} +impl<'a> VideoApi<'a> { + pub(crate) fn new(client: &'a Client) -> Self { + Self { client } + } +} diff --git a/src/endpoints/video/player.rs b/src/endpoints/video/player.rs new file mode 100644 index 0000000..b86bce0 --- /dev/null +++ b/src/endpoints/video/player.rs @@ -0,0 +1,25 @@ +//! 视频播放器状态与字幕元数据端点。 + +use super::*; + +impl<'a> VideoApi<'a> { + /// 获取视频当前在线观看人数。 + pub async fn online_total(&self, aid: u64, cid: u64) -> Result { + self.client + .get( + "https://api.bilibili.com/x/player/online/total", + q([("aid", aid.to_string()), ("cid", cid.to_string())]), + ) + .await + } + + /// 获取指定分 P 的字幕元数据。 + pub async fn subtitles(&self, aid: u64, cid: u64) -> Result { + self.client + .get( + "https://api.bilibili.com/x/v2/subtitle/web/view", + q([("aid", aid.to_string()), ("cid", cid.to_string())]), + ) + .await + } +} diff --git a/src/lib.rs b/src/lib.rs index a5fe1da..0b698ee 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,8 +1,17 @@ -//! Bilibili Web 与直播接口的异步客户端。 +//! Bilibili Web 与直播接口的异步 Rust 客户端。 //! -//! 本 crate 不实现密码、二维码、验证码或 OAuth 登录流程。请通过 Bilibili 的正常登录页面 -//! 获得会话,再将 `SESSDATA` 和 `bili_jct` 传给 [`Credentials`]。会改变账号状态的 -//! Cookie 请求会自动携带 `csrf` 和 `csrf_token`。 +//! # 设计范围 +//! +//! 本 crate 提供可复用的 HTTP 请求基础设施、WBI 查询签名、会话凭据处理,以及直播 +//! WebSocket 数据包与业务命令解析。常用 Web API 按直播、视频、用户、评论和搜索等 +//! 领域组织;各领域方法返回 [`serde_json::Value`],以兼容服务端持续变化的响应字段。 +//! 调用方也可以使用 [`Client`] 的通用请求方法访问尚未封装的端点。 +//! +//! # 认证与状态变更 +//! +//! 本 crate 不实现密码、二维码、验证码或 OAuth 登录流程。请通过 Bilibili 的正常登录 +//! 流程取得会话,再将 `SESSDATA` 和 `bili_jct` 提供给 [`Credentials`]。需要会话的 +//! 状态变更请求会自动补充 `csrf` 与 `csrf_token`;没有可用 CSRF token 时会返回错误。 //! //! ```no_run //! use libilibili::Client; diff --git a/src/websocket.rs b/src/websocket.rs deleted file mode 100644 index ab18351..0000000 --- a/src/websocket.rs +++ /dev/null @@ -1,1935 +0,0 @@ -//! 直播弹幕 WebSocket 协议支持。 -//! -//! [`LiveWebSocket::connect`] 会通过 WBI 获取直播间短期 token,只连接 Bilibili -//! 弹幕主机,并立即发送操作码为 7 的认证包。 - -use crate::{Client, Error, Result}; -use brotli::Decompressor; -use flate2::read::ZlibDecoder; -use futures_util::{SinkExt, StreamExt}; -use serde::Deserialize; -use serde_json::{json, Value}; -use std::collections::{HashMap, VecDeque}; -use std::io::Read; -use tokio::net::TcpStream; -use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream}; - -const HEADER_LEN: usize = 16; - -/// 一个已解析但尚未按业务类型解释的直播 WebSocket 数据包。 -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Packet { - /// 协议版本:`0` 为 JSON、`1` 为人气值、`2` 为 zlib、`3` 为 brotli。 - pub version: u16, - /// 操作码,例如 `3` 表示人气值、`5` 表示服务端推送、`8` 表示认证回复。 - pub operation: u32, - /// 去除 16 字节包头后的原始数据体。 - pub body: Vec, -} - -/// 由直播 WebSocket 数据包解析出的事件。 -#[derive(Debug, Clone, PartialEq)] -pub enum LiveEvent { - /// 操作码为 5 的命令,例如 `DANMU_MSG`、`SEND_GIFT` 或 `LIVE`。 - /// - /// 命令会按 `cmd` 分发为 [`LiveCommand`] 的强类型变体。 - Command(Box), - /// 服务端返回的直播间人气值。 - Popularity(u32), - /// 操作码为 8 的 JSON 认证结果。 - Auth(Value), - /// 当前未被进一步解释的数据包。 - Unknown(Packet), -} - -/// 直播间推送命令的强类型表示。 -/// -/// 已知命令会反序列化为对应结构体,每个结构体均通过 `extra` 保留未预期字段。 -/// 未识别的命令使用 [`LiveCommand::Unknown`];已识别但字段不符合已知结构的命令 -/// 使用 [`LiveCommand::Invalid`],两者都会保留完整原始 JSON。 -#[derive(Debug, Clone, PartialEq)] -pub enum LiveCommand { - /// 普通弹幕消息。 - Danmu(Box), - /// 礼物消息。 - Gift(Box), - /// 连击送礼消息。 - ComboSend(Box), - /// 用户点赞消息。 - LikeClick(Box), - /// 直播间点赞计数变化消息。 - LikeUpdate(Box), - /// 直播间点赞提示消息。 - LikeNotice(Box), - /// 高能榜人数变化消息。 - OnlineRankCount(Box), - /// 看过人数变化消息。 - WatchedChange(Box), - /// 一批下播房间列表消息。 - StopLiveRoomList(Box), - /// 礼物星球进度消息。 - GiftStarProcess(Box), - /// 高能榜 protobuf 消息的外层载荷。 - OnlineRankV3(Box), - /// 互动 protobuf 消息的外层载荷。 - InteractWordV2(Box), - /// 大航海购买消息。 - GuardBuy(Box), - /// 醒目留言消息。 - SuperChat(Box), - /// 用户进入、关注或分享等互动消息。 - InteractWord(Box), - /// 直播间标题或分区变更消息。 - RoomChange(Box), - /// 直播开始消息。 - Live(Box), - /// 直播结束消息。 - Preparing(Box), - /// crate 当前未支持的命令。 - Unknown { - /// 原始 `cmd` 字段;缺失或非字符串时为 `None`。 - command: Option, - /// 完整原始 JSON。 - raw: Value, - }, - /// 已支持的命令无法反序列化为预期结构。 - Invalid { - /// 服务端原始 `cmd` 字段。 - command: Option, - /// 完整原始 JSON。 - raw: Value, - /// serde 产生的解析错误文本。 - error: String, - }, -} - -/// 普通弹幕消息。 -/// -/// `DANMU_MSG.info` 是异构数组。本类型按文档中的稳定索引进行手工、尽力而为的解析: -/// 可识别内容会映射到具名强类型字段,未知索引与类型不匹配的数据保存在 -/// [`DanmuMessage::unknown_info`],不会导致整条事件被丢弃。 -#[derive(Debug, Clone, PartialEq)] -pub struct DanmuMessage { - /// 服务端原始命令名,可能含有冒号后缀。 - pub cmd: String, - /// 弹幕元信息,例如颜色、发送时间和弹幕类型。 - pub metadata: DanmuMetadata, - /// 弹幕正文。 - pub text: Option, - /// 发送者信息。 - pub sender: DanmuSender, - /// 发送者佩戴的粉丝勋章;没有勋章时为 `None`。 - pub fans_medal: Option, - /// 发送者的用户等级。 - pub user_level: Option, - /// 发送者的头衔。 - pub title: Option, - /// 用户身份或守护等级。 - pub guard_level: Option, - /// 弹幕校验与时间戳信息。 - pub timestamp: Option, - /// 弹幕扩展信息。 - pub extension: Option, - /// `info` 数组中未知的索引或类型不符合预期的原始值。 - pub unknown_info: HashMap, - /// 新版弹幕的辅助字段。 - pub dm_v2: Option, - /// 未被当前版本建模的顶层字段。 - pub extra: HashMap, -} - -/// [`DanmuMessage`] 的元信息。 -#[derive(Debug, Clone, Default, PartialEq)] -pub struct DanmuMetadata { - /// 十进制 RGB 弹幕颜色。 - pub color: Option, - /// 服务端记录的毫秒级时间戳。 - pub sent_at_ms: Option, - /// Bilibili 定义的弹幕类型。 - pub danmu_type: Option, - /// 渐变终止色。 - /// - /// 当前抓取样本为单个十进制颜色值;其他形态保留在 [`DanmuMetadata::extra`]。 - pub gradient_color: Option, - /// 元信息数组中未建模的索引。 - pub extra: HashMap, -} - -/// [`DanmuMessage`] 的发送者信息。 -#[derive(Debug, Clone, Default, PartialEq)] -pub struct DanmuSender { - /// 用户 UID。 - pub uid: Option, - /// 用户昵称。 - pub uname: Option, - /// 是否为房管。 - pub is_admin: Option, - /// 是否为大会员。 - pub is_vip: Option, - /// 是否为年度大会员。 - pub is_svip: Option, - /// 用户等级。 - pub rank: Option, - /// 昵称颜色。 - pub name_color: Option, - /// 发送者数组中未建模的索引。 - pub extra: HashMap, -} - -/// 粉丝勋章信息。 -#[derive(Debug, Clone, Default, PartialEq)] -pub struct FansMedal { - /// 勋章等级。 - pub level: Option, - /// 勋章名称。 - pub name: Option, - /// 主播昵称。 - pub anchor_name: Option, - /// 主播直播间 ID。 - pub anchor_room_id: Option, - /// 勋章颜色。 - pub color: Option, - /// 大航海等级。 - pub guard_level: Option, - /// 勋章数组中未建模的索引。 - pub extra: HashMap, -} - -/// 用户等级信息。 -#[derive(Debug, Clone, Default, PartialEq)] -pub struct UserLevel { - /// 用户等级。 - pub level: Option, - /// 十进制等级颜色。 - pub color: Option, - /// 等级排名文字。 - pub rank_text: Option, - /// 用户等级数组中未建模的索引。 - pub extra: HashMap, -} - -/// 用户头衔信息。 -#[derive(Debug, Clone, Default, PartialEq)] -pub struct UserTitle { - /// 头衔名称。 - pub name: Option, - /// 头衔图标 URL。 - pub icon: Option, - /// 头衔数组中未建模的索引。 - pub extra: HashMap, -} - -/// 弹幕校验与时间戳信息。 -#[derive(Debug, Clone, Default, PartialEq)] -pub struct DanmuTimestamp { - /// 服务端校验字符串。 - pub ct: Option, - /// Unix 秒级时间戳。 - pub ts: Option, - /// 未建模字段。 - pub extra: HashMap, -} - -/// 弹幕的扩展信息。 -#[derive(Debug, Clone, Default, PartialEq)] -pub struct DanmuExtension { - /// Bilibili 在扩展对象中返回的 JSON 字符串。 - pub extra_json: Option, - /// 未建模字段。 - pub extra: HashMap, -} - -/// 礼物消息。 -/// -/// 实际 WebSocket 结构将礼物字段置于 `data` 对象,本类型按该层级手工解析。 -#[derive(Debug, Clone, PartialEq)] -pub struct GiftMessage { - /// 服务端原始命令名。 - pub cmd: String, - /// 礼物业务数据。 - pub data: GiftData, - /// 弹幕区域信息。 - pub danmu: Option, - /// 服务端消息 ID。 - pub message_id: Option, - /// 服务端是否要求客户端确认。 - pub requires_ack: Option, - /// 服务端消息类型。 - pub message_type: Option, - /// 服务端发送时间。 - pub sent_at: Option, - /// 未被当前版本建模的顶层字段。 - pub extra: HashMap, -} - -/// [`GiftMessage`] 的礼物业务数据。 -#[derive(Debug, Clone, Default, PartialEq)] -pub struct GiftData { - /// 送礼用户 UID。 - pub uid: Option, - /// 送礼用户昵称。 - pub uname: Option, - /// 礼物 ID。 - pub gift_id: Option, - /// 礼物名称。 - pub gift_name: Option, - /// 礼物数量。 - pub num: Option, - /// 单个礼物价格。 - pub price: Option, - /// 实付总额。 - pub total_coin: Option, - /// 货币类型,例如 `gold` 或 `silver`。 - pub coin_type: Option, - /// 动作文案。 - pub action: Option, - /// 用户财富等级。 - pub wealth_level: Option, - /// 大航海等级。 - pub guard_level: Option, - /// 粉丝勋章信息。 - pub medal: Option, - /// 未被当前版本建模的 `data` 字段。 - pub extra: HashMap, -} - -/// 礼物消息中的粉丝勋章信息。 -#[derive(Debug, Clone, Default, PartialEq)] -pub struct GiftMedalInfo { - /// 勋章等级。 - pub level: Option, - /// 勋章名称。 - pub name: Option, - /// 勋章所属主播昵称。 - pub anchor_name: Option, - /// 大航海等级。 - pub guard_level: Option, - /// 未被当前版本建模的字段。 - pub extra: HashMap, -} - -/// 礼物消息的弹幕区域信息。 -#[derive(Debug, Clone, Default, PartialEq)] -pub struct GiftDanmu { - /// 弹幕区域。 - pub area: Option, - /// 未被当前版本建模的字段。 - pub extra: HashMap, -} - -/// 连击送礼消息。 -#[derive(Debug, Clone, PartialEq)] -pub struct ComboSendMessage { - /// 服务端原始命令名。 - pub cmd: String, - /// 连击送礼业务数据。 - pub data: ComboSendData, - /// 未被当前版本建模的顶层字段。 - pub extra: HashMap, -} - -/// [`ComboSendMessage`] 的连击送礼业务数据。 -#[derive(Debug, Clone, Default, PartialEq)] -pub struct ComboSendData { - /// 送礼用户 UID。 - pub uid: Option, - /// 送礼用户昵称。 - pub uname: Option, - /// 礼物 ID。 - pub gift_id: Option, - /// 礼物名称。 - pub gift_name: Option, - /// 本次礼物数量。 - pub num: Option, - /// 单个礼物价格。 - pub price: Option, - /// 本次实付总额。 - pub total_coin: Option, - /// 货币类型,例如 `gold`。 - pub coin_type: Option, - /// 动作文案。 - pub action: Option, - /// 连击 ID。 - pub combo_id: Option, - /// 批次连击 ID。 - pub batch_combo_id: Option, - /// 当前连击数量。 - pub combo_num: Option, - /// 当前批次连击数量。 - pub batch_combo_num: Option, - /// 连击累计实付总额。 - pub combo_total_coin: Option, - /// 大航海等级。 - pub guard_level: Option, - /// 粉丝勋章信息。 - pub medal_info: Option, - /// 未被当前版本建模的 `data` 字段。 - pub extra: HashMap, -} - -/// 连击送礼消息中的粉丝勋章信息。 -#[derive(Debug, Clone, Default, PartialEq)] -pub struct ComboSendMedalInfo { - /// 勋章等级。 - pub level: Option, - /// 勋章名称。 - pub name: Option, - /// 勋章所属主播昵称。 - pub anchor_name: Option, - /// 大航海等级。 - pub guard_level: Option, - /// 未被当前版本建模的字段。 - pub extra: HashMap, -} - -/// 用户点赞消息。 -#[derive(Debug, Clone, PartialEq)] -pub struct LikeClickMessage { - /// 服务端原始命令名。 - pub cmd: String, - /// 点赞业务数据。 - pub data: LikeClickData, - /// 未被当前版本建模的顶层字段。 - pub extra: HashMap, -} - -/// [`LikeClickMessage`] 的点赞业务数据。 -#[derive(Debug, Clone, Default, PartialEq)] -pub struct LikeClickData { - /// 点赞用户 UID。 - pub uid: Option, - /// 点赞用户昵称。 - pub uname: Option, - /// 点赞文案。 - pub like_text: Option, - /// 当前点赞计数。 - pub like_count: Option, - /// 是否为点赞操作。 - pub is_like: Option, - /// 点赞图标 URL。 - pub icon: Option, - /// 粉丝勋章信息。 - pub fans_medal: Option, - /// 用户贡献信息。 - pub contribution_info: Option, - /// 用户身份标识。 - pub identities: Vec, - /// 弹幕评分。 - pub dmscore: Option, - /// 未被当前版本建模的 `data` 字段。 - pub extra: HashMap, -} - -/// 点赞消息中的粉丝勋章信息。 -#[derive(Debug, Clone, Default, PartialEq)] -pub struct LikeFansMedalInfo { - /// 勋章等级。 - pub level: Option, - /// 勋章名称。 - pub name: Option, - /// 勋章颜色。 - pub color: Option, - /// 渐变起始颜色。 - pub color_start: Option, - /// 渐变结束颜色。 - pub color_end: Option, - /// 边框颜色。 - pub color_border: Option, - /// 勋章所属直播间 ID。 - pub anchor_room_id: Option, - /// 勋章所属主播昵称。 - pub anchor_name: Option, - /// 大航海等级。 - pub guard_level: Option, - /// 未被当前版本建模的字段。 - pub extra: HashMap, -} - -/// 点赞消息中的用户贡献信息。 -#[derive(Debug, Clone, Default, PartialEq)] -pub struct LikeContributionInfo { - /// 贡献等级。 - pub grade: Option, - /// 未被当前版本建模的字段。 - pub extra: HashMap, -} - -/// 直播间点赞计数变化消息。 -#[derive(Debug, Clone, Default, PartialEq)] -pub struct LikeUpdateMessage { - /// 服务端原始命令名。 - pub cmd: String, - /// 直播间当前点赞计数。 - pub click_count: Option, - /// 未被当前版本建模或类型不符合预期的 `data` 字段。 - pub data_extra: HashMap, - /// 未被当前版本建模的顶层字段。 - pub extra: HashMap, -} - -/// 直播间点赞提示消息。 -#[derive(Debug, Clone, Default, PartialEq)] -pub struct LikeNoticeMessage { - /// 服务端原始命令名。 - pub cmd: String, - /// 直播间当前点赞计数。 - pub like_count: Option, - /// 服务端定义的提示消息类型。 - pub message_type: Option, - /// 组成提示文案的片段。 - pub content_segments: Vec, - /// `content_segments` 中不是对象的原始条目。 - pub unknown_content_segments: HashMap, - /// 未被当前版本建模或类型不符合预期的 `data` 字段。 - pub data_extra: HashMap, - /// 未被当前版本建模的顶层字段。 - pub extra: HashMap, -} - -/// [`LikeNoticeMessage`] 提示文案中的一个片段。 -#[derive(Debug, Clone, Default, PartialEq)] -pub struct LikeNoticeContentSegment { - /// 服务端定义的片段类型。 - pub kind: Option, - /// 片段文本。 - pub text: Option, - /// 未被当前版本建模的字段。 - pub extra: HashMap, -} - -/// 高能榜人数变化消息。 -#[derive(Debug, Clone, Default, PartialEq)] -pub struct OnlineRankCountMessage { - /// 服务端原始命令名。 - pub cmd: String, - /// 高能榜人数。 - pub count: Option, - /// 高能榜人数显示文本。 - pub count_text: Option, - /// 在线人数。 - pub online_count: Option, - /// 在线人数显示文本。 - pub online_count_text: Option, - /// 未被当前版本建模的字段。 - pub extra: HashMap, -} - -/// 看过人数变化消息。 -#[derive(Debug, Clone, Default, PartialEq)] -pub struct WatchedChangeMessage { - /// 服务端原始命令名。 - pub cmd: String, - /// 看过人数。 - pub watched_count: Option, - /// 小字号显示文本。 - pub text_small: Option, - /// 大字号显示文本。 - pub text_large: Option, - /// 未被当前版本建模的字段。 - pub extra: HashMap, -} - -/// 一批下播房间列表消息。 -#[derive(Debug, Clone, Default, PartialEq)] -pub struct StopLiveRoomListMessage { - /// 服务端原始命令名。 - pub cmd: String, - /// 已下播的房间 ID 列表。 - pub room_ids: Vec, - /// 未被当前版本建模的字段。 - pub extra: HashMap, -} - -/// 礼物星球进度消息。 -#[derive(Debug, Clone, Default, PartialEq)] -pub struct GiftStarProcessMessage { - /// 服务端原始命令名。 - pub cmd: String, - /// 当前进度名称。 - pub name: Option, - /// 当前数量。 - pub current: Option, - /// 目标数量。 - pub total: Option, - /// 服务端版本号。 - pub version: Option, - /// 未被当前版本建模的字段。 - pub extra: HashMap, -} - -/// 含 protobuf Base64 载荷的推送消息。 -#[derive(Debug, Clone, Default, PartialEq)] -pub struct ProtobufPayloadMessage { - /// 服务端原始命令名。 - pub cmd: String, - /// Base64 编码的 protobuf 数据。 - pub pb_base64: String, - /// 弹幕评分。 - pub dm_score: Option, - /// 未被当前版本建模的顶层或 `data` 字段。 - pub extra: HashMap, -} - -/// 大航海购买消息。 -#[derive(Debug, Clone, Deserialize, PartialEq)] -pub struct GuardBuyMessage { - /// 服务端原始命令名。 - pub cmd: String, - /// 购买用户 UID。 - pub uid: u64, - /// 购买用户昵称。 - pub username: String, - /// 舰队名称。 - pub gift_name: String, - /// 购买数量。 - pub num: u64, - /// 舰队等级,通常 1=总督、2=提督、3=舰长。 - pub guard_level: u8, - /// 未被当前版本建模的顶层字段。 - #[serde(default, flatten)] - pub extra: HashMap, -} - -/// 醒目留言消息。 -#[derive(Debug, Clone, Deserialize, PartialEq)] -pub struct SuperChatMessage { - /// 服务端原始命令名。 - pub cmd: String, - /// 醒目留言的具体数据。 - pub data: SuperChatData, - /// 未被当前版本建模的顶层字段。 - #[serde(default, flatten)] - pub extra: HashMap, -} - -/// [`SuperChatMessage`] 的业务数据。 -#[derive(Debug, Clone, Deserialize, PartialEq)] -pub struct SuperChatData { - /// 发送用户 UID。 - pub uid: u64, - /// 醒目留言文本。 - pub message: String, - /// 醒目留言价格。 - pub price: u64, - /// 未被当前版本建模的字段。 - #[serde(default, flatten)] - pub extra: HashMap, -} - -/// 用户进入、关注或分享等互动消息。 -#[derive(Debug, Clone, Deserialize, PartialEq)] -pub struct InteractWordMessage { - /// 服务端原始命令名。 - pub cmd: String, - /// 互动事件的具体数据。 - pub data: InteractWordData, - /// 未被当前版本建模的顶层字段。 - #[serde(default, flatten)] - pub extra: HashMap, -} - -/// [`InteractWordMessage`] 的业务数据。 -#[derive(Debug, Clone, Deserialize, PartialEq)] -pub struct InteractWordData { - /// 触发互动的用户 UID。 - pub uid: u64, - /// 触发互动的用户昵称。 - pub uname: String, - /// 互动类型,通常 1=进入、2=关注、3=分享。 - pub msg_type: u8, - /// 未被当前版本建模的字段。 - #[serde(default, flatten)] - pub extra: HashMap, -} - -/// 直播间标题或分区发生变化时的消息。 -#[derive(Debug, Clone, Deserialize, PartialEq)] -pub struct RoomChangeMessage { - /// 服务端原始命令名。 - pub cmd: String, - /// 当前直播标题。 - #[serde(default)] - pub title: Option, - /// 当前子分区名称。 - #[serde(default)] - pub area_name: Option, - /// 当前父分区名称。 - #[serde(default)] - pub parent_area_name: Option, - /// 未被当前版本建模的顶层字段。 - #[serde(default, flatten)] - pub extra: HashMap, -} - -/// 开播或下播状态消息。 -#[derive(Debug, Clone, Deserialize, PartialEq)] -pub struct LiveStatusMessage { - /// 服务端原始命令名。 - pub cmd: String, - /// 直播间 ID;部分推送可能省略该字段。 - #[serde(default)] - pub roomid: Option, - /// 未被当前版本建模的顶层字段。 - #[serde(default, flatten)] - pub extra: HashMap, -} - -/// 将操作码 5 的原始 JSON 按 `cmd` 分发为 [`LiveCommand`]。 -/// -/// 对带冒号后缀的命令(例如 `DANMU_MSG:4:0`)按冒号前的基础命令名分发。 -pub fn parse_command(value: Value) -> LiveCommand { - let command = value.get("cmd").and_then(Value::as_str).map(str::to_owned); - let normalized = command - .as_deref() - .map(|name| name.split(':').next().unwrap_or(name)); - - macro_rules! parse_as { - ($type:ty, $variant:ident) => { - match serde_json::from_value::<$type>(value.clone()) { - Ok(message) => LiveCommand::$variant(Box::new(message)), - Err(error) => LiveCommand::Invalid { - command, - raw: value, - error: error.to_string(), - }, - } - }; - } - - match normalized { - Some("DANMU_MSG") => match parse_danmu(&value) { - Ok(message) => LiveCommand::Danmu(Box::new(message)), - Err(error) => LiveCommand::Invalid { - command, - raw: value, - error, - }, - }, - Some("SEND_GIFT") => match parse_gift(&value) { - Ok(message) => LiveCommand::Gift(Box::new(message)), - Err(error) => LiveCommand::Invalid { - command, - raw: value, - error, - }, - }, - Some("COMBO_SEND") => match parse_combo_send(&value) { - Ok(message) => LiveCommand::ComboSend(Box::new(message)), - Err(error) => LiveCommand::Invalid { - command, - raw: value, - error, - }, - }, - Some("LIKE_INFO_V3_CLICK") => match parse_like_click(&value) { - Ok(message) => LiveCommand::LikeClick(Box::new(message)), - Err(error) => LiveCommand::Invalid { - command, - raw: value, - error, - }, - }, - Some("LIKE_INFO_V3_UPDATE") => match parse_like_update(&value) { - Ok(message) => LiveCommand::LikeUpdate(Box::new(message)), - Err(error) => LiveCommand::Invalid { - command, - raw: value, - error, - }, - }, - Some("LIKE_INFO_V3_NOTICE") => match parse_like_notice(&value) { - Ok(message) => LiveCommand::LikeNotice(Box::new(message)), - Err(error) => LiveCommand::Invalid { - command, - raw: value, - error, - }, - }, - Some("ONLINE_RANK_COUNT") => match parse_online_rank_count(&value) { - Ok(message) => LiveCommand::OnlineRankCount(Box::new(message)), - Err(error) => LiveCommand::Invalid { - command, - raw: value, - error, - }, - }, - Some("WATCHED_CHANGE") => match parse_watched_change(&value) { - Ok(message) => LiveCommand::WatchedChange(Box::new(message)), - Err(error) => LiveCommand::Invalid { - command, - raw: value, - error, - }, - }, - Some("STOP_LIVE_ROOM_LIST") => match parse_stop_live_room_list(&value) { - Ok(message) => LiveCommand::StopLiveRoomList(Box::new(message)), - Err(error) => LiveCommand::Invalid { - command, - raw: value, - error, - }, - }, - Some("WIDGET_GIFT_STAR_PROCESS_V2") => match parse_gift_star_process(&value) { - Ok(message) => LiveCommand::GiftStarProcess(Box::new(message)), - Err(error) => LiveCommand::Invalid { - command, - raw: value, - error, - }, - }, - Some("ONLINE_RANK_V3") => match parse_protobuf_payload(&value) { - Ok(message) => LiveCommand::OnlineRankV3(Box::new(message)), - Err(error) => LiveCommand::Invalid { - command, - raw: value, - error, - }, - }, - Some("INTERACT_WORD_V2") => match parse_protobuf_payload(&value) { - Ok(message) => LiveCommand::InteractWordV2(Box::new(message)), - Err(error) => LiveCommand::Invalid { - command, - raw: value, - error, - }, - }, - Some("GUARD_BUY") => parse_as!(GuardBuyMessage, GuardBuy), - Some("SUPER_CHAT_MESSAGE") => parse_as!(SuperChatMessage, SuperChat), - Some("INTERACT_WORD") => parse_as!(InteractWordMessage, InteractWord), - Some("ROOM_CHANGE") => parse_as!(RoomChangeMessage, RoomChange), - Some("LIVE") => parse_as!(LiveStatusMessage, Live), - Some("PREPARING") => parse_as!(LiveStatusMessage, Preparing), - _ => LiveCommand::Unknown { - command, - raw: value, - }, - } -} - -fn parse_gift(value: &Value) -> std::result::Result { - let data = value - .get("data") - .ok_or_else(|| "SEND_GIFT 缺少 data 字段".to_owned())?; - let data_object = data - .as_object() - .ok_or_else(|| "SEND_GIFT.data 不是对象".to_owned())?; - let medal = data_object.get("medal_info").and_then(parse_gift_medal); - let danmu = value.get("danmu").and_then(parse_gift_danmu); - Ok(GiftMessage { - cmd: value.get("cmd").and_then(value_string).unwrap_or_default(), - data: GiftData { - uid: data_object.get("uid").and_then(value_u64), - uname: data_object.get("uname").and_then(value_string), - gift_id: data_object.get("giftId").and_then(value_u64), - gift_name: data_object.get("giftName").and_then(value_string), - num: data_object.get("num").and_then(value_u64), - price: data_object.get("price").and_then(value_u64), - total_coin: data_object.get("total_coin").and_then(value_u64), - coin_type: data_object.get("coin_type").and_then(value_string), - action: data_object.get("action").and_then(value_string), - wealth_level: data_object.get("wealth_level").and_then(value_u32), - guard_level: data_object.get("guard_level").and_then(value_u8), - medal, - extra: object_extra( - data, - &[ - "uid", - "uname", - "giftId", - "giftName", - "num", - "price", - "total_coin", - "coin_type", - "action", - "wealth_level", - "guard_level", - "medal_info", - ], - ), - }, - danmu, - message_id: value.get("msg_id").and_then(value_lossless_string), - requires_ack: value.get("p_is_ack").and_then(value_bool), - message_type: value.get("p_msg_type").and_then(value_u32), - sent_at: value.get("send_time").and_then(value_u64), - extra: object_extra( - value, - &[ - "cmd", - "data", - "danmu", - "msg_id", - "p_is_ack", - "p_msg_type", - "send_time", - ], - ), - }) -} - -fn parse_gift_medal(value: &Value) -> Option { - let object = value.as_object()?; - Some(GiftMedalInfo { - level: object.get("medal_level").and_then(value_u8), - name: object.get("medal_name").and_then(value_string), - anchor_name: object.get("anchor_uname").and_then(value_string), - guard_level: object.get("guard_level").and_then(value_u8), - extra: object_extra( - value, - &["medal_level", "medal_name", "anchor_uname", "guard_level"], - ), - }) -} - -fn parse_gift_danmu(value: &Value) -> Option { - let object = value.as_object()?; - Some(GiftDanmu { - area: object.get("area").and_then(value_u32), - extra: object_extra(value, &["area"]), - }) -} - -fn parse_combo_send(value: &Value) -> std::result::Result { - let data = command_data(value, "COMBO_SEND")?; - let medal_info = data.get("medal_info").and_then(parse_combo_send_medal); - Ok(ComboSendMessage { - cmd: value.get("cmd").and_then(value_string).unwrap_or_default(), - data: ComboSendData { - uid: data.get("uid").and_then(value_u64), - uname: data.get("uname").and_then(value_string), - gift_id: data - .get("gift_id") - .or_else(|| data.get("giftId")) - .and_then(value_u64), - gift_name: data.get("gift_name").and_then(value_string), - num: data.get("num").and_then(value_u64), - price: data.get("price").and_then(value_u64), - total_coin: data.get("total_coin").and_then(value_u64), - coin_type: data.get("coin_type").and_then(value_string), - action: data.get("action").and_then(value_string), - combo_id: data.get("combo_id").and_then(value_lossless_string), - batch_combo_id: data.get("batch_combo_id").and_then(value_lossless_string), - combo_num: data.get("combo_num").and_then(value_u64), - batch_combo_num: data.get("batch_combo_num").and_then(value_u64), - combo_total_coin: data.get("combo_total_coin").and_then(value_u64), - guard_level: data.get("guard_level").and_then(value_u8), - medal_info, - extra: object_extra_with_invalid( - &Value::Object(data.clone()), - &[ - ("uid", data.get("uid").and_then(value_u64).is_some()), - ("uname", data.get("uname").and_then(value_string).is_some()), - ("gift_id", data.get("gift_id").and_then(value_u64).is_some()), - ("giftId", data.get("giftId").and_then(value_u64).is_some()), - ( - "gift_name", - data.get("gift_name").and_then(value_string).is_some(), - ), - ("num", data.get("num").and_then(value_u64).is_some()), - ("price", data.get("price").and_then(value_u64).is_some()), - ( - "total_coin", - data.get("total_coin").and_then(value_u64).is_some(), - ), - ( - "coin_type", - data.get("coin_type").and_then(value_string).is_some(), - ), - ( - "action", - data.get("action").and_then(value_string).is_some(), - ), - ( - "combo_id", - data.get("combo_id") - .and_then(value_lossless_string) - .is_some(), - ), - ( - "batch_combo_id", - data.get("batch_combo_id") - .and_then(value_lossless_string) - .is_some(), - ), - ( - "combo_num", - data.get("combo_num").and_then(value_u64).is_some(), - ), - ( - "batch_combo_num", - data.get("batch_combo_num").and_then(value_u64).is_some(), - ), - ( - "combo_total_coin", - data.get("combo_total_coin").and_then(value_u64).is_some(), - ), - ( - "guard_level", - data.get("guard_level").and_then(value_u8).is_some(), - ), - ( - "medal_info", - data.get("medal_info") - .and_then(parse_combo_send_medal) - .is_some(), - ), - ], - ), - }, - extra: object_extra(value, &["cmd", "data"]), - }) -} - -fn parse_combo_send_medal(value: &Value) -> Option { - let object = value.as_object()?; - Some(ComboSendMedalInfo { - level: object.get("medal_level").and_then(value_u8), - name: object.get("medal_name").and_then(value_string), - anchor_name: object.get("anchor_uname").and_then(value_string), - guard_level: object.get("guard_level").and_then(value_u8), - extra: object_extra_with_invalid( - value, - &[ - ( - "medal_level", - object.get("medal_level").and_then(value_u8).is_some(), - ), - ( - "medal_name", - object.get("medal_name").and_then(value_string).is_some(), - ), - ( - "anchor_uname", - object.get("anchor_uname").and_then(value_string).is_some(), - ), - ( - "guard_level", - object.get("guard_level").and_then(value_u8).is_some(), - ), - ], - ), - }) -} - -fn parse_like_click(value: &Value) -> std::result::Result { - let data = command_data(value, "LIKE_INFO_V3_CLICK")?; - Ok(LikeClickMessage { - cmd: value.get("cmd").and_then(value_string).unwrap_or_default(), - data: LikeClickData { - uid: data.get("uid").and_then(value_u64), - uname: data.get("uname").and_then(value_string), - like_text: data.get("like_text").and_then(value_string), - like_count: data.get("like_count").and_then(value_u64), - is_like: data.get("is_like").and_then(value_bool), - icon: data.get("icon").and_then(value_string), - fans_medal: data.get("fans_medal").and_then(parse_like_fans_medal), - contribution_info: data - .get("contribution_info") - .and_then(parse_like_contribution_info), - identities: data - .get("identities") - .and_then(Value::as_array) - .cloned() - .unwrap_or_default(), - dmscore: data.get("dmscore").and_then(value_u64), - extra: object_extra_with_invalid( - &Value::Object(data.clone()), - &[ - ("uid", data.get("uid").and_then(value_u64).is_some()), - ("uname", data.get("uname").and_then(value_string).is_some()), - ( - "like_text", - data.get("like_text").and_then(value_string).is_some(), - ), - ( - "like_count", - data.get("like_count").and_then(value_u64).is_some(), - ), - ( - "is_like", - data.get("is_like").and_then(value_bool).is_some(), - ), - ("icon", data.get("icon").and_then(value_string).is_some()), - ( - "fans_medal", - data.get("fans_medal") - .and_then(parse_like_fans_medal) - .is_some(), - ), - ( - "contribution_info", - data.get("contribution_info") - .and_then(parse_like_contribution_info) - .is_some(), - ), - ( - "identities", - data.get("identities").and_then(Value::as_array).is_some(), - ), - ("dmscore", data.get("dmscore").and_then(value_u64).is_some()), - ], - ), - }, - extra: object_extra(value, &["cmd", "data"]), - }) -} - -fn parse_like_fans_medal(value: &Value) -> Option { - let object = value.as_object()?; - Some(LikeFansMedalInfo { - level: object.get("medal_level").and_then(value_u8), - name: object.get("medal_name").and_then(value_string), - color: object.get("medal_color").and_then(value_u32), - color_start: object.get("medal_color_start").and_then(value_u32), - color_end: object.get("medal_color_end").and_then(value_u32), - color_border: object.get("medal_color_border").and_then(value_u32), - anchor_room_id: object.get("anchor_roomid").and_then(value_u64), - anchor_name: object.get("anchor_uname").and_then(value_string), - guard_level: object.get("guard_level").and_then(value_u8), - extra: object_extra_with_invalid( - value, - &[ - ( - "medal_level", - object.get("medal_level").and_then(value_u8).is_some(), - ), - ( - "medal_name", - object.get("medal_name").and_then(value_string).is_some(), - ), - ( - "medal_color", - object.get("medal_color").and_then(value_u32).is_some(), - ), - ( - "medal_color_start", - object - .get("medal_color_start") - .and_then(value_u32) - .is_some(), - ), - ( - "medal_color_end", - object.get("medal_color_end").and_then(value_u32).is_some(), - ), - ( - "medal_color_border", - object - .get("medal_color_border") - .and_then(value_u32) - .is_some(), - ), - ( - "anchor_roomid", - object.get("anchor_roomid").and_then(value_u64).is_some(), - ), - ( - "anchor_uname", - object.get("anchor_uname").and_then(value_string).is_some(), - ), - ( - "guard_level", - object.get("guard_level").and_then(value_u8).is_some(), - ), - ], - ), - }) -} - -fn parse_like_contribution_info(value: &Value) -> Option { - let object = value.as_object()?; - Some(LikeContributionInfo { - grade: object.get("grade").and_then(value_u64), - extra: object_extra_with_invalid( - value, - &[("grade", object.get("grade").and_then(value_u64).is_some())], - ), - }) -} - -fn parse_like_update(value: &Value) -> std::result::Result { - let data = command_data(value, "LIKE_INFO_V3_UPDATE")?; - Ok(LikeUpdateMessage { - cmd: value.get("cmd").and_then(value_string).unwrap_or_default(), - click_count: data.get("click_count").and_then(value_u64), - data_extra: object_extra_with_invalid( - &Value::Object(data.clone()), - &[( - "click_count", - data.get("click_count").and_then(value_u64).is_some(), - )], - ), - extra: object_extra(value, &["cmd", "data"]), - }) -} - -fn parse_like_notice(value: &Value) -> std::result::Result { - let data = command_data(value, "LIKE_INFO_V3_NOTICE")?; - let segments = data - .get("content_segments") - .and_then(Value::as_array) - .cloned() - .unwrap_or_default(); - Ok(LikeNoticeMessage { - cmd: value.get("cmd").and_then(value_string).unwrap_or_default(), - like_count: data.get("like_count").and_then(value_u64), - message_type: data.get("msg_type").and_then(value_u8), - content_segments: segments - .iter() - .filter_map(parse_like_notice_content_segment) - .collect(), - unknown_content_segments: segments - .iter() - .enumerate() - .filter(|(_, segment)| !segment.is_object()) - .map(|(index, segment)| (index, segment.clone())) - .collect(), - data_extra: object_extra_with_invalid( - &Value::Object(data.clone()), - &[ - ( - "like_count", - data.get("like_count").and_then(value_u64).is_some(), - ), - ( - "msg_type", - data.get("msg_type").and_then(value_u8).is_some(), - ), - ( - "content_segments", - data.get("content_segments") - .and_then(Value::as_array) - .is_some(), - ), - ], - ), - extra: object_extra(value, &["cmd", "data"]), - }) -} - -fn parse_like_notice_content_segment(value: &Value) -> Option { - let object = value.as_object()?; - Some(LikeNoticeContentSegment { - kind: object.get("type").and_then(value_u8), - text: object.get("text").and_then(value_string), - extra: object_extra_with_invalid( - value, - &[ - ("type", object.get("type").and_then(value_u8).is_some()), - ("text", object.get("text").and_then(value_string).is_some()), - ], - ), - }) -} - -fn parse_online_rank_count(value: &Value) -> std::result::Result { - let data = command_data(value, "ONLINE_RANK_COUNT")?; - Ok(OnlineRankCountMessage { - cmd: value.get("cmd").and_then(value_string).unwrap_or_default(), - count: data.get("count").and_then(value_u64), - count_text: data.get("count_text").and_then(value_string), - online_count: data.get("online_count").and_then(value_u64), - online_count_text: data.get("online_count_text").and_then(value_string), - extra: object_extra(value, &["cmd", "data"]), - }) -} - -fn parse_watched_change(value: &Value) -> std::result::Result { - let data = command_data(value, "WATCHED_CHANGE")?; - Ok(WatchedChangeMessage { - cmd: value.get("cmd").and_then(value_string).unwrap_or_default(), - watched_count: data.get("num").and_then(value_u64), - text_small: data.get("text_small").and_then(value_string), - text_large: data.get("text_large").and_then(value_string), - extra: object_extra(value, &["cmd", "data"]), - }) -} - -fn parse_stop_live_room_list( - value: &Value, -) -> std::result::Result { - let data = command_data(value, "STOP_LIVE_ROOM_LIST")?; - Ok(StopLiveRoomListMessage { - cmd: value.get("cmd").and_then(value_string).unwrap_or_default(), - room_ids: data - .get("room_id_list") - .and_then(Value::as_array) - .into_iter() - .flatten() - .filter_map(value_u64) - .collect(), - extra: object_extra(value, &["cmd", "data"]), - }) -} - -fn parse_gift_star_process(value: &Value) -> std::result::Result { - let data = command_data(value, "WIDGET_GIFT_STAR_PROCESS_V2")?; - Ok(GiftStarProcessMessage { - cmd: value.get("cmd").and_then(value_string).unwrap_or_default(), - name: data.get("name").and_then(value_string), - current: data.get("cur_num").and_then(value_u64), - total: data.get("total_num").and_then(value_u64), - version: data.get("version").and_then(value_u64), - extra: object_extra(value, &["cmd", "data"]), - }) -} - -fn parse_protobuf_payload(value: &Value) -> std::result::Result { - let data = command_data(value, "protobuf 命令")?; - let pb_base64 = data - .get("pb") - .and_then(value_string) - .ok_or_else(|| "protobuf 命令缺少 data.pb".to_owned())?; - Ok(ProtobufPayloadMessage { - cmd: value.get("cmd").and_then(value_string).unwrap_or_default(), - pb_base64, - dm_score: data.get("dmscore").and_then(value_u64), - extra: object_extra(value, &["cmd", "data"]), - }) -} - -fn command_data<'a>( - value: &'a Value, - command: &str, -) -> std::result::Result<&'a serde_json::Map, String> { - value - .get("data") - .and_then(Value::as_object) - .ok_or_else(|| format!("{command} 缺少对象类型的 data 字段")) -} - -fn parse_danmu(value: &Value) -> std::result::Result { - let info = value - .get("info") - .and_then(Value::as_array) - .ok_or_else(|| "DANMU_MSG 缺少数组类型的 info 字段".to_owned())?; - let mut unknown_info = indexed_extra(info, &[0, 1, 2, 3, 4, 5, 7, 9, 15]); - - let metadata = parse_index(info, 0, &mut unknown_info, parse_metadata).unwrap_or_default(); - let text = parse_index(info, 1, &mut unknown_info, value_string); - let sender = parse_index(info, 2, &mut unknown_info, parse_sender).unwrap_or_default(); - let fans_medal = parse_fans_medal_index(info, &mut unknown_info); - let user_level = parse_index(info, 4, &mut unknown_info, parse_user_level); - let title = parse_index(info, 5, &mut unknown_info, parse_user_title); - let guard_level = parse_index(info, 7, &mut unknown_info, value_u8); - let timestamp = parse_index(info, 9, &mut unknown_info, parse_timestamp); - let extension = parse_index(info, 15, &mut unknown_info, parse_extension); - - Ok(DanmuMessage { - cmd: value_string(value.get("cmd").unwrap_or(&Value::Null)).unwrap_or_default(), - metadata, - text, - sender, - fans_medal, - user_level, - title, - guard_level, - timestamp, - extension, - unknown_info, - dm_v2: value.get("dm_v2").and_then(value_string), - extra: object_extra(value, &["cmd", "info", "dm_v2"]), - }) -} - -fn parse_index( - values: &[Value], - index: usize, - unknown: &mut HashMap, - parser: impl FnOnce(&Value) -> Option, -) -> Option { - let value = values.get(index)?; - match parser(value) { - Some(parsed) => Some(parsed), - None => { - unknown.insert(index, value.clone()); - None - } - } -} - -fn parse_fans_medal_index( - values: &[Value], - unknown: &mut HashMap, -) -> Option { - let value = values.get(3)?; - let Some(entries) = value.as_array() else { - unknown.insert(3, value.clone()); - return None; - }; - (!entries.is_empty()).then(|| FansMedal { - level: entries.first().and_then(value_u8), - name: entries.get(1).and_then(value_string), - anchor_name: entries.get(2).and_then(value_string), - anchor_room_id: entries.get(3).and_then(value_u64), - color: entries.get(4).and_then(value_u32), - guard_level: entries.get(10).and_then(value_u8), - extra: indexed_extra(entries, &[0, 1, 2, 3, 4, 10]), - }) -} - -fn parse_metadata(value: &Value) -> Option { - let entries = value.as_array()?; - Some(DanmuMetadata { - color: entries.get(3).and_then(value_u32), - sent_at_ms: entries.get(4).and_then(value_u64), - danmu_type: entries.get(9).and_then(value_u8), - gradient_color: entries.get(11).and_then(value_u32), - extra: indexed_extra(entries, &[3, 4, 9]), - }) -} - -fn parse_sender(value: &Value) -> Option { - let entries = value.as_array()?; - Some(DanmuSender { - uid: entries.first().and_then(value_u64), - uname: entries.get(1).and_then(value_string), - is_admin: entries.get(2).and_then(value_bool), - is_vip: entries.get(3).and_then(value_bool), - is_svip: entries.get(4).and_then(value_bool), - rank: entries.get(5).and_then(value_u32), - name_color: entries.get(7).and_then(value_string), - extra: indexed_extra(entries, &[0, 1, 2, 3, 4, 5, 7]), - }) -} - -fn parse_user_level(value: &Value) -> Option { - let entries = value.as_array()?; - Some(UserLevel { - level: entries.first().and_then(value_u8), - color: entries.get(2).and_then(value_u32), - rank_text: entries.get(3).and_then(value_string), - extra: indexed_extra(entries, &[0, 3]), - }) -} - -fn parse_user_title(value: &Value) -> Option { - let entries = value.as_array()?; - Some(UserTitle { - name: entries.first().and_then(value_string), - icon: entries.get(1).and_then(value_string), - extra: indexed_extra(entries, &[0, 1]), - }) -} - -fn parse_timestamp(value: &Value) -> Option { - value.as_object().map(|object| DanmuTimestamp { - ct: object.get("ct").and_then(value_string), - ts: object.get("ts").and_then(value_u64), - extra: object_extra(value, &["ct", "ts"]), - }) -} - -fn parse_extension(value: &Value) -> Option { - value.as_object().map(|object| DanmuExtension { - extra_json: object.get("extra").and_then(value_string), - extra: object_extra(value, &["extra"]), - }) -} - -fn indexed_extra(values: &[Value], known: &[usize]) -> HashMap { - values - .iter() - .enumerate() - .filter(|(index, _)| !known.contains(index)) - .map(|(index, value)| (index, value.clone())) - .collect() -} - -fn object_extra(value: &Value, known: &[&str]) -> HashMap { - value - .as_object() - .into_iter() - .flatten() - .filter(|(key, _)| !known.contains(&key.as_str())) - .map(|(key, value)| (key.clone(), value.clone())) - .collect() -} - -fn object_extra_with_invalid( - value: &Value, - parsed_fields: &[(&str, bool)], -) -> HashMap { - value - .as_object() - .into_iter() - .flatten() - .filter(|(key, _)| { - !parsed_fields - .iter() - .any(|(name, parsed)| key == name && *parsed) - }) - .map(|(key, value)| (key.clone(), value.clone())) - .collect() -} - -fn value_string(value: &Value) -> Option { - value.as_str().map(str::to_owned) -} - -fn value_lossless_string(value: &Value) -> Option { - value_string(value).or_else(|| value_u64(value).map(|number| number.to_string())) -} - -fn value_u64(value: &Value) -> Option { - value - .as_u64() - .or_else(|| value.as_str().and_then(|text| text.parse().ok())) -} - -fn value_u32(value: &Value) -> Option { - value_u64(value).and_then(|number| number.try_into().ok()) -} - -fn value_u8(value: &Value) -> Option { - value_u64(value).and_then(|number| number.try_into().ok()) -} - -fn value_bool(value: &Value) -> Option { - value.as_bool().or_else(|| match value_u64(value) { - Some(0) => Some(false), - Some(1) => Some(true), - _ => None, - }) -} - -/// 已认证的直播弹幕 WebSocket 连接。 -pub struct LiveWebSocket { - stream: WebSocketStream>, - pending: VecDeque, -} - -impl LiveWebSocket { - /// 建立直播弹幕连接并完成认证。 - /// - /// 会从导航接口读取当前登录用户 UID、请求弹幕 token、选择第一个 WSS 主机, - /// 并发送操作码为 7 的认证包。当前网页协议通常要求有效的登录 Cookie。 - pub async fn connect(client: &Client, room_id: u64) -> Result { - let nav = client.user().nav().await?; - let uid = nav - .get("mid") - .and_then(Value::as_u64) - .ok_or_else(|| Error::InvalidPacket("导航响应未包含当前用户 UID".into()))?; - Self::connect_with_uid(client, room_id, uid).await - } - - /// 使用已知用户 UID 建立直播弹幕连接并完成认证。 - /// - /// 一般应优先使用 [`LiveWebSocket::connect`],仅在调用方已通过可信方式取得 UID - /// 时使用此方法。 - pub async fn connect_with_uid(client: &Client, room_id: u64, uid: u64) -> Result { - let info = client.live().danmu_info(room_id).await?; - let token = info - .get("token") - .and_then(Value::as_str) - .ok_or_else(|| Error::InvalidPacket("danmu response has no token".into()))?; - let host = info - .pointer("/host_list/0/host") - .and_then(Value::as_str) - .ok_or_else(|| Error::InvalidPacket("danmu response has no host".into()))?; - let port = info - .pointer("/host_list/0/wss_port") - .and_then(Value::as_u64) - .unwrap_or(443); - if !host.ends_with(".chat.bilibili.com") { - return Err(Error::InvalidPacket(format!( - "refusing non-Bilibili chat host: {host}" - ))); - } - let (stream, _) = connect_async(format!("wss://{host}:{port}/sub")) - .await - .map_err(Error::websocket)?; - let mut socket = Self { - stream, - pending: VecDeque::new(), - }; - let auth = json!({"uid": uid, "roomid": room_id, "protover": 3, "platform": "web", "type": 2, "key": token}); - socket.send_packet(7, auth.to_string().as_bytes()).await?; - Ok(socket) - } - - /// 发送心跳包。 - /// - /// 调用方应约每 30 秒调用一次,以维持连接。 - pub async fn heartbeat(&mut self) -> Result<()> { - self.send_packet(2, &[]).await - } - - /// 等待并返回下一个直播事件。 - /// - /// 连接被服务器关闭时返回 `Ok(None)`。 - pub async fn next_event(&mut self) -> Result> { - if let Some(event) = self.pending.pop_front() { - return Ok(Some(event)); - } - while let Some(message) = self.stream.next().await { - match message.map_err(Error::websocket)? { - Message::Binary(bytes) => { - self.pending - .extend(events_from_packets(&decode_packets(&bytes)?)?); - if let Some(event) = self.pending.pop_front() { - return Ok(Some(event)); - } - } - Message::Close(_) => return Ok(None), - Message::Ping(payload) => self - .stream - .send(Message::Pong(payload)) - .await - .map_err(Error::websocket)?, - _ => {} - } - } - Ok(None) - } - - /// 等待下一个原始 WebSocket 二进制帧。 - /// - /// 返回的字节尚未经过协议包拆分、zlib/brotli 解压或 JSON 解析,适合抓取和离线 - /// 分析。此方法会自动响应 WebSocket Ping;不要与 [`LiveWebSocket::next_event`] - /// 交替调用,否则事件顺序难以推断。连接关闭时返回 `Ok(None)`。 - pub async fn next_raw_frame(&mut self) -> Result>> { - while let Some(message) = self.stream.next().await { - match message.map_err(Error::websocket)? { - Message::Binary(bytes) => return Ok(Some(bytes.to_vec())), - Message::Close(_) => return Ok(None), - Message::Ping(payload) => self - .stream - .send(Message::Pong(payload)) - .await - .map_err(Error::websocket)?, - _ => {} - } - } - Ok(None) - } - - async fn send_packet(&mut self, operation: u32, body: &[u8]) -> Result<()> { - self.stream - .send(Message::Binary(encode_packet(operation, body).into())) - .await - .map_err(Error::websocket)?; - Ok(()) - } -} - -/// 解码一个 WebSocket 二进制帧中的一个或多个协议包。 -/// -/// 压缩包的数据仍保留在 [`Packet::body`] 中;[`LiveWebSocket::next_event`] 会递归 -/// 解压 zlib 与 brotli 包并转换成事件。 -pub fn decode_packets(bytes: &[u8]) -> Result> { - let mut packets = Vec::new(); - let mut offset = 0; - while offset < bytes.len() { - if bytes.len() - offset < HEADER_LEN { - return Err(Error::InvalidPacket("truncated header".into())); - } - let packet_len = u32::from_be_bytes(bytes[offset..offset + 4].try_into().unwrap()) as usize; - let header_len = - u16::from_be_bytes(bytes[offset + 4..offset + 6].try_into().unwrap()) as usize; - if packet_len < HEADER_LEN - || header_len < HEADER_LEN - || header_len > packet_len - || offset + packet_len > bytes.len() - { - return Err(Error::InvalidPacket("invalid packet length".into())); - } - let version = u16::from_be_bytes(bytes[offset + 6..offset + 8].try_into().unwrap()); - let operation = u32::from_be_bytes(bytes[offset + 8..offset + 12].try_into().unwrap()); - packets.push(Packet { - version, - operation, - body: bytes[offset + header_len..offset + packet_len].to_vec(), - }); - offset += packet_len; - } - Ok(packets) -} - -/// 解码一个原始 WebSocket 二进制帧中的全部直播事件。 -/// -/// 本函数会拆分协议包,并递归解压 zlib 和 brotli 载荷,再将操作码为 5 的消息分发为 -/// [`LiveCommand`]。它适合离线分析 [`crate` 提供的抓取工具](crate::websocket) 输出的 -/// 原始帧。 -pub fn decode_events(bytes: &[u8]) -> Result> { - events_from_packets(&decode_packets(bytes)?) -} - -fn events_from_packets(packets: &[Packet]) -> Result> { - let mut events = Vec::new(); - for packet in packets { - match packet.version { - 2 => { - let mut decoder = ZlibDecoder::new(packet.body.as_slice()); - let mut nested = Vec::new(); - decoder.read_to_end(&mut nested)?; - events.extend(events_from_packets(&decode_packets(&nested)?)?); - } - 3 => { - let mut decoder = Decompressor::new(packet.body.as_slice(), 4096); - let mut nested = Vec::new(); - decoder.read_to_end(&mut nested)?; - events.extend(events_from_packets(&decode_packets(&nested)?)?); - } - _ if packet.operation == 3 && packet.body.len() >= 4 => events.push( - LiveEvent::Popularity(u32::from_be_bytes(packet.body[..4].try_into().unwrap())), - ), - _ if packet.operation == 5 || packet.operation == 8 => { - let value = serde_json::from_slice(&packet.body)?; - events.push(if packet.operation == 8 { - LiveEvent::Auth(value) - } else { - LiveEvent::Command(Box::new(parse_command(value))) - }); - } - _ => events.push(LiveEvent::Unknown(packet.clone())), - } - } - Ok(events) -} - -fn encode_packet(operation: u32, body: &[u8]) -> Vec { - let mut packet = Vec::with_capacity(HEADER_LEN + body.len()); - packet.extend_from_slice(&((HEADER_LEN + body.len()) as u32).to_be_bytes()); - packet.extend_from_slice(&(HEADER_LEN as u16).to_be_bytes()); - packet.extend_from_slice(&1u16.to_be_bytes()); - packet.extend_from_slice(&operation.to_be_bytes()); - packet.extend_from_slice(&1u32.to_be_bytes()); - packet.extend_from_slice(body); - packet -} - -#[cfg(test)] -mod tests { - use super::*; - #[test] - fn parses_packet_and_popularity() { - let packet = encode_packet(3, &42u32.to_be_bytes()); - let decoded = decode_packets(&packet).unwrap(); - assert_eq!( - events_from_packets(&decoded).unwrap(), - vec![LiveEvent::Popularity(42)] - ); - } - #[test] - fn rejects_truncated_packet() { - assert!(decode_packets(&[0; 15]).is_err()); - } - - #[test] - fn dispatches_known_command_and_keeps_extra_fields() { - let command = parse_command(json!({ - "cmd": "SEND_GIFT", - "danmu": { "area": 0 }, - "data": { - "uid": 1, - "uname": "测试用户", - "giftId": 123, - "giftName": "辣条", - "num": 2, - "price": 100, - "total_coin": 200, - "coin_type": "gold", - "new_data_field": { "enabled": true } - }, - "msg_id": "message-id", - "p_is_ack": false, - "new_server_field": { "enabled": true } - })); - let LiveCommand::Gift(gift) = command else { - panic!("应解析为礼物消息"); - }; - assert_eq!(gift.data.gift_name.as_deref(), Some("辣条")); - assert_eq!(gift.data.total_coin, Some(200)); - assert_eq!(gift.extra["new_server_field"]["enabled"], true); - } - - #[test] - fn parses_combo_send_and_keeps_nested_extra_fields() { - let command = parse_command(json!({ - "cmd": "COMBO_SEND", - "data": { - "uid": "42", - "uname": "测试用户", - "gift_id": 123, - "gift_name": "辣条", - "num": 2, - "price": 100, - "total_coin": 200, - "coin_type": "gold", - "action": "赠送了", - "combo_id": "combo-1", - "batch_combo_id": "batch-1", - "combo_num": 3, - "batch_combo_num": 4, - "combo_total_coin": 300, - "guard_level": 3, - "medal_info": { - "medal_level": 12, - "medal_name": "测试勋章", - "anchor_uname": "主播", - "medal_future": true - }, - "data_future": true - }, - "top_level_future": true - })); - let LiveCommand::ComboSend(combo) = command else { - panic!("应解析为连击送礼消息"); - }; - assert_eq!(combo.data.uid, Some(42)); - assert_eq!(combo.data.combo_total_coin, Some(300)); - assert_eq!( - combo.data.medal_info.as_ref().and_then(|medal| medal.level), - Some(12) - ); - assert_eq!( - combo.data.medal_info.as_ref().unwrap().extra["medal_future"], - true - ); - assert_eq!(combo.data.extra["data_future"], true); - assert_eq!(combo.extra["top_level_future"], true); - } - - #[test] - fn parses_like_click_and_keeps_nested_extra_fields() { - let command = parse_command(json!({ - "cmd": "LIKE_INFO_V3_CLICK", - "data": { - "uid": 42, - "uname": "测试用户", - "like_text": "为主播点赞了", - "like_count": "99", - "is_like": 1, - "icon": "https://example.test/like.png", - "fans_medal": { - "medal_level": 12, - "medal_name": "测试勋章", - "medal_color": 123, - "medal_future": true - }, - "contribution_info": { "grade": 7, "future": "保留" }, - "identities": ["guard", 3], - "dmscore": 60, - "data_future": true - }, - "top_level_future": true - })); - let LiveCommand::LikeClick(like) = command else { - panic!("应解析为点赞消息"); - }; - assert_eq!(like.data.like_count, Some(99)); - assert_eq!(like.data.is_like, Some(true)); - assert_eq!(like.data.identities, vec![json!("guard"), json!(3)]); - assert_eq!( - like.data.fans_medal.as_ref().unwrap().extra["medal_future"], - true - ); - assert_eq!( - like.data.contribution_info.as_ref().unwrap().extra["future"], - "保留" - ); - assert_eq!(like.data.extra["data_future"], true); - assert_eq!(like.extra["top_level_future"], true); - } - - #[test] - fn parses_like_update() { - let command = parse_command(json!({ - "cmd": "LIKE_INFO_V3_UPDATE", - "data": { "click_count": "12345", "data_future": true }, - "top_level_future": true - })); - let LiveCommand::LikeUpdate(update) = command else { - panic!("应解析为点赞计数变化消息"); - }; - assert_eq!(update.click_count, Some(12_345)); - assert_eq!(update.data_extra["data_future"], true); - assert_eq!(update.extra["top_level_future"], true); - } - - #[test] - fn parses_like_notice_and_keeps_segment_extra_fields() { - let command = parse_command(json!({ - "cmd": "LIKE_INFO_V3_NOTICE", - "data": { - "like_count": 12345, - "msg_type": 1, - "content_segments": [ - { "type": 1, "text": "已有 ", "segment_future": true }, - { "type": 2, "text": "12345" } - ], - "data_future": true - }, - "top_level_future": true - })); - let LiveCommand::LikeNotice(notice) = command else { - panic!("应解析为点赞提示消息"); - }; - assert_eq!(notice.like_count, Some(12_345)); - assert_eq!(notice.message_type, Some(1)); - assert_eq!(notice.content_segments[0].text.as_deref(), Some("已有 ")); - assert_eq!(notice.content_segments[0].extra["segment_future"], true); - assert_eq!(notice.data_extra["data_future"], true); - assert_eq!(notice.extra["top_level_future"], true); - } - - #[test] - fn preserves_unknown_and_invalid_commands() { - let unknown = parse_command(json!({"cmd": "FUTURE_COMMAND", "value": 1})); - assert!(matches!(unknown, LiveCommand::Unknown { .. })); - - let invalid = parse_command(json!({"cmd": "SEND_GIFT", "data": []})); - assert!(matches!(invalid, LiveCommand::Invalid { .. })); - } - - #[test] - fn dispatches_danmu_command_with_suffix() { - let command = parse_command(json!({ - "cmd": "DANMU_MSG:4:0", - "info": [ - [0, 0, 0, 16777215, 1_700_000_000_000_u64, 0, 0, 0, 0, 1, 0, [1, 2]], - "你好,世界", - [42, "测试用户", 1, 0, 0, 37, 1, "#FFFFFF"], - [12, "测试勋章", "主播", 5050, 123, 0, 0, 0, 0, 0, 3], - [38, 0, "#FFFFFF", ">50000"], - ["总督", "https://example.test/title.png"], - null, - 3, - null, - {"ct": "token", "ts": 1_700_000_000}, - null, - null, - null, - null, - null, - {"extra": "{\"foo\":true}", "new_field": 1} - ], - "dm_v2": "v2", - "top_level_future": true - })); - let LiveCommand::Danmu(danmu) = command else { - panic!("应解析为弹幕消息"); - }; - assert_eq!(danmu.text.as_deref(), Some("你好,世界")); - assert_eq!(danmu.sender.uid, Some(42)); - assert_eq!( - danmu.fans_medal.as_ref().and_then(|medal| medal.level), - Some(12) - ); - assert_eq!(danmu.metadata.color, Some(16_777_215)); - assert_eq!( - danmu.timestamp.as_ref().and_then(|time| time.ts), - Some(1_700_000_000) - ); - assert_eq!( - danmu - .extension - .as_ref() - .and_then(|extension| extension.extra_json.as_deref()), - Some("{\"foo\":true}") - ); - assert_eq!(danmu.extra["top_level_future"], true); - } -} diff --git a/src/websocket/command/mod.rs b/src/websocket/command/mod.rs new file mode 100644 index 0000000..3b021cd --- /dev/null +++ b/src/websocket/command/mod.rs @@ -0,0 +1,73 @@ +//! 直播业务命令的类型模型与分发。 +//! +//! 命令名称可能包含服务端后缀;解析器会按基础命令名称分发。对于服务端新增或 +//! 不符合已知形态的字段,模型通过原始 JSON 或 extra 字段保留信息。 + +mod models; +mod parse; + +pub use models::*; +pub use parse::parse_command; + +use serde_json::Value; + +/// 直播间推送命令的强类型表示。 +/// +/// 已知命令会反序列化为对应结构体,每个结构体均通过 `extra` 保留未预期字段。 +/// 未识别的命令使用 [`LiveCommand::Unknown`];已识别但字段不符合已知结构的命令 +/// 使用 [`LiveCommand::Invalid`],两者都会保留完整原始 JSON。 +#[derive(Debug, Clone, PartialEq)] +pub enum LiveCommand { + /// 普通弹幕消息。 + Danmu(Box), + /// 礼物消息。 + Gift(Box), + /// 连击送礼消息。 + ComboSend(Box), + /// 用户点赞消息。 + LikeClick(Box), + /// 直播间点赞计数变化消息。 + LikeUpdate(Box), + /// 直播间点赞提示消息。 + LikeNotice(Box), + /// 高能榜人数变化消息。 + OnlineRankCount(Box), + /// 看过人数变化消息。 + WatchedChange(Box), + /// 一批下播房间列表消息。 + StopLiveRoomList(Box), + /// 礼物星球进度消息。 + GiftStarProcess(Box), + /// 高能榜 protobuf 消息的外层载荷。 + OnlineRankV3(Box), + /// 互动 protobuf 消息的外层载荷。 + InteractWordV2(Box), + /// 大航海购买消息。 + GuardBuy(Box), + /// 醒目留言消息。 + SuperChat(Box), + /// 用户进入、关注或分享等互动消息。 + InteractWord(Box), + /// 直播间标题或分区变更消息。 + RoomChange(Box), + /// 直播开始消息。 + Live(Box), + /// 直播结束消息。 + Preparing(Box), + /// crate 当前未支持的命令。 + Unknown { + /// 原始 `cmd` 字段;缺失或非字符串时为 `None`。 + command: Option, + /// 完整原始 JSON。 + raw: Value, + }, + /// 已支持的命令无法反序列化为预期结构。 + Invalid { + /// 服务端原始 `cmd` 字段。 + command: Option, + /// 完整原始 JSON。 + raw: Value, + /// serde 产生的解析错误文本。 + error: String, + }, +} diff --git a/src/websocket/command/models/danmu.rs b/src/websocket/command/models/danmu.rs new file mode 100644 index 0000000..1835efa --- /dev/null +++ b/src/websocket/command/models/danmu.rs @@ -0,0 +1,140 @@ +//! 弹幕相关直播命令的数据模型. + +use serde_json::Value; +use std::collections::HashMap; + +/// 普通弹幕消息。 +/// +/// `DANMU_MSG.info` 是异构数组。本类型按文档中的稳定索引进行手工、尽力而为的解析: +/// 可识别内容会映射到具名强类型字段,未知索引与类型不匹配的数据保存在 +/// [`DanmuMessage::unknown_info`],不会导致整条事件被丢弃。 +#[derive(Debug, Clone, PartialEq)] +pub struct DanmuMessage { + /// 服务端原始命令名,可能含有冒号后缀。 + pub cmd: String, + /// 弹幕元信息,例如颜色、发送时间和弹幕类型。 + pub metadata: DanmuMetadata, + /// 弹幕正文。 + pub text: Option, + /// 发送者信息。 + pub sender: DanmuSender, + /// 发送者佩戴的粉丝勋章;没有勋章时为 `None`。 + pub fans_medal: Option, + /// 发送者的用户等级。 + pub user_level: Option, + /// 发送者的头衔。 + pub title: Option, + /// 用户身份或守护等级。 + pub guard_level: Option, + /// 弹幕校验与时间戳信息。 + pub timestamp: Option, + /// 弹幕扩展信息。 + pub extension: Option, + /// `info` 数组中未知的索引或类型不符合预期的原始值。 + pub unknown_info: HashMap, + /// 新版弹幕的辅助字段。 + pub dm_v2: Option, + /// 未被当前版本建模的顶层字段。 + pub extra: HashMap, +} + +/// [`DanmuMessage`] 的元信息。 +#[derive(Debug, Clone, Default, PartialEq)] +pub struct DanmuMetadata { + /// 十进制 RGB 弹幕颜色。 + pub color: Option, + /// 服务端记录的毫秒级时间戳。 + pub sent_at_ms: Option, + /// Bilibili 定义的弹幕类型。 + pub danmu_type: Option, + /// 渐变终止色。 + /// + /// 当前抓取样本为单个十进制颜色值;其他形态保留在 [`DanmuMetadata::extra`]。 + pub gradient_color: Option, + /// 元信息数组中未建模的索引。 + pub extra: HashMap, +} + +/// [`DanmuMessage`] 的发送者信息。 +#[derive(Debug, Clone, Default, PartialEq)] +pub struct DanmuSender { + /// 用户 UID。 + pub uid: Option, + /// 用户昵称。 + pub uname: Option, + /// 是否为房管。 + pub is_admin: Option, + /// 是否为大会员。 + pub is_vip: Option, + /// 是否为年度大会员。 + pub is_svip: Option, + /// 用户等级。 + pub rank: Option, + /// 昵称颜色。 + pub name_color: Option, + /// 发送者数组中未建模的索引。 + pub extra: HashMap, +} + +/// 粉丝勋章信息。 +#[derive(Debug, Clone, Default, PartialEq)] +pub struct FansMedal { + /// 勋章等级。 + pub level: Option, + /// 勋章名称。 + pub name: Option, + /// 主播昵称。 + pub anchor_name: Option, + /// 主播直播间 ID。 + pub anchor_room_id: Option, + /// 勋章颜色。 + pub color: Option, + /// 大航海等级。 + pub guard_level: Option, + /// 勋章数组中未建模的索引。 + pub extra: HashMap, +} + +/// 用户等级信息。 +#[derive(Debug, Clone, Default, PartialEq)] +pub struct UserLevel { + /// 用户等级。 + pub level: Option, + /// 十进制等级颜色。 + pub color: Option, + /// 等级排名文字。 + pub rank_text: Option, + /// 用户等级数组中未建模的索引。 + pub extra: HashMap, +} + +/// 用户头衔信息。 +#[derive(Debug, Clone, Default, PartialEq)] +pub struct UserTitle { + /// 头衔名称。 + pub name: Option, + /// 头衔图标 URL。 + pub icon: Option, + /// 头衔数组中未建模的索引。 + pub extra: HashMap, +} + +/// 弹幕校验与时间戳信息。 +#[derive(Debug, Clone, Default, PartialEq)] +pub struct DanmuTimestamp { + /// 服务端校验字符串。 + pub ct: Option, + /// Unix 秒级时间戳。 + pub ts: Option, + /// 未建模字段。 + pub extra: HashMap, +} + +/// 弹幕的扩展信息。 +#[derive(Debug, Clone, Default, PartialEq)] +pub struct DanmuExtension { + /// Bilibili 在扩展对象中返回的 JSON 字符串。 + pub extra_json: Option, + /// 未建模字段。 + pub extra: HashMap, +} diff --git a/src/websocket/command/models/gift.rs b/src/websocket/command/models/gift.rs new file mode 100644 index 0000000..614d65a --- /dev/null +++ b/src/websocket/command/models/gift.rs @@ -0,0 +1,147 @@ +//! 礼物与连击相关直播命令的数据模型. + +use serde_json::Value; +use std::collections::HashMap; + +/// 礼物消息。 +/// +/// 实际 WebSocket 结构将礼物字段置于 `data` 对象,本类型按该层级手工解析。 +#[derive(Debug, Clone, PartialEq)] +pub struct GiftMessage { + /// 服务端原始命令名。 + pub cmd: String, + /// 礼物业务数据。 + pub data: GiftData, + /// 弹幕区域信息。 + pub danmu: Option, + /// 服务端消息 ID。 + pub message_id: Option, + /// 服务端是否要求客户端确认。 + pub requires_ack: Option, + /// 服务端消息类型。 + pub message_type: Option, + /// 服务端发送时间。 + pub sent_at: Option, + /// 未被当前版本建模的顶层字段。 + pub extra: HashMap, +} + +/// [`GiftMessage`] 的礼物业务数据。 +#[derive(Debug, Clone, Default, PartialEq)] +pub struct GiftData { + /// 送礼用户 UID。 + pub uid: Option, + /// 送礼用户昵称。 + pub uname: Option, + /// 礼物 ID。 + pub gift_id: Option, + /// 礼物名称。 + pub gift_name: Option, + /// 礼物数量。 + pub num: Option, + /// 单个礼物价格。 + pub price: Option, + /// 实付总额。 + pub total_coin: Option, + /// 货币类型,例如 `gold` 或 `silver`。 + pub coin_type: Option, + /// 动作文案。 + pub action: Option, + /// 用户财富等级。 + pub wealth_level: Option, + /// 大航海等级。 + pub guard_level: Option, + /// 粉丝勋章信息。 + pub medal: Option, + /// 未被当前版本建模的 `data` 字段。 + pub extra: HashMap, +} + +/// 礼物消息中的粉丝勋章信息。 +#[derive(Debug, Clone, Default, PartialEq)] +pub struct GiftMedalInfo { + /// 勋章等级。 + pub level: Option, + /// 勋章名称。 + pub name: Option, + /// 勋章所属主播昵称。 + pub anchor_name: Option, + /// 大航海等级。 + pub guard_level: Option, + /// 未被当前版本建模的字段。 + pub extra: HashMap, +} + +/// 礼物消息的弹幕区域信息。 +#[derive(Debug, Clone, Default, PartialEq)] +pub struct GiftDanmu { + /// 弹幕区域。 + pub area: Option, + /// 未被当前版本建模的字段。 + pub extra: HashMap, +} + +/// 连击送礼消息。 +#[derive(Debug, Clone, PartialEq)] +pub struct ComboSendMessage { + /// 服务端原始命令名。 + pub cmd: String, + /// 连击送礼业务数据。 + pub data: ComboSendData, + /// 未被当前版本建模的顶层字段。 + pub extra: HashMap, +} + +/// [`ComboSendMessage`] 的连击送礼业务数据。 +#[derive(Debug, Clone, Default, PartialEq)] +pub struct ComboSendData { + /// 送礼用户 UID。 + pub uid: Option, + /// 送礼用户昵称。 + pub uname: Option, + /// 礼物 ID。 + pub gift_id: Option, + /// 礼物名称。 + pub gift_name: Option, + /// 本次礼物数量。 + pub num: Option, + /// 单个礼物价格。 + pub price: Option, + /// 本次实付总额。 + pub total_coin: Option, + /// 货币类型,例如 `gold`。 + pub coin_type: Option, + /// 动作文案。 + pub action: Option, + /// 连击 ID。 + pub combo_id: Option, + /// 批次连击 ID。 + pub batch_combo_id: Option, + /// 当前连击数量。 + pub combo_num: Option, + /// 当前批次连击数量。 + pub batch_combo_num: Option, + /// 连击累计实付总额。 + pub combo_total_coin: Option, + /// 大航海等级。 + pub guard_level: Option, + /// 粉丝勋章信息。 + pub medal_info: Option, + /// 未被当前版本建模的 `data` 字段。 + pub extra: HashMap, +} + +/// 连击送礼消息中的粉丝勋章信息。 +#[derive(Debug, Clone, Default, PartialEq)] +pub struct ComboSendMedalInfo { + /// 勋章等级。 + pub level: Option, + /// 勋章名称。 + pub name: Option, + /// 勋章所属主播昵称。 + pub anchor_name: Option, + /// 大航海等级。 + pub guard_level: Option, + /// 未被当前版本建模的字段。 + pub extra: HashMap, +} diff --git a/src/websocket/command/models/like.rs b/src/websocket/command/models/like.rs new file mode 100644 index 0000000..66c97e0 --- /dev/null +++ b/src/websocket/command/models/like.rs @@ -0,0 +1,119 @@ +//! 点赞相关直播命令的数据模型. + +use serde_json::Value; +use std::collections::HashMap; + +/// 用户点赞消息。 +#[derive(Debug, Clone, PartialEq)] +pub struct LikeClickMessage { + /// 服务端原始命令名。 + pub cmd: String, + /// 点赞业务数据。 + pub data: LikeClickData, + /// 未被当前版本建模的顶层字段。 + pub extra: HashMap, +} + +/// [`LikeClickMessage`] 的点赞业务数据。 +#[derive(Debug, Clone, Default, PartialEq)] +pub struct LikeClickData { + /// 点赞用户 UID。 + pub uid: Option, + /// 点赞用户昵称。 + pub uname: Option, + /// 点赞文案。 + pub like_text: Option, + /// 当前点赞计数。 + pub like_count: Option, + /// 是否为点赞操作。 + pub is_like: Option, + /// 点赞图标 URL。 + pub icon: Option, + /// 粉丝勋章信息。 + pub fans_medal: Option, + /// 用户贡献信息。 + pub contribution_info: Option, + /// 用户身份标识。 + pub identities: Vec, + /// 弹幕评分。 + pub dmscore: Option, + /// 未被当前版本建模的 `data` 字段。 + pub extra: HashMap, +} + +/// 点赞消息中的粉丝勋章信息。 +#[derive(Debug, Clone, Default, PartialEq)] +pub struct LikeFansMedalInfo { + /// 勋章等级。 + pub level: Option, + /// 勋章名称。 + pub name: Option, + /// 勋章颜色。 + pub color: Option, + /// 渐变起始颜色。 + pub color_start: Option, + /// 渐变结束颜色。 + pub color_end: Option, + /// 边框颜色。 + pub color_border: Option, + /// 勋章所属直播间 ID。 + pub anchor_room_id: Option, + /// 勋章所属主播昵称。 + pub anchor_name: Option, + /// 大航海等级。 + pub guard_level: Option, + /// 未被当前版本建模的字段。 + pub extra: HashMap, +} + +/// 点赞消息中的用户贡献信息。 +#[derive(Debug, Clone, Default, PartialEq)] +pub struct LikeContributionInfo { + /// 贡献等级。 + pub grade: Option, + /// 未被当前版本建模的字段。 + pub extra: HashMap, +} + +/// 直播间点赞计数变化消息。 +#[derive(Debug, Clone, Default, PartialEq)] +pub struct LikeUpdateMessage { + /// 服务端原始命令名。 + pub cmd: String, + /// 直播间当前点赞计数。 + pub click_count: Option, + /// 未被当前版本建模或类型不符合预期的 `data` 字段。 + pub data_extra: HashMap, + /// 未被当前版本建模的顶层字段。 + pub extra: HashMap, +} + +/// 直播间点赞提示消息。 +#[derive(Debug, Clone, Default, PartialEq)] +pub struct LikeNoticeMessage { + /// 服务端原始命令名。 + pub cmd: String, + /// 直播间当前点赞计数。 + pub like_count: Option, + /// 服务端定义的提示消息类型。 + pub message_type: Option, + /// 组成提示文案的片段。 + pub content_segments: Vec, + /// `content_segments` 中不是对象的原始条目。 + pub unknown_content_segments: HashMap, + /// 未被当前版本建模或类型不符合预期的 `data` 字段。 + pub data_extra: HashMap, + /// 未被当前版本建模的顶层字段。 + pub extra: HashMap, +} + +/// [`LikeNoticeMessage`] 提示文案中的一个片段。 +#[derive(Debug, Clone, Default, PartialEq)] +pub struct LikeNoticeContentSegment { + /// 服务端定义的片段类型。 + pub kind: Option, + /// 片段文本。 + pub text: Option, + /// 未被当前版本建模的字段。 + pub extra: HashMap, +} diff --git a/src/websocket/command/models/mod.rs b/src/websocket/command/models/mod.rs new file mode 100644 index 0000000..edce55f --- /dev/null +++ b/src/websocket/command/models/mod.rs @@ -0,0 +1,14 @@ +//! 已知直播命令的强类型数据模型。 +//! +//! 模型按弹幕、礼物、点赞与状态消息划分。每类模型优先表达稳定字段,并为服务端 +//! 扩展字段保留原始 JSON 值,使调用方能够在协议演进期间继续处理事件。 + +mod danmu; +mod gift; +mod like; +mod status; + +pub use danmu::*; +pub use gift::*; +pub use like::*; +pub use status::*; diff --git a/src/websocket/command/models/status.rs b/src/websocket/command/models/status.rs new file mode 100644 index 0000000..82cb7e5 --- /dev/null +++ b/src/websocket/command/models/status.rs @@ -0,0 +1,182 @@ +//! 排行、互动与房间状态相关直播命令的数据模型. + +use serde::Deserialize; +use serde_json::Value; +use std::collections::HashMap; + +/// 高能榜人数变化消息。 +#[derive(Debug, Clone, Default, PartialEq)] +pub struct OnlineRankCountMessage { + /// 服务端原始命令名。 + pub cmd: String, + /// 高能榜人数。 + pub count: Option, + /// 高能榜人数显示文本。 + pub count_text: Option, + /// 在线人数。 + pub online_count: Option, + /// 在线人数显示文本。 + pub online_count_text: Option, + /// 未被当前版本建模的字段。 + pub extra: HashMap, +} + +/// 看过人数变化消息。 +#[derive(Debug, Clone, Default, PartialEq)] +pub struct WatchedChangeMessage { + /// 服务端原始命令名。 + pub cmd: String, + /// 看过人数。 + pub watched_count: Option, + /// 小字号显示文本。 + pub text_small: Option, + /// 大字号显示文本。 + pub text_large: Option, + /// 未被当前版本建模的字段。 + pub extra: HashMap, +} + +/// 一批下播房间列表消息。 +#[derive(Debug, Clone, Default, PartialEq)] +pub struct StopLiveRoomListMessage { + /// 服务端原始命令名。 + pub cmd: String, + /// 已下播的房间 ID 列表。 + pub room_ids: Vec, + /// 未被当前版本建模的字段。 + pub extra: HashMap, +} + +/// 礼物星球进度消息。 +#[derive(Debug, Clone, Default, PartialEq)] +pub struct GiftStarProcessMessage { + /// 服务端原始命令名。 + pub cmd: String, + /// 当前进度名称。 + pub name: Option, + /// 当前数量。 + pub current: Option, + /// 目标数量。 + pub total: Option, + /// 服务端版本号。 + pub version: Option, + /// 未被当前版本建模的字段。 + pub extra: HashMap, +} + +/// 含 protobuf Base64 载荷的推送消息。 +#[derive(Debug, Clone, Default, PartialEq)] +pub struct ProtobufPayloadMessage { + /// 服务端原始命令名。 + pub cmd: String, + /// Base64 编码的 protobuf 数据。 + pub pb_base64: String, + /// 弹幕评分。 + pub dm_score: Option, + /// 未被当前版本建模的顶层或 `data` 字段。 + pub extra: HashMap, +} + +/// 大航海购买消息。 +#[derive(Debug, Clone, Deserialize, PartialEq)] +pub struct GuardBuyMessage { + /// 服务端原始命令名。 + pub cmd: String, + /// 购买用户 UID。 + pub uid: u64, + /// 购买用户昵称。 + pub username: String, + /// 舰队名称。 + pub gift_name: String, + /// 购买数量。 + pub num: u64, + /// 舰队等级,通常 1=总督、2=提督、3=舰长。 + pub guard_level: u8, + /// 未被当前版本建模的顶层字段。 + #[serde(default, flatten)] + pub extra: HashMap, +} + +/// 醒目留言消息。 +#[derive(Debug, Clone, Deserialize, PartialEq)] +pub struct SuperChatMessage { + /// 服务端原始命令名。 + pub cmd: String, + /// 醒目留言的具体数据。 + pub data: SuperChatData, + /// 未被当前版本建模的顶层字段。 + #[serde(default, flatten)] + pub extra: HashMap, +} + +/// [`SuperChatMessage`] 的业务数据。 +#[derive(Debug, Clone, Deserialize, PartialEq)] +pub struct SuperChatData { + /// 发送用户 UID。 + pub uid: u64, + /// 醒目留言文本。 + pub message: String, + /// 醒目留言价格。 + pub price: u64, + /// 未被当前版本建模的字段。 + #[serde(default, flatten)] + pub extra: HashMap, +} + +/// 用户进入、关注或分享等互动消息。 +#[derive(Debug, Clone, Deserialize, PartialEq)] +pub struct InteractWordMessage { + /// 服务端原始命令名。 + pub cmd: String, + /// 互动事件的具体数据。 + pub data: InteractWordData, + /// 未被当前版本建模的顶层字段。 + #[serde(default, flatten)] + pub extra: HashMap, +} + +/// [`InteractWordMessage`] 的业务数据。 +#[derive(Debug, Clone, Deserialize, PartialEq)] +pub struct InteractWordData { + /// 触发互动的用户 UID。 + pub uid: u64, + /// 触发互动的用户昵称。 + pub uname: String, + /// 互动类型,通常 1=进入、2=关注、3=分享。 + pub msg_type: u8, + /// 未被当前版本建模的字段。 + #[serde(default, flatten)] + pub extra: HashMap, +} + +/// 直播间标题或分区发生变化时的消息。 +#[derive(Debug, Clone, Deserialize, PartialEq)] +pub struct RoomChangeMessage { + /// 服务端原始命令名。 + pub cmd: String, + /// 当前直播标题。 + #[serde(default)] + pub title: Option, + /// 当前子分区名称。 + #[serde(default)] + pub area_name: Option, + /// 当前父分区名称。 + #[serde(default)] + pub parent_area_name: Option, + /// 未被当前版本建模的顶层字段。 + #[serde(default, flatten)] + pub extra: HashMap, +} + +/// 开播或下播状态消息。 +#[derive(Debug, Clone, Deserialize, PartialEq)] +pub struct LiveStatusMessage { + /// 服务端原始命令名。 + pub cmd: String, + /// 直播间 ID;部分推送可能省略该字段。 + #[serde(default)] + pub roomid: Option, + /// 未被当前版本建模的顶层字段。 + #[serde(default, flatten)] + pub extra: HashMap, +} diff --git a/src/websocket/command/parse/common.rs b/src/websocket/command/parse/common.rs new file mode 100644 index 0000000..a016c7c --- /dev/null +++ b/src/websocket/command/parse/common.rs @@ -0,0 +1,70 @@ +//! 命令解析共用的 JSON 值转换与扩展字段保留逻辑. + +use serde_json::Value; +use std::collections::HashMap; + +pub(super) fn indexed_extra(values: &[Value], known: &[usize]) -> HashMap { + values + .iter() + .enumerate() + .filter(|(index, _)| !known.contains(index)) + .map(|(index, value)| (index, value.clone())) + .collect() +} + +pub(super) fn object_extra(value: &Value, known: &[&str]) -> HashMap { + value + .as_object() + .into_iter() + .flatten() + .filter(|(key, _)| !known.contains(&key.as_str())) + .map(|(key, value)| (key.clone(), value.clone())) + .collect() +} + +pub(super) fn object_extra_with_invalid( + value: &Value, + parsed_fields: &[(&str, bool)], +) -> HashMap { + value + .as_object() + .into_iter() + .flatten() + .filter(|(key, _)| { + !parsed_fields + .iter() + .any(|(name, parsed)| key == name && *parsed) + }) + .map(|(key, value)| (key.clone(), value.clone())) + .collect() +} + +pub(super) fn value_string(value: &Value) -> Option { + value.as_str().map(str::to_owned) +} + +pub(super) fn value_lossless_string(value: &Value) -> Option { + value_string(value).or_else(|| value_u64(value).map(|number| number.to_string())) +} + +pub(super) fn value_u64(value: &Value) -> Option { + value + .as_u64() + .or_else(|| value.as_str().and_then(|text| text.parse().ok())) +} + +pub(super) fn value_u32(value: &Value) -> Option { + value_u64(value).and_then(|number| number.try_into().ok()) +} + +pub(super) fn value_u8(value: &Value) -> Option { + value_u64(value).and_then(|number| number.try_into().ok()) +} + +pub(super) fn value_bool(value: &Value) -> Option { + value.as_bool().or_else(|| match value_u64(value) { + Some(0) => Some(false), + Some(1) => Some(true), + _ => None, + }) +} diff --git a/src/websocket/command/parse/danmu.rs b/src/websocket/command/parse/danmu.rs new file mode 100644 index 0000000..cae46df --- /dev/null +++ b/src/websocket/command/parse/danmu.rs @@ -0,0 +1,135 @@ +//! 异构弹幕数组命令的解析器. + +use super::super::*; +use super::common::*; +use serde_json::Value; +use std::collections::HashMap; + +pub(super) fn parse_danmu(value: &Value) -> std::result::Result { + let info = value + .get("info") + .and_then(Value::as_array) + .ok_or_else(|| "DANMU_MSG 缺少数组类型的 info 字段".to_owned())?; + let mut unknown_info = indexed_extra(info, &[0, 1, 2, 3, 4, 5, 7, 9, 15]); + + let metadata = parse_index(info, 0, &mut unknown_info, parse_metadata).unwrap_or_default(); + let text = parse_index(info, 1, &mut unknown_info, value_string); + let sender = parse_index(info, 2, &mut unknown_info, parse_sender).unwrap_or_default(); + let fans_medal = parse_fans_medal_index(info, &mut unknown_info); + let user_level = parse_index(info, 4, &mut unknown_info, parse_user_level); + let title = parse_index(info, 5, &mut unknown_info, parse_user_title); + let guard_level = parse_index(info, 7, &mut unknown_info, value_u8); + let timestamp = parse_index(info, 9, &mut unknown_info, parse_timestamp); + let extension = parse_index(info, 15, &mut unknown_info, parse_extension); + + Ok(DanmuMessage { + cmd: value_string(value.get("cmd").unwrap_or(&Value::Null)).unwrap_or_default(), + metadata, + text, + sender, + fans_medal, + user_level, + title, + guard_level, + timestamp, + extension, + unknown_info, + dm_v2: value.get("dm_v2").and_then(value_string), + extra: object_extra(value, &["cmd", "info", "dm_v2"]), + }) +} + +pub(super) fn parse_index( + values: &[Value], + index: usize, + unknown: &mut HashMap, + parser: impl FnOnce(&Value) -> Option, +) -> Option { + let value = values.get(index)?; + match parser(value) { + Some(parsed) => Some(parsed), + None => { + unknown.insert(index, value.clone()); + None + } + } +} + +pub(super) fn parse_fans_medal_index( + values: &[Value], + unknown: &mut HashMap, +) -> Option { + let value = values.get(3)?; + let Some(entries) = value.as_array() else { + unknown.insert(3, value.clone()); + return None; + }; + (!entries.is_empty()).then(|| FansMedal { + level: entries.first().and_then(value_u8), + name: entries.get(1).and_then(value_string), + anchor_name: entries.get(2).and_then(value_string), + anchor_room_id: entries.get(3).and_then(value_u64), + color: entries.get(4).and_then(value_u32), + guard_level: entries.get(10).and_then(value_u8), + extra: indexed_extra(entries, &[0, 1, 2, 3, 4, 10]), + }) +} + +pub(super) fn parse_metadata(value: &Value) -> Option { + let entries = value.as_array()?; + Some(DanmuMetadata { + color: entries.get(3).and_then(value_u32), + sent_at_ms: entries.get(4).and_then(value_u64), + danmu_type: entries.get(9).and_then(value_u8), + gradient_color: entries.get(11).and_then(value_u32), + extra: indexed_extra(entries, &[3, 4, 9]), + }) +} + +pub(super) fn parse_sender(value: &Value) -> Option { + let entries = value.as_array()?; + Some(DanmuSender { + uid: entries.first().and_then(value_u64), + uname: entries.get(1).and_then(value_string), + is_admin: entries.get(2).and_then(value_bool), + is_vip: entries.get(3).and_then(value_bool), + is_svip: entries.get(4).and_then(value_bool), + rank: entries.get(5).and_then(value_u32), + name_color: entries.get(7).and_then(value_string), + extra: indexed_extra(entries, &[0, 1, 2, 3, 4, 5, 7]), + }) +} + +pub(super) fn parse_user_level(value: &Value) -> Option { + let entries = value.as_array()?; + Some(UserLevel { + level: entries.first().and_then(value_u8), + color: entries.get(2).and_then(value_u32), + rank_text: entries.get(3).and_then(value_string), + extra: indexed_extra(entries, &[0, 3]), + }) +} + +pub(super) fn parse_user_title(value: &Value) -> Option { + let entries = value.as_array()?; + Some(UserTitle { + name: entries.first().and_then(value_string), + icon: entries.get(1).and_then(value_string), + extra: indexed_extra(entries, &[0, 1]), + }) +} + +pub(super) fn parse_timestamp(value: &Value) -> Option { + value.as_object().map(|object| DanmuTimestamp { + ct: object.get("ct").and_then(value_string), + ts: object.get("ts").and_then(value_u64), + extra: object_extra(value, &["ct", "ts"]), + }) +} + +pub(super) fn parse_extension(value: &Value) -> Option { + value.as_object().map(|object| DanmuExtension { + extra_json: object.get("extra").and_then(value_string), + extra: object_extra(value, &["extra"]), + }) +} diff --git a/src/websocket/command/parse/dispatch.rs b/src/websocket/command/parse/dispatch.rs new file mode 100644 index 0000000..0229dba --- /dev/null +++ b/src/websocket/command/parse/dispatch.rs @@ -0,0 +1,146 @@ +//! 命令名称到领域解析器的分发逻辑. + +use super::super::*; +use super::{ + danmu::parse_danmu, + gift::{parse_combo_send, parse_gift}, + like::{parse_like_click, parse_like_notice, parse_like_update}, + status::{ + parse_gift_star_process, parse_online_rank_count, parse_protobuf_payload, + parse_stop_live_room_list, parse_watched_change, + }, +}; + +/// 将操作码 5 的 JSON 命令转换为对应的强类型业务命令。 +/// +/// 命令名中的服务端后缀不会影响分发。已知命令缺少必需容器字段时返回 +/// [`LiveCommand::Invalid`];未知命令或尚未实现的命令返回 [`LiveCommand::Unknown`], +/// 两者都会保留完整的原始 JSON。 +pub fn parse_command(value: Value) -> LiveCommand { + let command = value.get("cmd").and_then(Value::as_str).map(str::to_owned); + let normalized = command + .as_deref() + .map(|name| name.split(':').next().unwrap_or(name)); + + macro_rules! parse_as { + ($type:ty, $variant:ident) => { + match serde_json::from_value::<$type>(value.clone()) { + Ok(message) => LiveCommand::$variant(Box::new(message)), + Err(error) => LiveCommand::Invalid { + command, + raw: value, + error: error.to_string(), + }, + } + }; + } + + match normalized { + Some("DANMU_MSG") => match parse_danmu(&value) { + Ok(message) => LiveCommand::Danmu(Box::new(message)), + Err(error) => LiveCommand::Invalid { + command, + raw: value, + error, + }, + }, + Some("SEND_GIFT") => match parse_gift(&value) { + Ok(message) => LiveCommand::Gift(Box::new(message)), + Err(error) => LiveCommand::Invalid { + command, + raw: value, + error, + }, + }, + Some("COMBO_SEND") => match parse_combo_send(&value) { + Ok(message) => LiveCommand::ComboSend(Box::new(message)), + Err(error) => LiveCommand::Invalid { + command, + raw: value, + error, + }, + }, + Some("LIKE_INFO_V3_CLICK") => match parse_like_click(&value) { + Ok(message) => LiveCommand::LikeClick(Box::new(message)), + Err(error) => LiveCommand::Invalid { + command, + raw: value, + error, + }, + }, + Some("LIKE_INFO_V3_UPDATE") => match parse_like_update(&value) { + Ok(message) => LiveCommand::LikeUpdate(Box::new(message)), + Err(error) => LiveCommand::Invalid { + command, + raw: value, + error, + }, + }, + Some("LIKE_INFO_V3_NOTICE") => match parse_like_notice(&value) { + Ok(message) => LiveCommand::LikeNotice(Box::new(message)), + Err(error) => LiveCommand::Invalid { + command, + raw: value, + error, + }, + }, + Some("ONLINE_RANK_COUNT") => match parse_online_rank_count(&value) { + Ok(message) => LiveCommand::OnlineRankCount(Box::new(message)), + Err(error) => LiveCommand::Invalid { + command, + raw: value, + error, + }, + }, + Some("WATCHED_CHANGE") => match parse_watched_change(&value) { + Ok(message) => LiveCommand::WatchedChange(Box::new(message)), + Err(error) => LiveCommand::Invalid { + command, + raw: value, + error, + }, + }, + Some("STOP_LIVE_ROOM_LIST") => match parse_stop_live_room_list(&value) { + Ok(message) => LiveCommand::StopLiveRoomList(Box::new(message)), + Err(error) => LiveCommand::Invalid { + command, + raw: value, + error, + }, + }, + Some("WIDGET_GIFT_STAR_PROCESS_V2") => match parse_gift_star_process(&value) { + Ok(message) => LiveCommand::GiftStarProcess(Box::new(message)), + Err(error) => LiveCommand::Invalid { + command, + raw: value, + error, + }, + }, + Some("ONLINE_RANK_V3") => match parse_protobuf_payload(&value) { + Ok(message) => LiveCommand::OnlineRankV3(Box::new(message)), + Err(error) => LiveCommand::Invalid { + command, + raw: value, + error, + }, + }, + Some("INTERACT_WORD_V2") => match parse_protobuf_payload(&value) { + Ok(message) => LiveCommand::InteractWordV2(Box::new(message)), + Err(error) => LiveCommand::Invalid { + command, + raw: value, + error, + }, + }, + Some("GUARD_BUY") => parse_as!(GuardBuyMessage, GuardBuy), + Some("SUPER_CHAT_MESSAGE") => parse_as!(SuperChatMessage, SuperChat), + Some("INTERACT_WORD") => parse_as!(InteractWordMessage, InteractWord), + Some("ROOM_CHANGE") => parse_as!(RoomChangeMessage, RoomChange), + Some("LIVE") => parse_as!(LiveStatusMessage, Live), + Some("PREPARING") => parse_as!(LiveStatusMessage, Preparing), + _ => LiveCommand::Unknown { + command, + raw: value, + }, + } +} diff --git a/src/websocket/command/parse/gift.rs b/src/websocket/command/parse/gift.rs new file mode 100644 index 0000000..3364091 --- /dev/null +++ b/src/websocket/command/parse/gift.rs @@ -0,0 +1,212 @@ +//! 礼物、连击与礼物相关状态命令的解析器. + +use super::super::*; +use super::common::*; +use super::status::command_data; +use serde_json::Value; + +pub(super) fn parse_gift(value: &Value) -> std::result::Result { + let data = value + .get("data") + .ok_or_else(|| "SEND_GIFT 缺少 data 字段".to_owned())?; + let data_object = data + .as_object() + .ok_or_else(|| "SEND_GIFT.data 不是对象".to_owned())?; + let medal = data_object.get("medal_info").and_then(parse_gift_medal); + let danmu = value.get("danmu").and_then(parse_gift_danmu); + Ok(GiftMessage { + cmd: value.get("cmd").and_then(value_string).unwrap_or_default(), + data: GiftData { + uid: data_object.get("uid").and_then(value_u64), + uname: data_object.get("uname").and_then(value_string), + gift_id: data_object.get("giftId").and_then(value_u64), + gift_name: data_object.get("giftName").and_then(value_string), + num: data_object.get("num").and_then(value_u64), + price: data_object.get("price").and_then(value_u64), + total_coin: data_object.get("total_coin").and_then(value_u64), + coin_type: data_object.get("coin_type").and_then(value_string), + action: data_object.get("action").and_then(value_string), + wealth_level: data_object.get("wealth_level").and_then(value_u32), + guard_level: data_object.get("guard_level").and_then(value_u8), + medal, + extra: object_extra( + data, + &[ + "uid", + "uname", + "giftId", + "giftName", + "num", + "price", + "total_coin", + "coin_type", + "action", + "wealth_level", + "guard_level", + "medal_info", + ], + ), + }, + danmu, + message_id: value.get("msg_id").and_then(value_lossless_string), + requires_ack: value.get("p_is_ack").and_then(value_bool), + message_type: value.get("p_msg_type").and_then(value_u32), + sent_at: value.get("send_time").and_then(value_u64), + extra: object_extra( + value, + &[ + "cmd", + "data", + "danmu", + "msg_id", + "p_is_ack", + "p_msg_type", + "send_time", + ], + ), + }) +} + +pub(super) fn parse_gift_medal(value: &Value) -> Option { + let object = value.as_object()?; + Some(GiftMedalInfo { + level: object.get("medal_level").and_then(value_u8), + name: object.get("medal_name").and_then(value_string), + anchor_name: object.get("anchor_uname").and_then(value_string), + guard_level: object.get("guard_level").and_then(value_u8), + extra: object_extra( + value, + &["medal_level", "medal_name", "anchor_uname", "guard_level"], + ), + }) +} + +pub(super) fn parse_gift_danmu(value: &Value) -> Option { + let object = value.as_object()?; + Some(GiftDanmu { + area: object.get("area").and_then(value_u32), + extra: object_extra(value, &["area"]), + }) +} + +pub(super) fn parse_combo_send(value: &Value) -> std::result::Result { + let data = command_data(value, "COMBO_SEND")?; + let medal_info = data.get("medal_info").and_then(parse_combo_send_medal); + Ok(ComboSendMessage { + cmd: value.get("cmd").and_then(value_string).unwrap_or_default(), + data: ComboSendData { + uid: data.get("uid").and_then(value_u64), + uname: data.get("uname").and_then(value_string), + gift_id: data + .get("gift_id") + .or_else(|| data.get("giftId")) + .and_then(value_u64), + gift_name: data.get("gift_name").and_then(value_string), + num: data.get("num").and_then(value_u64), + price: data.get("price").and_then(value_u64), + total_coin: data.get("total_coin").and_then(value_u64), + coin_type: data.get("coin_type").and_then(value_string), + action: data.get("action").and_then(value_string), + combo_id: data.get("combo_id").and_then(value_lossless_string), + batch_combo_id: data.get("batch_combo_id").and_then(value_lossless_string), + combo_num: data.get("combo_num").and_then(value_u64), + batch_combo_num: data.get("batch_combo_num").and_then(value_u64), + combo_total_coin: data.get("combo_total_coin").and_then(value_u64), + guard_level: data.get("guard_level").and_then(value_u8), + medal_info, + extra: object_extra_with_invalid( + &Value::Object(data.clone()), + &[ + ("uid", data.get("uid").and_then(value_u64).is_some()), + ("uname", data.get("uname").and_then(value_string).is_some()), + ("gift_id", data.get("gift_id").and_then(value_u64).is_some()), + ("giftId", data.get("giftId").and_then(value_u64).is_some()), + ( + "gift_name", + data.get("gift_name").and_then(value_string).is_some(), + ), + ("num", data.get("num").and_then(value_u64).is_some()), + ("price", data.get("price").and_then(value_u64).is_some()), + ( + "total_coin", + data.get("total_coin").and_then(value_u64).is_some(), + ), + ( + "coin_type", + data.get("coin_type").and_then(value_string).is_some(), + ), + ( + "action", + data.get("action").and_then(value_string).is_some(), + ), + ( + "combo_id", + data.get("combo_id") + .and_then(value_lossless_string) + .is_some(), + ), + ( + "batch_combo_id", + data.get("batch_combo_id") + .and_then(value_lossless_string) + .is_some(), + ), + ( + "combo_num", + data.get("combo_num").and_then(value_u64).is_some(), + ), + ( + "batch_combo_num", + data.get("batch_combo_num").and_then(value_u64).is_some(), + ), + ( + "combo_total_coin", + data.get("combo_total_coin").and_then(value_u64).is_some(), + ), + ( + "guard_level", + data.get("guard_level").and_then(value_u8).is_some(), + ), + ( + "medal_info", + data.get("medal_info") + .and_then(parse_combo_send_medal) + .is_some(), + ), + ], + ), + }, + extra: object_extra(value, &["cmd", "data"]), + }) +} + +pub(super) fn parse_combo_send_medal(value: &Value) -> Option { + let object = value.as_object()?; + Some(ComboSendMedalInfo { + level: object.get("medal_level").and_then(value_u8), + name: object.get("medal_name").and_then(value_string), + anchor_name: object.get("anchor_uname").and_then(value_string), + guard_level: object.get("guard_level").and_then(value_u8), + extra: object_extra_with_invalid( + value, + &[ + ( + "medal_level", + object.get("medal_level").and_then(value_u8).is_some(), + ), + ( + "medal_name", + object.get("medal_name").and_then(value_string).is_some(), + ), + ( + "anchor_uname", + object.get("anchor_uname").and_then(value_string).is_some(), + ), + ( + "guard_level", + object.get("guard_level").and_then(value_u8).is_some(), + ), + ], + ), + }) +} diff --git a/src/websocket/command/parse/like.rs b/src/websocket/command/parse/like.rs new file mode 100644 index 0000000..7b89e3f --- /dev/null +++ b/src/websocket/command/parse/like.rs @@ -0,0 +1,217 @@ +//! 点赞命令及其嵌套数据的解析器. + +use super::super::*; +use super::common::*; +use super::status::command_data; +use serde_json::Value; + +pub(super) fn parse_like_click(value: &Value) -> std::result::Result { + let data = command_data(value, "LIKE_INFO_V3_CLICK")?; + Ok(LikeClickMessage { + cmd: value.get("cmd").and_then(value_string).unwrap_or_default(), + data: LikeClickData { + uid: data.get("uid").and_then(value_u64), + uname: data.get("uname").and_then(value_string), + like_text: data.get("like_text").and_then(value_string), + like_count: data.get("like_count").and_then(value_u64), + is_like: data.get("is_like").and_then(value_bool), + icon: data.get("icon").and_then(value_string), + fans_medal: data.get("fans_medal").and_then(parse_like_fans_medal), + contribution_info: data + .get("contribution_info") + .and_then(parse_like_contribution_info), + identities: data + .get("identities") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(), + dmscore: data.get("dmscore").and_then(value_u64), + extra: object_extra_with_invalid( + &Value::Object(data.clone()), + &[ + ("uid", data.get("uid").and_then(value_u64).is_some()), + ("uname", data.get("uname").and_then(value_string).is_some()), + ( + "like_text", + data.get("like_text").and_then(value_string).is_some(), + ), + ( + "like_count", + data.get("like_count").and_then(value_u64).is_some(), + ), + ( + "is_like", + data.get("is_like").and_then(value_bool).is_some(), + ), + ("icon", data.get("icon").and_then(value_string).is_some()), + ( + "fans_medal", + data.get("fans_medal") + .and_then(parse_like_fans_medal) + .is_some(), + ), + ( + "contribution_info", + data.get("contribution_info") + .and_then(parse_like_contribution_info) + .is_some(), + ), + ( + "identities", + data.get("identities").and_then(Value::as_array).is_some(), + ), + ("dmscore", data.get("dmscore").and_then(value_u64).is_some()), + ], + ), + }, + extra: object_extra(value, &["cmd", "data"]), + }) +} + +pub(super) fn parse_like_fans_medal(value: &Value) -> Option { + let object = value.as_object()?; + Some(LikeFansMedalInfo { + level: object.get("medal_level").and_then(value_u8), + name: object.get("medal_name").and_then(value_string), + color: object.get("medal_color").and_then(value_u32), + color_start: object.get("medal_color_start").and_then(value_u32), + color_end: object.get("medal_color_end").and_then(value_u32), + color_border: object.get("medal_color_border").and_then(value_u32), + anchor_room_id: object.get("anchor_roomid").and_then(value_u64), + anchor_name: object.get("anchor_uname").and_then(value_string), + guard_level: object.get("guard_level").and_then(value_u8), + extra: object_extra_with_invalid( + value, + &[ + ( + "medal_level", + object.get("medal_level").and_then(value_u8).is_some(), + ), + ( + "medal_name", + object.get("medal_name").and_then(value_string).is_some(), + ), + ( + "medal_color", + object.get("medal_color").and_then(value_u32).is_some(), + ), + ( + "medal_color_start", + object + .get("medal_color_start") + .and_then(value_u32) + .is_some(), + ), + ( + "medal_color_end", + object.get("medal_color_end").and_then(value_u32).is_some(), + ), + ( + "medal_color_border", + object + .get("medal_color_border") + .and_then(value_u32) + .is_some(), + ), + ( + "anchor_roomid", + object.get("anchor_roomid").and_then(value_u64).is_some(), + ), + ( + "anchor_uname", + object.get("anchor_uname").and_then(value_string).is_some(), + ), + ( + "guard_level", + object.get("guard_level").and_then(value_u8).is_some(), + ), + ], + ), + }) +} + +pub(super) fn parse_like_contribution_info(value: &Value) -> Option { + let object = value.as_object()?; + Some(LikeContributionInfo { + grade: object.get("grade").and_then(value_u64), + extra: object_extra_with_invalid( + value, + &[("grade", object.get("grade").and_then(value_u64).is_some())], + ), + }) +} + +pub(super) fn parse_like_update(value: &Value) -> std::result::Result { + let data = command_data(value, "LIKE_INFO_V3_UPDATE")?; + Ok(LikeUpdateMessage { + cmd: value.get("cmd").and_then(value_string).unwrap_or_default(), + click_count: data.get("click_count").and_then(value_u64), + data_extra: object_extra_with_invalid( + &Value::Object(data.clone()), + &[( + "click_count", + data.get("click_count").and_then(value_u64).is_some(), + )], + ), + extra: object_extra(value, &["cmd", "data"]), + }) +} + +pub(super) fn parse_like_notice(value: &Value) -> std::result::Result { + let data = command_data(value, "LIKE_INFO_V3_NOTICE")?; + let segments = data + .get("content_segments") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + Ok(LikeNoticeMessage { + cmd: value.get("cmd").and_then(value_string).unwrap_or_default(), + like_count: data.get("like_count").and_then(value_u64), + message_type: data.get("msg_type").and_then(value_u8), + content_segments: segments + .iter() + .filter_map(parse_like_notice_content_segment) + .collect(), + unknown_content_segments: segments + .iter() + .enumerate() + .filter(|(_, segment)| !segment.is_object()) + .map(|(index, segment)| (index, segment.clone())) + .collect(), + data_extra: object_extra_with_invalid( + &Value::Object(data.clone()), + &[ + ( + "like_count", + data.get("like_count").and_then(value_u64).is_some(), + ), + ( + "msg_type", + data.get("msg_type").and_then(value_u8).is_some(), + ), + ( + "content_segments", + data.get("content_segments") + .and_then(Value::as_array) + .is_some(), + ), + ], + ), + extra: object_extra(value, &["cmd", "data"]), + }) +} + +pub(super) fn parse_like_notice_content_segment(value: &Value) -> Option { + let object = value.as_object()?; + Some(LikeNoticeContentSegment { + kind: object.get("type").and_then(value_u8), + text: object.get("text").and_then(value_string), + extra: object_extra_with_invalid( + value, + &[ + ("type", object.get("type").and_then(value_u8).is_some()), + ("text", object.get("text").and_then(value_string).is_some()), + ], + ), + }) +} diff --git a/src/websocket/command/parse/mod.rs b/src/websocket/command/parse/mod.rs new file mode 100644 index 0000000..b1f3b2a --- /dev/null +++ b/src/websocket/command/parse/mod.rs @@ -0,0 +1,13 @@ +//! 直播业务命令的容错解析实现。 +//! +//! 解析器只在命令所需的容器字段缺失或形态错误时报告失败;可选字段无法识别时, +//! 会保留其原始 JSON,以便调用方在协议演进期间继续处理事件。 + +mod common; +mod danmu; +mod dispatch; +mod gift; +mod like; +mod status; + +pub use dispatch::parse_command; diff --git a/src/websocket/command/parse/status.rs b/src/websocket/command/parse/status.rs new file mode 100644 index 0000000..0243aee --- /dev/null +++ b/src/websocket/command/parse/status.rs @@ -0,0 +1,89 @@ +//! 排行、人数、房间状态与 protobuf 载荷命令的解析器. + +use super::super::*; +use super::common::*; +use serde_json::Value; + +pub(super) fn parse_online_rank_count( + value: &Value, +) -> std::result::Result { + let data = command_data(value, "ONLINE_RANK_COUNT")?; + Ok(OnlineRankCountMessage { + cmd: value.get("cmd").and_then(value_string).unwrap_or_default(), + count: data.get("count").and_then(value_u64), + count_text: data.get("count_text").and_then(value_string), + online_count: data.get("online_count").and_then(value_u64), + online_count_text: data.get("online_count_text").and_then(value_string), + extra: object_extra(value, &["cmd", "data"]), + }) +} + +pub(super) fn parse_watched_change( + value: &Value, +) -> std::result::Result { + let data = command_data(value, "WATCHED_CHANGE")?; + Ok(WatchedChangeMessage { + cmd: value.get("cmd").and_then(value_string).unwrap_or_default(), + watched_count: data.get("num").and_then(value_u64), + text_small: data.get("text_small").and_then(value_string), + text_large: data.get("text_large").and_then(value_string), + extra: object_extra(value, &["cmd", "data"]), + }) +} + +pub(super) fn parse_stop_live_room_list( + value: &Value, +) -> std::result::Result { + let data = command_data(value, "STOP_LIVE_ROOM_LIST")?; + Ok(StopLiveRoomListMessage { + cmd: value.get("cmd").and_then(value_string).unwrap_or_default(), + room_ids: data + .get("room_id_list") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(value_u64) + .collect(), + extra: object_extra(value, &["cmd", "data"]), + }) +} + +pub(super) fn parse_gift_star_process( + value: &Value, +) -> std::result::Result { + let data = command_data(value, "WIDGET_GIFT_STAR_PROCESS_V2")?; + Ok(GiftStarProcessMessage { + cmd: value.get("cmd").and_then(value_string).unwrap_or_default(), + name: data.get("name").and_then(value_string), + current: data.get("cur_num").and_then(value_u64), + total: data.get("total_num").and_then(value_u64), + version: data.get("version").and_then(value_u64), + extra: object_extra(value, &["cmd", "data"]), + }) +} + +pub(super) fn parse_protobuf_payload( + value: &Value, +) -> std::result::Result { + let data = command_data(value, "protobuf 命令")?; + let pb_base64 = data + .get("pb") + .and_then(value_string) + .ok_or_else(|| "protobuf 命令缺少 data.pb".to_owned())?; + Ok(ProtobufPayloadMessage { + cmd: value.get("cmd").and_then(value_string).unwrap_or_default(), + pb_base64, + dm_score: data.get("dmscore").and_then(value_u64), + extra: object_extra(value, &["cmd", "data"]), + }) +} + +pub(super) fn command_data<'a>( + value: &'a Value, + command: &str, +) -> std::result::Result<&'a serde_json::Map, String> { + value + .get("data") + .and_then(Value::as_object) + .ok_or_else(|| format!("{command} 缺少对象类型的 data 字段")) +} diff --git a/src/websocket/connection.rs b/src/websocket/connection.rs new file mode 100644 index 0000000..41bb166 --- /dev/null +++ b/src/websocket/connection.rs @@ -0,0 +1,130 @@ +//! 已认证的直播 WebSocket 连接与读取流程. + +use super::packet::{decode_packets, encode_packet, events_from_packets}; +use super::LiveEvent; +use crate::{Client, Error, Result}; +use futures_util::{SinkExt, StreamExt}; +use serde_json::{json, Value}; +use std::collections::VecDeque; +use tokio::net::TcpStream; +use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream}; + +/// 已认证的直播弹幕 WebSocket 连接。 +pub struct LiveWebSocket { + stream: WebSocketStream>, + pending: VecDeque, +} + +impl LiveWebSocket { + /// 建立直播弹幕连接并完成认证。 + /// + /// 会从导航接口读取当前登录用户 UID、请求弹幕 token、选择第一个 WSS 主机, + /// 并发送操作码为 7 的认证包。当前网页协议通常要求有效的登录 Cookie。 + pub async fn connect(client: &Client, room_id: u64) -> Result { + let nav = client.user().nav().await?; + let uid = nav + .get("mid") + .and_then(Value::as_u64) + .ok_or_else(|| Error::InvalidPacket("导航响应未包含当前用户 UID".into()))?; + Self::connect_with_uid(client, room_id, uid).await + } + + /// 使用已知用户 UID 建立直播弹幕连接并完成认证。 + /// + /// 一般应优先使用 [`LiveWebSocket::connect`],仅在调用方已通过可信方式取得 UID + /// 时使用此方法。 + pub async fn connect_with_uid(client: &Client, room_id: u64, uid: u64) -> Result { + let info = client.live().danmu_info(room_id).await?; + let token = info + .get("token") + .and_then(Value::as_str) + .ok_or_else(|| Error::InvalidPacket("danmu response has no token".into()))?; + let host = info + .pointer("/host_list/0/host") + .and_then(Value::as_str) + .ok_or_else(|| Error::InvalidPacket("danmu response has no host".into()))?; + let port = info + .pointer("/host_list/0/wss_port") + .and_then(Value::as_u64) + .unwrap_or(443); + if !host.ends_with(".chat.bilibili.com") { + return Err(Error::InvalidPacket(format!( + "refusing non-Bilibili chat host: {host}" + ))); + } + let (stream, _) = connect_async(format!("wss://{host}:{port}/sub")) + .await + .map_err(Error::websocket)?; + let mut socket = Self { + stream, + pending: VecDeque::new(), + }; + let auth = json!({"uid": uid, "roomid": room_id, "protover": 3, "platform": "web", "type": 2, "key": token}); + socket.send_packet(7, auth.to_string().as_bytes()).await?; + Ok(socket) + } + + /// 发送心跳包。 + /// + /// 调用方应约每 30 秒调用一次,以维持连接。 + pub async fn heartbeat(&mut self) -> Result<()> { + self.send_packet(2, &[]).await + } + + /// 等待并返回下一个直播事件。 + /// + /// 连接被服务器关闭时返回 `Ok(None)`。 + pub async fn next_event(&mut self) -> Result> { + if let Some(event) = self.pending.pop_front() { + return Ok(Some(event)); + } + while let Some(message) = self.stream.next().await { + match message.map_err(Error::websocket)? { + Message::Binary(bytes) => { + self.pending + .extend(events_from_packets(&decode_packets(&bytes)?)?); + if let Some(event) = self.pending.pop_front() { + return Ok(Some(event)); + } + } + Message::Close(_) => return Ok(None), + Message::Ping(payload) => self + .stream + .send(Message::Pong(payload)) + .await + .map_err(Error::websocket)?, + _ => {} + } + } + Ok(None) + } + + /// 等待下一个原始 WebSocket 二进制帧。 + /// + /// 返回的字节尚未经过协议包拆分、zlib/brotli 解压或 JSON 解析,适合抓取和离线 + /// 分析。此方法会自动响应 WebSocket Ping;不要与 [`LiveWebSocket::next_event`] + /// 交替调用,否则事件顺序难以推断。连接关闭时返回 `Ok(None)`。 + pub async fn next_raw_frame(&mut self) -> Result>> { + while let Some(message) = self.stream.next().await { + match message.map_err(Error::websocket)? { + Message::Binary(bytes) => return Ok(Some(bytes.to_vec())), + Message::Close(_) => return Ok(None), + Message::Ping(payload) => self + .stream + .send(Message::Pong(payload)) + .await + .map_err(Error::websocket)?, + _ => {} + } + } + Ok(None) + } + + async fn send_packet(&mut self, operation: u32, body: &[u8]) -> Result<()> { + self.stream + .send(Message::Binary(encode_packet(operation, body).into())) + .await + .map_err(Error::websocket)?; + Ok(()) + } +} diff --git a/src/websocket/mod.rs b/src/websocket/mod.rs new file mode 100644 index 0000000..8e8823e --- /dev/null +++ b/src/websocket/mod.rs @@ -0,0 +1,20 @@ +//! Bilibili 直播 WebSocket 协议支持。 +//! +//! 本模块将传输数据包、连接生命周期和业务命令分离:二进制数据包支持压缩载荷, +//! 连接实现认证、心跳与事件读取,业务命令则转换为强类型事件。 +//! +//! [LiveWebSocket] 是面向在线消费的入口;[decode_events] 可用于离线重放已捕获的原始帧。 + +mod command; +mod connection; +mod packet; + +pub use command::*; +pub use connection::LiveWebSocket; +pub use packet::{decode_events, decode_packets, LiveEvent, Packet}; + +#[cfg(test)] +use packet::{encode_packet, events_from_packets}; + +#[cfg(test)] +mod tests; diff --git a/src/websocket/packet.rs b/src/websocket/packet.rs new file mode 100644 index 0000000..ab03719 --- /dev/null +++ b/src/websocket/packet.rs @@ -0,0 +1,124 @@ +//! Bilibili 直播二进制数据包的编解码。 +//! +//! 支持原始 JSON、zlib 和 brotli 载荷,并将操作码为 5 的消息交给业务命令解析器。 + +use super::command::{parse_command, LiveCommand}; +use crate::{Error, Result}; +use brotli::Decompressor; +use flate2::read::ZlibDecoder; +use serde_json::Value; +use std::io::Read; + +const HEADER_LEN: usize = 16; + +/// 一个已解析但尚未按业务类型解释的直播 WebSocket 数据包。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Packet { + /// 协议版本:`0` 为 JSON、`1` 为人气值、`2` 为 zlib、`3` 为 brotli。 + pub version: u16, + /// 操作码,例如 `3` 表示人气值、`5` 表示服务端推送、`8` 表示认证回复。 + pub operation: u32, + /// 去除 16 字节包头后的原始数据体。 + pub body: Vec, +} + +/// 由直播 WebSocket 数据包解析出的事件。 +#[derive(Debug, Clone, PartialEq)] +pub enum LiveEvent { + /// 操作码为 5 的命令,例如 `DANMU_MSG`、`SEND_GIFT` 或 `LIVE`。 + /// + /// 命令会按 `cmd` 分发为 [`LiveCommand`] 的强类型变体。 + Command(Box), + /// 服务端返回的直播间人气值。 + Popularity(u32), + /// 操作码为 8 的 JSON 认证结果。 + Auth(Value), + /// 当前未被进一步解释的数据包。 + Unknown(Packet), +} + +/// 解码一个 WebSocket 二进制帧中的一个或多个协议包。 +/// +/// 压缩包的数据仍保留在 [`Packet::body`] 中;连接读取流程会递归解压 zlib 与 brotli +/// 载荷并转换成事件。 +pub fn decode_packets(bytes: &[u8]) -> Result> { + let mut packets = Vec::new(); + let mut offset = 0; + while offset < bytes.len() { + if bytes.len() - offset < HEADER_LEN { + return Err(Error::InvalidPacket("truncated header".into())); + } + let packet_len = u32::from_be_bytes(bytes[offset..offset + 4].try_into().unwrap()) as usize; + let header_len = + u16::from_be_bytes(bytes[offset + 4..offset + 6].try_into().unwrap()) as usize; + if packet_len < HEADER_LEN + || header_len < HEADER_LEN + || header_len > packet_len + || offset + packet_len > bytes.len() + { + return Err(Error::InvalidPacket("invalid packet length".into())); + } + let version = u16::from_be_bytes(bytes[offset + 6..offset + 8].try_into().unwrap()); + let operation = u32::from_be_bytes(bytes[offset + 8..offset + 12].try_into().unwrap()); + packets.push(Packet { + version, + operation, + body: bytes[offset + header_len..offset + packet_len].to_vec(), + }); + offset += packet_len; + } + Ok(packets) +} + +/// 解码一个原始 WebSocket 二进制帧中的全部直播事件。 +/// +/// 本函数会拆分协议包,并递归解压 zlib 和 brotli 载荷,再将操作码为 5 的消息分发为 +/// [`LiveCommand`]。它适合离线分析 [`crate` 提供的抓取工具](crate::websocket) 输出的 +/// 原始帧。 +pub fn decode_events(bytes: &[u8]) -> Result> { + events_from_packets(&decode_packets(bytes)?) +} + +pub(crate) fn events_from_packets(packets: &[Packet]) -> Result> { + let mut events = Vec::new(); + for packet in packets { + match packet.version { + 2 => { + let mut decoder = ZlibDecoder::new(packet.body.as_slice()); + let mut nested = Vec::new(); + decoder.read_to_end(&mut nested)?; + events.extend(events_from_packets(&decode_packets(&nested)?)?); + } + 3 => { + let mut decoder = Decompressor::new(packet.body.as_slice(), 4096); + let mut nested = Vec::new(); + decoder.read_to_end(&mut nested)?; + events.extend(events_from_packets(&decode_packets(&nested)?)?); + } + _ if packet.operation == 3 && packet.body.len() >= 4 => events.push( + LiveEvent::Popularity(u32::from_be_bytes(packet.body[..4].try_into().unwrap())), + ), + _ if packet.operation == 5 || packet.operation == 8 => { + let value = serde_json::from_slice(&packet.body)?; + events.push(if packet.operation == 8 { + LiveEvent::Auth(value) + } else { + LiveEvent::Command(Box::new(parse_command(value))) + }); + } + _ => events.push(LiveEvent::Unknown(packet.clone())), + } + } + Ok(events) +} + +pub(crate) fn encode_packet(operation: u32, body: &[u8]) -> Vec { + let mut packet = Vec::with_capacity(HEADER_LEN + body.len()); + packet.extend_from_slice(&((HEADER_LEN + body.len()) as u32).to_be_bytes()); + packet.extend_from_slice(&(HEADER_LEN as u16).to_be_bytes()); + packet.extend_from_slice(&1u16.to_be_bytes()); + packet.extend_from_slice(&operation.to_be_bytes()); + packet.extend_from_slice(&1u32.to_be_bytes()); + packet.extend_from_slice(body); + packet +} diff --git a/src/websocket/tests.rs b/src/websocket/tests.rs new file mode 100644 index 0000000..3fde24a --- /dev/null +++ b/src/websocket/tests.rs @@ -0,0 +1,231 @@ +use super::*; +use serde_json::json; +#[test] +fn parses_packet_and_popularity() { + let packet = encode_packet(3, &42u32.to_be_bytes()); + let decoded = decode_packets(&packet).unwrap(); + assert_eq!( + events_from_packets(&decoded).unwrap(), + vec![LiveEvent::Popularity(42)] + ); +} +#[test] +fn rejects_truncated_packet() { + assert!(decode_packets(&[0; 15]).is_err()); +} + +#[test] +fn dispatches_known_command_and_keeps_extra_fields() { + let command = parse_command(json!({ + "cmd": "SEND_GIFT", + "danmu": { "area": 0 }, + "data": { + "uid": 1, + "uname": "测试用户", + "giftId": 123, + "giftName": "辣条", + "num": 2, + "price": 100, + "total_coin": 200, + "coin_type": "gold", + "new_data_field": { "enabled": true } + }, + "msg_id": "message-id", + "p_is_ack": false, + "new_server_field": { "enabled": true } + })); + let LiveCommand::Gift(gift) = command else { + panic!("应解析为礼物消息"); + }; + assert_eq!(gift.data.gift_name.as_deref(), Some("辣条")); + assert_eq!(gift.data.total_coin, Some(200)); + assert_eq!(gift.extra["new_server_field"]["enabled"], true); +} + +#[test] +fn parses_combo_send_and_keeps_nested_extra_fields() { + let command = parse_command(json!({ + "cmd": "COMBO_SEND", + "data": { + "uid": "42", + "uname": "测试用户", + "gift_id": 123, + "gift_name": "辣条", + "num": 2, + "price": 100, + "total_coin": 200, + "coin_type": "gold", + "action": "赠送了", + "combo_id": "combo-1", + "batch_combo_id": "batch-1", + "combo_num": 3, + "batch_combo_num": 4, + "combo_total_coin": 300, + "guard_level": 3, + "medal_info": { + "medal_level": 12, + "medal_name": "测试勋章", + "anchor_uname": "主播", + "medal_future": true + }, + "data_future": true + }, + "top_level_future": true + })); + let LiveCommand::ComboSend(combo) = command else { + panic!("应解析为连击送礼消息"); + }; + assert_eq!(combo.data.uid, Some(42)); + assert_eq!(combo.data.combo_total_coin, Some(300)); + assert_eq!( + combo.data.medal_info.as_ref().and_then(|medal| medal.level), + Some(12) + ); + assert_eq!( + combo.data.medal_info.as_ref().unwrap().extra["medal_future"], + true + ); + assert_eq!(combo.data.extra["data_future"], true); + assert_eq!(combo.extra["top_level_future"], true); +} + +#[test] +fn parses_like_click_and_keeps_nested_extra_fields() { + let command = parse_command(json!({ + "cmd": "LIKE_INFO_V3_CLICK", + "data": { + "uid": 42, + "uname": "测试用户", + "like_text": "为主播点赞了", + "like_count": "99", + "is_like": 1, + "icon": "https://example.test/like.png", + "fans_medal": { + "medal_level": 12, + "medal_name": "测试勋章", + "medal_color": 123, + "medal_future": true + }, + "contribution_info": { "grade": 7, "future": "保留" }, + "identities": ["guard", 3], + "dmscore": 60, + "data_future": true + }, + "top_level_future": true + })); + let LiveCommand::LikeClick(like) = command else { + panic!("应解析为点赞消息"); + }; + assert_eq!(like.data.like_count, Some(99)); + assert_eq!(like.data.is_like, Some(true)); + assert_eq!(like.data.identities, vec![json!("guard"), json!(3)]); + assert_eq!( + like.data.fans_medal.as_ref().unwrap().extra["medal_future"], + true + ); + assert_eq!( + like.data.contribution_info.as_ref().unwrap().extra["future"], + "保留" + ); + assert_eq!(like.data.extra["data_future"], true); + assert_eq!(like.extra["top_level_future"], true); +} + +#[test] +fn parses_like_update() { + let command = parse_command(json!({ + "cmd": "LIKE_INFO_V3_UPDATE", + "data": { "click_count": "12345", "data_future": true }, + "top_level_future": true + })); + let LiveCommand::LikeUpdate(update) = command else { + panic!("应解析为点赞计数变化消息"); + }; + assert_eq!(update.click_count, Some(12_345)); + assert_eq!(update.data_extra["data_future"], true); + assert_eq!(update.extra["top_level_future"], true); +} + +#[test] +fn parses_like_notice_and_keeps_segment_extra_fields() { + let command = parse_command(json!({ + "cmd": "LIKE_INFO_V3_NOTICE", + "data": { + "like_count": 12345, + "msg_type": 1, + "content_segments": [ + { "type": 1, "text": "已有 ", "segment_future": true }, + { "type": 2, "text": "12345" } + ], + "data_future": true + }, + "top_level_future": true + })); + let LiveCommand::LikeNotice(notice) = command else { + panic!("应解析为点赞提示消息"); + }; + assert_eq!(notice.like_count, Some(12_345)); + assert_eq!(notice.message_type, Some(1)); + assert_eq!(notice.content_segments[0].text.as_deref(), Some("已有 ")); + assert_eq!(notice.content_segments[0].extra["segment_future"], true); + assert_eq!(notice.data_extra["data_future"], true); + assert_eq!(notice.extra["top_level_future"], true); +} + +#[test] +fn preserves_unknown_and_invalid_commands() { + let unknown = parse_command(json!({"cmd": "FUTURE_COMMAND", "value": 1})); + assert!(matches!(unknown, LiveCommand::Unknown { .. })); + + let invalid = parse_command(json!({"cmd": "SEND_GIFT", "data": []})); + assert!(matches!(invalid, LiveCommand::Invalid { .. })); +} + +#[test] +fn dispatches_danmu_command_with_suffix() { + let command = parse_command(json!({ + "cmd": "DANMU_MSG:4:0", + "info": [ + [0, 0, 0, 16777215, 1_700_000_000_000_u64, 0, 0, 0, 0, 1, 0, [1, 2]], + "你好,世界", + [42, "测试用户", 1, 0, 0, 37, 1, "#FFFFFF"], + [12, "测试勋章", "主播", 5050, 123, 0, 0, 0, 0, 0, 3], + [38, 0, "#FFFFFF", ">50000"], + ["总督", "https://example.test/title.png"], + null, + 3, + null, + {"ct": "token", "ts": 1_700_000_000}, + null, + null, + null, + null, + null, + {"extra": "{\"foo\":true}", "new_field": 1} + ], + "dm_v2": "v2", + "top_level_future": true + })); + let LiveCommand::Danmu(danmu) = command else { + panic!("应解析为弹幕消息"); + }; + assert_eq!(danmu.text.as_deref(), Some("你好,世界")); + assert_eq!(danmu.sender.uid, Some(42)); + assert_eq!( + danmu.fans_medal.as_ref().and_then(|medal| medal.level), + Some(12) + ); + assert_eq!(danmu.metadata.color, Some(16_777_215)); + assert_eq!( + danmu.timestamp.as_ref().and_then(|time| time.ts), + Some(1_700_000_000) + ); + assert_eq!( + danmu + .extension + .as_ref() + .and_then(|extension| extension.extra_json.as_deref()), + Some("{\"foo\":true}") + ); + assert_eq!(danmu.extra["top_level_future"], true); +}