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.
This commit is contained in:
2026-07-19 22:31:47 -07:00
parent c5db920738
commit b3e3a3c87c
33 changed files with 2956 additions and 2712 deletions
+90
View File
@@ -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<String>) -> Self {
self.user_agent = user_agent.into();
self
}
/// 创建客户端及其 HTTP 连接池。
pub fn build(self) -> Result<Client> {
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> {
Self::builder().build()
}
/// 使用给定凭据创建客户端。
pub fn with_credentials(credentials: Credentials) -> Result<Self> {
Self::builder().credentials(credentials).build()
}
}
+33
View File
@@ -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<RwLock<Option<WbiKey>>>,
}
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()
}
}
+6 -145
View File
@@ -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::{ use reqwest::{
header::{ header::{ORIGIN, REFERER},
HeaderMap, HeaderValue, ACCEPT, ACCEPT_LANGUAGE, COOKIE, ORIGIN, REFERER, USER_AGENT,
},
Method, Method,
}; };
use serde::de::DeserializeOwned; use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize}; use serde::Deserialize;
use serde_json::Value; 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"; 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<RwLock<Option<WbiKey>>>,
}
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<String>) -> Self {
self.user_agent = user_agent.into();
self
}
/// 创建客户端及其 HTTP 连接池。
pub fn build(self) -> Result<Client> {
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<T> {
/// 业务状态码;`0` 表示成功。
pub code: i64,
/// 业务错误消息。
#[serde(default, alias = "msg")]
pub message: String,
/// Bilibili 缓存控制字段。
#[serde(default)]
pub ttl: i64,
/// 实际业务数据。
///
/// 非成功响应通常不包含该字段。
#[serde(default)]
pub data: Option<T>,
}
impl<T> Response<T> {
/// 在 `code == 0` 时取出 `data`,否则转换为 [`ApiError`]。
pub fn into_data(self) -> Result<T> {
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 { impl Client {
/// 创建客户端构建器。
pub fn builder() -> ClientBuilder {
ClientBuilder::default()
}
/// 创建不含登录凭据的匿名客户端。
pub fn anonymous() -> Result<Self> {
Self::builder().build()
}
/// 使用给定凭据创建客户端。
pub fn with_credentials(credentials: Credentials) -> Result<Self> {
Self::builder().credentials(credentials).build()
}
/// 获取客户端保存的凭据引用。 /// 获取客户端保存的凭据引用。
pub fn credentials(&self) -> &Credentials { pub fn credentials(&self) -> &Credentials {
&self.credentials &self.credentials
+47
View File
@@ -0,0 +1,47 @@
//! Bilibili JSON 响应包裹的通用类型。
use crate::{ApiError, Result};
use serde::{Deserialize, Serialize};
/// Bilibili 常见的 JSON 响应包裹结构。
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Response<T> {
/// 业务状态码;`0` 表示成功。
pub code: i64,
/// 业务错误消息。
#[serde(default, alias = "msg")]
pub message: String,
/// Bilibili 缓存控制字段。
#[serde(default)]
pub ttl: i64,
/// 实际业务数据。
///
/// 非成功响应通常不包含该字段。
#[serde(default)]
pub data: Option<T>,
}
impl<T> Response<T> {
/// 在 `code == 0` 时取出 `data`,否则转换为 [`ApiError`]。
pub fn into_data(self) -> Result<T> {
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())
}
}
}
-628
View File
@@ -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<Item = (&'static str, String)>) -> 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<Value> {
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<Value> {
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<Value> {
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<Value> {
let ids = room_ids
.iter()
.map(u64::to_string)
.collect::<Vec<_>>()
.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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<String>) -> Result<Value> {
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<String>) -> Result<Value> {
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<Value> {
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<String>) -> Result<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<String>,
del_media_ids: impl Into<String>,
) -> Result<Value> {
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<Value> {
self.client
.get("https://api.bilibili.com/x/web-interface/nav", Vec::new())
.await
}
/// 获取用户基础资料。
pub async fn profile(&self, uid: u64) -> Result<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<String>) -> Result<Value> {
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<String>) -> Result<Value> {
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<Value> {
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<Value> {
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<Value> {
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<String>,
root_reply_id: Option<u64>,
) -> Result<Value> {
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<Value> {
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<String>, page: u32) -> Result<Value> {
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<String>, page: u32) -> Result<Value> {
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<String>) -> Result<Value> {
self.client
.get(
"https://api.bilibili.com/x/web-interface/suggest",
q([("term", keyword.into())]),
)
.await
}
/// 通过 WBI 签名获取搜索默认词。
pub async fn default_word(&self) -> Result<Value> {
self.client
.get_wbi(
"https://api.bilibili.com/x/web-interface/wbi/search/default",
Vec::new(),
)
.await
}
}
+100
View File
@@ -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<Value> {
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<Value> {
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<Value> {
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<String>,
root_reply_id: Option<u64>,
) -> Result<Value> {
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<Value> {
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
}
}
+254
View File
@@ -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<Value> {
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<Value> {
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<Value> {
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<Value> {
let ids = room_ids
.iter()
.map(u64::to_string)
.collect::<Vec<_>>()
.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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<String>) -> Result<Value> {
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
}
}
+24
View File
@@ -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<Item = (&'static str, String)>) -> 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;
+58
View File
@@ -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<String>, page: u32) -> Result<Value> {
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<String>, page: u32) -> Result<Value> {
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<String>) -> Result<Value> {
self.client
.get(
"https://api.bilibili.com/x/web-interface/suggest",
q([("term", keyword.into())]),
)
.await
}
/// 通过 WBI 签名获取搜索默认词。
pub async fn default_word(&self) -> Result<Value> {
self.client
.get_wbi(
"https://api.bilibili.com/x/web-interface/wbi/search/default",
Vec::new(),
)
.await
}
}
+110
View File
@@ -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<Value> {
self.client
.get("https://api.bilibili.com/x/web-interface/nav", Vec::new())
.await
}
/// 获取用户基础资料。
pub async fn profile(&self, uid: u64) -> Result<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<String>) -> Result<Value> {
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<String>) -> Result<Value> {
self.client
.get(
"https://api.bilibili.com/x/polymer/web-dynamic/v1/detail",
q([
("id", dynamic_id.into()),
("timezone_offset", "-480".into()),
]),
)
.await
}
}
+43
View File
@@ -0,0 +1,43 @@
//! 视频资源元数据与分类信息端点。
use super::*;
impl<'a> VideoApi<'a> {
/// 根据 BV 号获取视频详情。
pub async fn view_by_bvid(&self, bvid: impl Into<String>) -> Result<Value> {
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<Value> {
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<String>) -> Result<Value> {
self.client
.get(
"https://api.bilibili.com/x/player/pagelist",
q([("bvid", bvid.into())]),
)
.await
}
/// 获取视频标签。
pub async fn tags(&self, aid: u64) -> Result<Value> {
self.client
.get(
"https://api.bilibili.com/x/tag/archive/tags",
q([("aid", aid.to_string())]),
)
.await
}
}
+66
View File
@@ -0,0 +1,66 @@
//! 视频互动关系与状态变更端点。
use super::*;
impl<'a> VideoApi<'a> {
/// 获取当前用户与视频的互动关系,例如点赞和收藏状态。
pub async fn archive_relation(&self, aid: u64) -> Result<Value> {
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<Value> {
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<Value> {
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<String>,
del_media_ids: impl Into<String>,
) -> Result<Value> {
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
}
}
+25
View File
@@ -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 }
}
}
+25
View File
@@ -0,0 +1,25 @@
//! 视频播放器状态与字幕元数据端点。
use super::*;
impl<'a> VideoApi<'a> {
/// 获取视频当前在线观看人数。
pub async fn online_total(&self, aid: u64, cid: u64) -> Result<Value> {
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<Value> {
self.client
.get(
"https://api.bilibili.com/x/v2/subtitle/web/view",
q([("aid", aid.to_string()), ("cid", cid.to_string())]),
)
.await
}
}
+13 -4
View File
@@ -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 //! ```no_run
//! use libilibili::Client; //! use libilibili::Client;
-1935
View File
File diff suppressed because it is too large Load Diff
+73
View File
@@ -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<DanmuMessage>),
/// 礼物消息。
Gift(Box<GiftMessage>),
/// 连击送礼消息。
ComboSend(Box<ComboSendMessage>),
/// 用户点赞消息。
LikeClick(Box<LikeClickMessage>),
/// 直播间点赞计数变化消息。
LikeUpdate(Box<LikeUpdateMessage>),
/// 直播间点赞提示消息。
LikeNotice(Box<LikeNoticeMessage>),
/// 高能榜人数变化消息。
OnlineRankCount(Box<OnlineRankCountMessage>),
/// 看过人数变化消息。
WatchedChange(Box<WatchedChangeMessage>),
/// 一批下播房间列表消息。
StopLiveRoomList(Box<StopLiveRoomListMessage>),
/// 礼物星球进度消息。
GiftStarProcess(Box<GiftStarProcessMessage>),
/// 高能榜 protobuf 消息的外层载荷。
OnlineRankV3(Box<ProtobufPayloadMessage>),
/// 互动 protobuf 消息的外层载荷。
InteractWordV2(Box<ProtobufPayloadMessage>),
/// 大航海购买消息。
GuardBuy(Box<GuardBuyMessage>),
/// 醒目留言消息。
SuperChat(Box<SuperChatMessage>),
/// 用户进入、关注或分享等互动消息。
InteractWord(Box<InteractWordMessage>),
/// 直播间标题或分区变更消息。
RoomChange(Box<RoomChangeMessage>),
/// 直播开始消息。
Live(Box<LiveStatusMessage>),
/// 直播结束消息。
Preparing(Box<LiveStatusMessage>),
/// crate 当前未支持的命令。
Unknown {
/// 原始 `cmd` 字段;缺失或非字符串时为 `None`。
command: Option<String>,
/// 完整原始 JSON。
raw: Value,
},
/// 已支持的命令无法反序列化为预期结构。
Invalid {
/// 服务端原始 `cmd` 字段。
command: Option<String>,
/// 完整原始 JSON。
raw: Value,
/// serde 产生的解析错误文本。
error: String,
},
}
+140
View File
@@ -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<String>,
/// 发送者信息。
pub sender: DanmuSender,
/// 发送者佩戴的粉丝勋章;没有勋章时为 `None`。
pub fans_medal: Option<FansMedal>,
/// 发送者的用户等级。
pub user_level: Option<UserLevel>,
/// 发送者的头衔。
pub title: Option<UserTitle>,
/// 用户身份或守护等级。
pub guard_level: Option<u8>,
/// 弹幕校验与时间戳信息。
pub timestamp: Option<DanmuTimestamp>,
/// 弹幕扩展信息。
pub extension: Option<DanmuExtension>,
/// `info` 数组中未知的索引或类型不符合预期的原始值。
pub unknown_info: HashMap<usize, Value>,
/// 新版弹幕的辅助字段。
pub dm_v2: Option<String>,
/// 未被当前版本建模的顶层字段。
pub extra: HashMap<String, Value>,
}
/// [`DanmuMessage`] 的元信息。
#[derive(Debug, Clone, Default, PartialEq)]
pub struct DanmuMetadata {
/// 十进制 RGB 弹幕颜色。
pub color: Option<u32>,
/// 服务端记录的毫秒级时间戳。
pub sent_at_ms: Option<u64>,
/// Bilibili 定义的弹幕类型。
pub danmu_type: Option<u8>,
/// 渐变终止色。
///
/// 当前抓取样本为单个十进制颜色值;其他形态保留在 [`DanmuMetadata::extra`]。
pub gradient_color: Option<u32>,
/// 元信息数组中未建模的索引。
pub extra: HashMap<usize, Value>,
}
/// [`DanmuMessage`] 的发送者信息。
#[derive(Debug, Clone, Default, PartialEq)]
pub struct DanmuSender {
/// 用户 UID。
pub uid: Option<u64>,
/// 用户昵称。
pub uname: Option<String>,
/// 是否为房管。
pub is_admin: Option<bool>,
/// 是否为大会员。
pub is_vip: Option<bool>,
/// 是否为年度大会员。
pub is_svip: Option<bool>,
/// 用户等级。
pub rank: Option<u32>,
/// 昵称颜色。
pub name_color: Option<String>,
/// 发送者数组中未建模的索引。
pub extra: HashMap<usize, Value>,
}
/// 粉丝勋章信息。
#[derive(Debug, Clone, Default, PartialEq)]
pub struct FansMedal {
/// 勋章等级。
pub level: Option<u8>,
/// 勋章名称。
pub name: Option<String>,
/// 主播昵称。
pub anchor_name: Option<String>,
/// 主播直播间 ID。
pub anchor_room_id: Option<u64>,
/// 勋章颜色。
pub color: Option<u32>,
/// 大航海等级。
pub guard_level: Option<u8>,
/// 勋章数组中未建模的索引。
pub extra: HashMap<usize, Value>,
}
/// 用户等级信息。
#[derive(Debug, Clone, Default, PartialEq)]
pub struct UserLevel {
/// 用户等级。
pub level: Option<u8>,
/// 十进制等级颜色。
pub color: Option<u32>,
/// 等级排名文字。
pub rank_text: Option<String>,
/// 用户等级数组中未建模的索引。
pub extra: HashMap<usize, Value>,
}
/// 用户头衔信息。
#[derive(Debug, Clone, Default, PartialEq)]
pub struct UserTitle {
/// 头衔名称。
pub name: Option<String>,
/// 头衔图标 URL。
pub icon: Option<String>,
/// 头衔数组中未建模的索引。
pub extra: HashMap<usize, Value>,
}
/// 弹幕校验与时间戳信息。
#[derive(Debug, Clone, Default, PartialEq)]
pub struct DanmuTimestamp {
/// 服务端校验字符串。
pub ct: Option<String>,
/// Unix 秒级时间戳。
pub ts: Option<u64>,
/// 未建模字段。
pub extra: HashMap<String, Value>,
}
/// 弹幕的扩展信息。
#[derive(Debug, Clone, Default, PartialEq)]
pub struct DanmuExtension {
/// Bilibili 在扩展对象中返回的 JSON 字符串。
pub extra_json: Option<String>,
/// 未建模字段。
pub extra: HashMap<String, Value>,
}
+147
View File
@@ -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<GiftDanmu>,
/// 服务端消息 ID。
pub message_id: Option<String>,
/// 服务端是否要求客户端确认。
pub requires_ack: Option<bool>,
/// 服务端消息类型。
pub message_type: Option<u32>,
/// 服务端发送时间。
pub sent_at: Option<u64>,
/// 未被当前版本建模的顶层字段。
pub extra: HashMap<String, Value>,
}
/// [`GiftMessage`] 的礼物业务数据。
#[derive(Debug, Clone, Default, PartialEq)]
pub struct GiftData {
/// 送礼用户 UID。
pub uid: Option<u64>,
/// 送礼用户昵称。
pub uname: Option<String>,
/// 礼物 ID。
pub gift_id: Option<u64>,
/// 礼物名称。
pub gift_name: Option<String>,
/// 礼物数量。
pub num: Option<u64>,
/// 单个礼物价格。
pub price: Option<u64>,
/// 实付总额。
pub total_coin: Option<u64>,
/// 货币类型,例如 `gold` 或 `silver`。
pub coin_type: Option<String>,
/// 动作文案。
pub action: Option<String>,
/// 用户财富等级。
pub wealth_level: Option<u32>,
/// 大航海等级。
pub guard_level: Option<u8>,
/// 粉丝勋章信息。
pub medal: Option<GiftMedalInfo>,
/// 未被当前版本建模的 `data` 字段。
pub extra: HashMap<String, Value>,
}
/// 礼物消息中的粉丝勋章信息。
#[derive(Debug, Clone, Default, PartialEq)]
pub struct GiftMedalInfo {
/// 勋章等级。
pub level: Option<u8>,
/// 勋章名称。
pub name: Option<String>,
/// 勋章所属主播昵称。
pub anchor_name: Option<String>,
/// 大航海等级。
pub guard_level: Option<u8>,
/// 未被当前版本建模的字段。
pub extra: HashMap<String, Value>,
}
/// 礼物消息的弹幕区域信息。
#[derive(Debug, Clone, Default, PartialEq)]
pub struct GiftDanmu {
/// 弹幕区域。
pub area: Option<u32>,
/// 未被当前版本建模的字段。
pub extra: HashMap<String, Value>,
}
/// 连击送礼消息。
#[derive(Debug, Clone, PartialEq)]
pub struct ComboSendMessage {
/// 服务端原始命令名。
pub cmd: String,
/// 连击送礼业务数据。
pub data: ComboSendData,
/// 未被当前版本建模的顶层字段。
pub extra: HashMap<String, Value>,
}
/// [`ComboSendMessage`] 的连击送礼业务数据。
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ComboSendData {
/// 送礼用户 UID。
pub uid: Option<u64>,
/// 送礼用户昵称。
pub uname: Option<String>,
/// 礼物 ID。
pub gift_id: Option<u64>,
/// 礼物名称。
pub gift_name: Option<String>,
/// 本次礼物数量。
pub num: Option<u64>,
/// 单个礼物价格。
pub price: Option<u64>,
/// 本次实付总额。
pub total_coin: Option<u64>,
/// 货币类型,例如 `gold`。
pub coin_type: Option<String>,
/// 动作文案。
pub action: Option<String>,
/// 连击 ID。
pub combo_id: Option<String>,
/// 批次连击 ID。
pub batch_combo_id: Option<String>,
/// 当前连击数量。
pub combo_num: Option<u64>,
/// 当前批次连击数量。
pub batch_combo_num: Option<u64>,
/// 连击累计实付总额。
pub combo_total_coin: Option<u64>,
/// 大航海等级。
pub guard_level: Option<u8>,
/// 粉丝勋章信息。
pub medal_info: Option<ComboSendMedalInfo>,
/// 未被当前版本建模的 `data` 字段。
pub extra: HashMap<String, Value>,
}
/// 连击送礼消息中的粉丝勋章信息。
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ComboSendMedalInfo {
/// 勋章等级。
pub level: Option<u8>,
/// 勋章名称。
pub name: Option<String>,
/// 勋章所属主播昵称。
pub anchor_name: Option<String>,
/// 大航海等级。
pub guard_level: Option<u8>,
/// 未被当前版本建模的字段。
pub extra: HashMap<String, Value>,
}
+119
View File
@@ -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<String, Value>,
}
/// [`LikeClickMessage`] 的点赞业务数据。
#[derive(Debug, Clone, Default, PartialEq)]
pub struct LikeClickData {
/// 点赞用户 UID。
pub uid: Option<u64>,
/// 点赞用户昵称。
pub uname: Option<String>,
/// 点赞文案。
pub like_text: Option<String>,
/// 当前点赞计数。
pub like_count: Option<u64>,
/// 是否为点赞操作。
pub is_like: Option<bool>,
/// 点赞图标 URL。
pub icon: Option<String>,
/// 粉丝勋章信息。
pub fans_medal: Option<LikeFansMedalInfo>,
/// 用户贡献信息。
pub contribution_info: Option<LikeContributionInfo>,
/// 用户身份标识。
pub identities: Vec<Value>,
/// 弹幕评分。
pub dmscore: Option<u64>,
/// 未被当前版本建模的 `data` 字段。
pub extra: HashMap<String, Value>,
}
/// 点赞消息中的粉丝勋章信息。
#[derive(Debug, Clone, Default, PartialEq)]
pub struct LikeFansMedalInfo {
/// 勋章等级。
pub level: Option<u8>,
/// 勋章名称。
pub name: Option<String>,
/// 勋章颜色。
pub color: Option<u32>,
/// 渐变起始颜色。
pub color_start: Option<u32>,
/// 渐变结束颜色。
pub color_end: Option<u32>,
/// 边框颜色。
pub color_border: Option<u32>,
/// 勋章所属直播间 ID。
pub anchor_room_id: Option<u64>,
/// 勋章所属主播昵称。
pub anchor_name: Option<String>,
/// 大航海等级。
pub guard_level: Option<u8>,
/// 未被当前版本建模的字段。
pub extra: HashMap<String, Value>,
}
/// 点赞消息中的用户贡献信息。
#[derive(Debug, Clone, Default, PartialEq)]
pub struct LikeContributionInfo {
/// 贡献等级。
pub grade: Option<u64>,
/// 未被当前版本建模的字段。
pub extra: HashMap<String, Value>,
}
/// 直播间点赞计数变化消息。
#[derive(Debug, Clone, Default, PartialEq)]
pub struct LikeUpdateMessage {
/// 服务端原始命令名。
pub cmd: String,
/// 直播间当前点赞计数。
pub click_count: Option<u64>,
/// 未被当前版本建模或类型不符合预期的 `data` 字段。
pub data_extra: HashMap<String, Value>,
/// 未被当前版本建模的顶层字段。
pub extra: HashMap<String, Value>,
}
/// 直播间点赞提示消息。
#[derive(Debug, Clone, Default, PartialEq)]
pub struct LikeNoticeMessage {
/// 服务端原始命令名。
pub cmd: String,
/// 直播间当前点赞计数。
pub like_count: Option<u64>,
/// 服务端定义的提示消息类型。
pub message_type: Option<u8>,
/// 组成提示文案的片段。
pub content_segments: Vec<LikeNoticeContentSegment>,
/// `content_segments` 中不是对象的原始条目。
pub unknown_content_segments: HashMap<usize, Value>,
/// 未被当前版本建模或类型不符合预期的 `data` 字段。
pub data_extra: HashMap<String, Value>,
/// 未被当前版本建模的顶层字段。
pub extra: HashMap<String, Value>,
}
/// [`LikeNoticeMessage`] 提示文案中的一个片段。
#[derive(Debug, Clone, Default, PartialEq)]
pub struct LikeNoticeContentSegment {
/// 服务端定义的片段类型。
pub kind: Option<u8>,
/// 片段文本。
pub text: Option<String>,
/// 未被当前版本建模的字段。
pub extra: HashMap<String, Value>,
}
+14
View File
@@ -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::*;
+182
View File
@@ -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<u64>,
/// 高能榜人数显示文本。
pub count_text: Option<String>,
/// 在线人数。
pub online_count: Option<u64>,
/// 在线人数显示文本。
pub online_count_text: Option<String>,
/// 未被当前版本建模的字段。
pub extra: HashMap<String, Value>,
}
/// 看过人数变化消息。
#[derive(Debug, Clone, Default, PartialEq)]
pub struct WatchedChangeMessage {
/// 服务端原始命令名。
pub cmd: String,
/// 看过人数。
pub watched_count: Option<u64>,
/// 小字号显示文本。
pub text_small: Option<String>,
/// 大字号显示文本。
pub text_large: Option<String>,
/// 未被当前版本建模的字段。
pub extra: HashMap<String, Value>,
}
/// 一批下播房间列表消息。
#[derive(Debug, Clone, Default, PartialEq)]
pub struct StopLiveRoomListMessage {
/// 服务端原始命令名。
pub cmd: String,
/// 已下播的房间 ID 列表。
pub room_ids: Vec<u64>,
/// 未被当前版本建模的字段。
pub extra: HashMap<String, Value>,
}
/// 礼物星球进度消息。
#[derive(Debug, Clone, Default, PartialEq)]
pub struct GiftStarProcessMessage {
/// 服务端原始命令名。
pub cmd: String,
/// 当前进度名称。
pub name: Option<String>,
/// 当前数量。
pub current: Option<u64>,
/// 目标数量。
pub total: Option<u64>,
/// 服务端版本号。
pub version: Option<u64>,
/// 未被当前版本建模的字段。
pub extra: HashMap<String, Value>,
}
/// 含 protobuf Base64 载荷的推送消息。
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ProtobufPayloadMessage {
/// 服务端原始命令名。
pub cmd: String,
/// Base64 编码的 protobuf 数据。
pub pb_base64: String,
/// 弹幕评分。
pub dm_score: Option<u64>,
/// 未被当前版本建模的顶层或 `data` 字段。
pub extra: HashMap<String, Value>,
}
/// 大航海购买消息。
#[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<String, Value>,
}
/// 醒目留言消息。
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct SuperChatMessage {
/// 服务端原始命令名。
pub cmd: String,
/// 醒目留言的具体数据。
pub data: SuperChatData,
/// 未被当前版本建模的顶层字段。
#[serde(default, flatten)]
pub extra: HashMap<String, Value>,
}
/// [`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<String, Value>,
}
/// 用户进入、关注或分享等互动消息。
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct InteractWordMessage {
/// 服务端原始命令名。
pub cmd: String,
/// 互动事件的具体数据。
pub data: InteractWordData,
/// 未被当前版本建模的顶层字段。
#[serde(default, flatten)]
pub extra: HashMap<String, Value>,
}
/// [`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<String, Value>,
}
/// 直播间标题或分区发生变化时的消息。
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct RoomChangeMessage {
/// 服务端原始命令名。
pub cmd: String,
/// 当前直播标题。
#[serde(default)]
pub title: Option<String>,
/// 当前子分区名称。
#[serde(default)]
pub area_name: Option<String>,
/// 当前父分区名称。
#[serde(default)]
pub parent_area_name: Option<String>,
/// 未被当前版本建模的顶层字段。
#[serde(default, flatten)]
pub extra: HashMap<String, Value>,
}
/// 开播或下播状态消息。
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct LiveStatusMessage {
/// 服务端原始命令名。
pub cmd: String,
/// 直播间 ID;部分推送可能省略该字段。
#[serde(default)]
pub roomid: Option<u64>,
/// 未被当前版本建模的顶层字段。
#[serde(default, flatten)]
pub extra: HashMap<String, Value>,
}
+70
View File
@@ -0,0 +1,70 @@
//! 命令解析共用的 JSON 值转换与扩展字段保留逻辑.
use serde_json::Value;
use std::collections::HashMap;
pub(super) fn indexed_extra(values: &[Value], known: &[usize]) -> HashMap<usize, Value> {
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<String, Value> {
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<String, Value> {
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<String> {
value.as_str().map(str::to_owned)
}
pub(super) fn value_lossless_string(value: &Value) -> Option<String> {
value_string(value).or_else(|| value_u64(value).map(|number| number.to_string()))
}
pub(super) fn value_u64(value: &Value) -> Option<u64> {
value
.as_u64()
.or_else(|| value.as_str().and_then(|text| text.parse().ok()))
}
pub(super) fn value_u32(value: &Value) -> Option<u32> {
value_u64(value).and_then(|number| number.try_into().ok())
}
pub(super) fn value_u8(value: &Value) -> Option<u8> {
value_u64(value).and_then(|number| number.try_into().ok())
}
pub(super) fn value_bool(value: &Value) -> Option<bool> {
value.as_bool().or_else(|| match value_u64(value) {
Some(0) => Some(false),
Some(1) => Some(true),
_ => None,
})
}
+135
View File
@@ -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<DanmuMessage, String> {
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<T>(
values: &[Value],
index: usize,
unknown: &mut HashMap<usize, Value>,
parser: impl FnOnce(&Value) -> Option<T>,
) -> Option<T> {
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<usize, Value>,
) -> Option<FansMedal> {
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<DanmuMetadata> {
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<DanmuSender> {
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<UserLevel> {
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<UserTitle> {
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<DanmuTimestamp> {
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<DanmuExtension> {
value.as_object().map(|object| DanmuExtension {
extra_json: object.get("extra").and_then(value_string),
extra: object_extra(value, &["extra"]),
})
}
+146
View File
@@ -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,
},
}
}
+212
View File
@@ -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<GiftMessage, String> {
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<GiftMedalInfo> {
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<GiftDanmu> {
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<ComboSendMessage, String> {
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<ComboSendMedalInfo> {
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(),
),
],
),
})
}
+217
View File
@@ -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<LikeClickMessage, String> {
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<LikeFansMedalInfo> {
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<LikeContributionInfo> {
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<LikeUpdateMessage, String> {
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<LikeNoticeMessage, String> {
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<LikeNoticeContentSegment> {
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()),
],
),
})
}
+13
View File
@@ -0,0 +1,13 @@
//! 直播业务命令的容错解析实现。
//!
//! 解析器只在命令所需的容器字段缺失或形态错误时报告失败;可选字段无法识别时,
//! 会保留其原始 JSON,以便调用方在协议演进期间继续处理事件。
mod common;
mod danmu;
mod dispatch;
mod gift;
mod like;
mod status;
pub use dispatch::parse_command;
+89
View File
@@ -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<OnlineRankCountMessage, String> {
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<WatchedChangeMessage, String> {
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<StopLiveRoomListMessage, String> {
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<GiftStarProcessMessage, String> {
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<ProtobufPayloadMessage, String> {
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>, String> {
value
.get("data")
.and_then(Value::as_object)
.ok_or_else(|| format!("{command} 缺少对象类型的 data 字段"))
}
+130
View File
@@ -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<MaybeTlsStream<TcpStream>>,
pending: VecDeque<LiveEvent>,
}
impl LiveWebSocket {
/// 建立直播弹幕连接并完成认证。
///
/// 会从导航接口读取当前登录用户 UID、请求弹幕 token、选择第一个 WSS 主机,
/// 并发送操作码为 7 的认证包。当前网页协议通常要求有效的登录 Cookie。
pub async fn connect(client: &Client, room_id: u64) -> Result<Self> {
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<Self> {
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<Option<LiveEvent>> {
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<Option<Vec<u8>>> {
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(())
}
}
+20
View File
@@ -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;
+124
View File
@@ -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<u8>,
}
/// 由直播 WebSocket 数据包解析出的事件。
#[derive(Debug, Clone, PartialEq)]
pub enum LiveEvent {
/// 操作码为 5 的命令,例如 `DANMU_MSG`、`SEND_GIFT` 或 `LIVE`。
///
/// 命令会按 `cmd` 分发为 [`LiveCommand`] 的强类型变体。
Command(Box<LiveCommand>),
/// 服务端返回的直播间人气值。
Popularity(u32),
/// 操作码为 8 的 JSON 认证结果。
Auth(Value),
/// 当前未被进一步解释的数据包。
Unknown(Packet),
}
/// 解码一个 WebSocket 二进制帧中的一个或多个协议包。
///
/// 压缩包的数据仍保留在 [`Packet::body`] 中;连接读取流程会递归解压 zlib 与 brotli
/// 载荷并转换成事件。
pub fn decode_packets(bytes: &[u8]) -> Result<Vec<Packet>> {
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<Vec<LiveEvent>> {
events_from_packets(&decode_packets(bytes)?)
}
pub(crate) fn events_from_packets(packets: &[Packet]) -> Result<Vec<LiveEvent>> {
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<u8> {
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
}
+231
View File
@@ -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);
}