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:
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
}
|
||||||
@@ -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::*;
|
||||||
@@ -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>,
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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"]),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
//! 直播业务命令的容错解析实现。
|
||||||
|
//!
|
||||||
|
//! 解析器只在命令所需的容器字段缺失或形态错误时报告失败;可选字段无法识别时,
|
||||||
|
//! 会保留其原始 JSON,以便调用方在协议演进期间继续处理事件。
|
||||||
|
|
||||||
|
mod common;
|
||||||
|
mod danmu;
|
||||||
|
mod dispatch;
|
||||||
|
mod gift;
|
||||||
|
mod like;
|
||||||
|
mod status;
|
||||||
|
|
||||||
|
pub use dispatch::parse_command;
|
||||||
@@ -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 字段"))
|
||||||
|
}
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user