initial commit
This commit is contained in:
+86
@@ -0,0 +1,86 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// 浏览器会话凭据。
|
||||
///
|
||||
/// 凭据仅保存在内存中,且不会通过本 crate 的 `Debug` 输出泄露。
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Credentials {
|
||||
cookies: BTreeMap<String, String>,
|
||||
csrf: Option<String>,
|
||||
access_key: Option<String>,
|
||||
}
|
||||
|
||||
impl Credentials {
|
||||
/// 创建凭据构建器。
|
||||
pub fn builder() -> CredentialsBuilder {
|
||||
CredentialsBuilder::default()
|
||||
}
|
||||
|
||||
/// 从 `Cookie` 请求头文本解析凭据。
|
||||
///
|
||||
/// 若其中含有 `bili_jct`,它会同时被用作 CSRF token。
|
||||
pub fn from_cookie_header(header: impl AsRef<str>) -> Self {
|
||||
let mut result = Self::default();
|
||||
for pair in header.as_ref().split(';') {
|
||||
if let Some((name, value)) = pair.trim().split_once('=') {
|
||||
if !name.is_empty() {
|
||||
result.cookies.insert(name.to_owned(), value.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
result.csrf = result.cookies.get("bili_jct").cloned();
|
||||
result
|
||||
}
|
||||
|
||||
/// 生成可直接写入 HTTP `Cookie` 请求头的值;没有 Cookie 时返回 `None`。
|
||||
pub fn cookie_header(&self) -> Option<String> {
|
||||
(!self.cookies.is_empty()).then(|| {
|
||||
self.cookies
|
||||
.iter()
|
||||
.map(|(name, value)| format!("{name}={value}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ")
|
||||
})
|
||||
}
|
||||
/// 获取 CSRF token,通常等于 Cookie 中的 `bili_jct`。
|
||||
pub fn csrf(&self) -> Option<&str> {
|
||||
self.csrf.as_deref()
|
||||
}
|
||||
/// 获取 APP 接口使用的 `access_key`。
|
||||
pub fn access_key(&self) -> Option<&str> {
|
||||
self.access_key.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Credentials`] 的链式构建器。
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct CredentialsBuilder(Credentials);
|
||||
|
||||
impl CredentialsBuilder {
|
||||
/// 添加任意名称的 Cookie。
|
||||
pub fn cookie(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
|
||||
self.0.cookies.insert(name.into(), value.into());
|
||||
self
|
||||
}
|
||||
/// 设置登录会话 Cookie `SESSDATA`。
|
||||
pub fn sessdata(self, value: impl Into<String>) -> Self {
|
||||
self.cookie("SESSDATA", value)
|
||||
}
|
||||
/// 显式设置 CSRF token;未设置时会尝试使用 `bili_jct` Cookie。
|
||||
pub fn csrf(mut self, value: impl Into<String>) -> Self {
|
||||
self.0.csrf = Some(value.into());
|
||||
self
|
||||
}
|
||||
/// 设置移动端接口使用的 `access_key`。
|
||||
pub fn access_key(mut self, value: impl Into<String>) -> Self {
|
||||
self.0.access_key = Some(value.into());
|
||||
self
|
||||
}
|
||||
/// 构建凭据。
|
||||
pub fn build(mut self) -> Credentials {
|
||||
if self.0.csrf.is_none() {
|
||||
self.0.csrf = self.0.cookies.get("bili_jct").cloned();
|
||||
}
|
||||
self.0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
//! `libilibili-capture` 产生的 JSONL 文件的离线类型分发统计工具。
|
||||
//!
|
||||
//! 工具只输出事件类型、未知命令和解析错误数量,不输出弹幕、昵称或其他业务内容。
|
||||
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use libilibili::{
|
||||
websocket::{decode_events, LiveCommand, LiveEvent},
|
||||
Error, Result,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::{collections::BTreeMap, env, fs};
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let path = env::args().nth(1).ok_or_else(|| {
|
||||
Error::InvalidPacket(
|
||||
"用法:libilibili-analyze <libilibili-capture 输出的 JSONL 文件>".into(),
|
||||
)
|
||||
})?;
|
||||
let input = fs::read_to_string(path)?;
|
||||
let mut counts = BTreeMap::<String, u64>::new();
|
||||
let mut frames = 0_u64;
|
||||
let mut decode_errors = 0_u64;
|
||||
|
||||
for line in input.lines().filter(|line| !line.trim().is_empty()) {
|
||||
let record: Value = serde_json::from_str(line)?;
|
||||
let Some(frame_base64) = record.get("frame_base64").and_then(Value::as_str) else {
|
||||
decode_errors += 1;
|
||||
continue;
|
||||
};
|
||||
frames += 1;
|
||||
match STANDARD
|
||||
.decode(frame_base64)
|
||||
.ok()
|
||||
.and_then(|frame| decode_events(&frame).ok())
|
||||
{
|
||||
Some(events) => {
|
||||
for event in events {
|
||||
*counts.entry(event_name(&event)).or_default() += 1;
|
||||
}
|
||||
}
|
||||
None => decode_errors += 1,
|
||||
}
|
||||
}
|
||||
|
||||
println!("frames={frames}");
|
||||
println!("decode_errors={decode_errors}");
|
||||
for (name, count) in counts {
|
||||
println!("{name}={count}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn event_name(event: &LiveEvent) -> String {
|
||||
match event {
|
||||
LiveEvent::Popularity(_) => "Popularity".into(),
|
||||
LiveEvent::Auth(_) => "Auth".into(),
|
||||
LiveEvent::Unknown(_) => "PacketUnknown".into(),
|
||||
LiveEvent::Command(command) => match command.as_ref() {
|
||||
LiveCommand::Danmu(_) => "Danmu".into(),
|
||||
LiveCommand::Gift(_) => "Gift".into(),
|
||||
LiveCommand::ComboSend(_) => "ComboSend".into(),
|
||||
LiveCommand::LikeClick(_) => "LikeClick".into(),
|
||||
LiveCommand::LikeUpdate(_) => "LikeUpdate".into(),
|
||||
LiveCommand::LikeNotice(_) => "LikeNotice".into(),
|
||||
LiveCommand::OnlineRankCount(_) => "OnlineRankCount".into(),
|
||||
LiveCommand::WatchedChange(_) => "WatchedChange".into(),
|
||||
LiveCommand::StopLiveRoomList(_) => "StopLiveRoomList".into(),
|
||||
LiveCommand::GiftStarProcess(_) => "GiftStarProcess".into(),
|
||||
LiveCommand::OnlineRankV3(_) => "OnlineRankV3".into(),
|
||||
LiveCommand::InteractWordV2(_) => "InteractWordV2".into(),
|
||||
LiveCommand::GuardBuy(_) => "GuardBuy".into(),
|
||||
LiveCommand::SuperChat(_) => "SuperChat".into(),
|
||||
LiveCommand::InteractWord(_) => "InteractWord".into(),
|
||||
LiveCommand::RoomChange(_) => "RoomChange".into(),
|
||||
LiveCommand::Live(_) => "Live".into(),
|
||||
LiveCommand::Preparing(_) => "Preparing".into(),
|
||||
LiveCommand::Unknown { command, .. } => {
|
||||
format!("Unknown:{}", command.as_deref().unwrap_or("<missing>"))
|
||||
}
|
||||
LiveCommand::Invalid { command, .. } => {
|
||||
format!("Invalid:{}", command.as_deref().unwrap_or("<missing>"))
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
//! 直播弹幕 WebSocket 原始帧抓取工具。
|
||||
//!
|
||||
//! stderr 仅记录运行状态;stdout 输出 JSON Lines,每一行都是一个收到的原始
|
||||
//! WebSocket 二进制帧及其可选的协议包头摘要。Cookie 永远不会被输出。
|
||||
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use libilibili::{
|
||||
websocket::{decode_packets, LiveWebSocket},
|
||||
Client, Credentials, Error, Result,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use std::{
|
||||
env,
|
||||
io::{self, Read, Write},
|
||||
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
struct Config {
|
||||
cookie: String,
|
||||
room_id: u64,
|
||||
duration: Duration,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let Some(config) = parse_args()? else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
// Cookie 仅传给客户端;日志刻意不打印其值或长度。
|
||||
let client = Client::with_credentials(Credentials::from_cookie_header(&config.cookie))?;
|
||||
eprintln!(
|
||||
"正在连接直播间 {},抓取时长 {} 秒。",
|
||||
config.room_id,
|
||||
config.duration.as_secs()
|
||||
);
|
||||
let mut socket = LiveWebSocket::connect(&client, config.room_id).await?;
|
||||
eprintln!("WebSocket 已认证。原始帧将以 JSON Lines 输出到 stdout。");
|
||||
|
||||
let end = Instant::now() + config.duration;
|
||||
let mut next_heartbeat = Instant::now() + Duration::from_secs(30);
|
||||
let mut sequence = 0_u64;
|
||||
let stdout = io::stdout();
|
||||
let mut output = io::BufWriter::new(stdout.lock());
|
||||
|
||||
while Instant::now() < end {
|
||||
let now = Instant::now();
|
||||
if now >= next_heartbeat {
|
||||
socket.heartbeat().await?;
|
||||
next_heartbeat = now + Duration::from_secs(30);
|
||||
eprintln!("已发送心跳。");
|
||||
}
|
||||
|
||||
let deadline = end.min(next_heartbeat);
|
||||
let timeout = deadline.saturating_duration_since(Instant::now());
|
||||
match tokio::time::timeout(timeout, socket.next_raw_frame()).await {
|
||||
Err(_) => continue,
|
||||
Ok(Ok(None)) => {
|
||||
eprintln!("服务器已关闭 WebSocket 连接。");
|
||||
break;
|
||||
}
|
||||
Ok(Err(error)) => return Err(error),
|
||||
Ok(Ok(Some(frame))) => {
|
||||
sequence += 1;
|
||||
let record = frame_record(sequence, &frame);
|
||||
serde_json::to_writer(&mut output, &record)?;
|
||||
output.write_all(b"\n")?;
|
||||
output.flush()?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
eprintln!("抓取结束,共输出 {sequence} 个 WebSocket 帧。");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn frame_record(sequence: u64, frame: &[u8]) -> Value {
|
||||
let received_at_ms = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis();
|
||||
match decode_packets(frame) {
|
||||
Ok(packets) => json!({
|
||||
"sequence": sequence,
|
||||
"received_at_ms": received_at_ms,
|
||||
"frame_base64": STANDARD.encode(frame),
|
||||
"packets": packets.into_iter().map(|packet| json!({
|
||||
"version": packet.version,
|
||||
"operation": packet.operation,
|
||||
"body_base64": STANDARD.encode(packet.body),
|
||||
})).collect::<Vec<_>>(),
|
||||
}),
|
||||
Err(error) => json!({
|
||||
"sequence": sequence,
|
||||
"received_at_ms": received_at_ms,
|
||||
"frame_base64": STANDARD.encode(frame),
|
||||
"decode_error": error.to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_args() -> Result<Option<Config>> {
|
||||
let mut cookie = None;
|
||||
let mut cookie_stdin = false;
|
||||
let mut room_id = None;
|
||||
let mut seconds = 60_u64;
|
||||
let mut args = env::args().skip(1);
|
||||
|
||||
while let Some(argument) = args.next() {
|
||||
match argument.as_str() {
|
||||
"--cookie" => cookie = Some(next_value(&mut args, "--cookie")?),
|
||||
"--cookie-stdin" => cookie_stdin = true,
|
||||
"--room-id" | "-r" => {
|
||||
room_id = Some(
|
||||
next_value(&mut args, "--room-id")?
|
||||
.parse()
|
||||
.map_err(|_| Error::InvalidPacket("直播间 ID 必须是无符号整数".into()))?,
|
||||
)
|
||||
}
|
||||
"--seconds" | "-s" => {
|
||||
seconds = next_value(&mut args, "--seconds")?
|
||||
.parse()
|
||||
.map_err(|_| Error::InvalidPacket("抓取秒数必须是无符号整数".into()))?
|
||||
}
|
||||
"--help" | "-h" => {
|
||||
print_usage();
|
||||
return Ok(None);
|
||||
}
|
||||
_ => {
|
||||
return Err(Error::InvalidPacket(format!(
|
||||
"未知参数:{argument};使用 --help 查看帮助"
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if cookie.is_some() && cookie_stdin {
|
||||
return Err(Error::InvalidPacket(
|
||||
"--cookie 与 --cookie-stdin 不能同时使用".into(),
|
||||
));
|
||||
}
|
||||
let cookie = match (cookie, cookie_stdin) {
|
||||
(Some(cookie), false) => cookie,
|
||||
(None, true) => {
|
||||
let mut input = String::new();
|
||||
io::stdin().read_to_string(&mut input)?;
|
||||
let cookie = input.trim().to_owned();
|
||||
if cookie.is_empty() {
|
||||
return Err(Error::InvalidPacket("标准输入中的 Cookie 为空".into()));
|
||||
}
|
||||
cookie
|
||||
}
|
||||
(None, false) => {
|
||||
return Err(Error::InvalidPacket(
|
||||
"缺少 --cookie 或 --cookie-stdin 参数".into(),
|
||||
))
|
||||
}
|
||||
(Some(_), true) => unreachable!("前置条件已检查"),
|
||||
};
|
||||
let room_id = room_id.ok_or_else(|| Error::InvalidPacket("缺少 --room-id 参数".into()))?;
|
||||
Ok(Some(Config {
|
||||
cookie,
|
||||
room_id,
|
||||
duration: Duration::from_secs(seconds),
|
||||
}))
|
||||
}
|
||||
|
||||
fn next_value(args: &mut impl Iterator<Item = String>, name: &str) -> Result<String> {
|
||||
args.next()
|
||||
.ok_or_else(|| Error::InvalidPacket(format!("{name} 需要一个值")))
|
||||
}
|
||||
|
||||
fn print_usage() {
|
||||
eprintln!(
|
||||
"\
|
||||
用法:
|
||||
libilibili-capture --cookie 'SESSDATA=...; bili_jct=...; buvid3=...' --room-id 5050 [--seconds 60]
|
||||
|
||||
选项:
|
||||
-r, --room-id <ID> 要监听的直播间 ID
|
||||
-s, --seconds <秒> 抓取时长,默认 60 秒
|
||||
--cookie <文本> 完整 Cookie 请求头文本
|
||||
--cookie-stdin 从标准输入读取完整 Cookie(更安全)
|
||||
-h, --help 显示本帮助
|
||||
|
||||
stdout 为 JSON Lines,包含原始帧和包体的 Base64。运行状态仅写入 stderr。
|
||||
注意:命令行参数可能被同机其他用户读取;优先使用 --cookie-stdin 或 secret 管理器注入 Cookie。"
|
||||
);
|
||||
}
|
||||
+308
@@ -0,0 +1,308 @@
|
||||
use crate::{wbi, ApiError, Credentials, Error, Result, WbiKey};
|
||||
use reqwest::{
|
||||
header::{
|
||||
HeaderMap, HeaderValue, ACCEPT, ACCEPT_LANGUAGE, COOKIE, ORIGIN, REFERER, USER_AGENT,
|
||||
},
|
||||
Method,
|
||||
};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
const WEB_ORIGIN: &str = "https://www.bilibili.com";
|
||||
const LIVE_ORIGIN: &str = "https://live.bilibili.com";
|
||||
|
||||
/// Bilibili HTTP API 客户端。
|
||||
///
|
||||
/// 客户端可廉价克隆,克隆后共享连接池与已缓存的 WBI key。
|
||||
#[derive(Clone)]
|
||||
pub struct Client {
|
||||
pub(crate) http: reqwest::Client,
|
||||
pub(crate) credentials: Credentials,
|
||||
wbi_key: Arc<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 {
|
||||
/// 创建客户端构建器。
|
||||
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 {
|
||||
&self.credentials
|
||||
}
|
||||
/// 访问直播相关接口。
|
||||
pub fn live(&self) -> crate::LiveApi<'_> {
|
||||
crate::LiveApi::new(self)
|
||||
}
|
||||
/// 访问视频相关接口。
|
||||
pub fn video(&self) -> crate::VideoApi<'_> {
|
||||
crate::VideoApi::new(self)
|
||||
}
|
||||
/// 访问用户、空间和关系接口。
|
||||
pub fn user(&self) -> crate::UserApi<'_> {
|
||||
crate::UserApi::new(self)
|
||||
}
|
||||
/// 访问评论接口。
|
||||
pub fn comments(&self) -> crate::CommentApi<'_> {
|
||||
crate::CommentApi::new(self)
|
||||
}
|
||||
/// 访问搜索接口。
|
||||
pub fn search(&self) -> crate::SearchApi<'_> {
|
||||
crate::SearchApi::new(self)
|
||||
}
|
||||
|
||||
/// 调用任意公开 GET 端点并返回响应中的 `data`。
|
||||
pub async fn get<T: DeserializeOwned>(
|
||||
&self,
|
||||
url: &str,
|
||||
query: Vec<(String, String)>,
|
||||
) -> Result<T> {
|
||||
self.send(Method::GET, url, query, None, false).await
|
||||
}
|
||||
/// 调用需要 WBI 签名的 GET 端点。
|
||||
///
|
||||
/// 会在首次调用时获取并缓存当前 WBI key。
|
||||
pub async fn get_wbi<T: DeserializeOwned>(
|
||||
&self,
|
||||
url: &str,
|
||||
query: Vec<(String, String)>,
|
||||
) -> Result<T> {
|
||||
let key = self.wbi_key().await?;
|
||||
let signed = key.sign(query);
|
||||
self.send(
|
||||
Method::GET,
|
||||
&signed_url(url, &signed),
|
||||
Vec::new(),
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
}
|
||||
/// 发送需要登录态的表单 POST 请求。
|
||||
///
|
||||
/// 自动补充 `csrf` 与 `csrf_token`;缺少 `bili_jct` 时返回 [`Error::MissingCsrf`]。
|
||||
pub async fn post_form<T: DeserializeOwned>(
|
||||
&self,
|
||||
url: &str,
|
||||
mut form: Vec<(String, String)>,
|
||||
) -> Result<T> {
|
||||
let csrf = self.credentials.csrf().ok_or(Error::MissingCsrf)?;
|
||||
if !form.iter().any(|(k, _)| k == "csrf") {
|
||||
form.push(("csrf".into(), csrf.into()));
|
||||
}
|
||||
if !form.iter().any(|(k, _)| k == "csrf_token") {
|
||||
form.push(("csrf_token".into(), csrf.into()));
|
||||
}
|
||||
self.send(Method::POST, url, Vec::new(), Some(form), true)
|
||||
.await
|
||||
}
|
||||
/// 发送同时需要 Cookie/CSRF 和 WBI 查询签名的表单 POST 请求。
|
||||
pub async fn post_form_wbi<T: DeserializeOwned>(
|
||||
&self,
|
||||
url: &str,
|
||||
query: Vec<(String, String)>,
|
||||
form: Vec<(String, String)>,
|
||||
) -> Result<T> {
|
||||
let csrf = self.credentials.csrf().ok_or(Error::MissingCsrf)?;
|
||||
let mut form = form;
|
||||
if !form.iter().any(|(k, _)| k == "csrf") {
|
||||
form.push(("csrf".into(), csrf.into()));
|
||||
}
|
||||
if !form.iter().any(|(k, _)| k == "csrf_token") {
|
||||
form.push(("csrf_token".into(), csrf.into()));
|
||||
}
|
||||
let key = self.wbi_key().await?;
|
||||
let signed = key.sign(query);
|
||||
self.send(
|
||||
Method::POST,
|
||||
&signed_url(url, &signed),
|
||||
Vec::new(),
|
||||
Some(form),
|
||||
true,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn send<T: DeserializeOwned>(
|
||||
&self,
|
||||
method: Method,
|
||||
url: &str,
|
||||
query: Vec<(String, String)>,
|
||||
form: Option<Vec<(String, String)>>,
|
||||
live: bool,
|
||||
) -> Result<T> {
|
||||
let mut request = self.http.request(method, url).query(&query);
|
||||
if live {
|
||||
request = request
|
||||
.header(ORIGIN, LIVE_ORIGIN)
|
||||
.header(REFERER, "https://live.bilibili.com/");
|
||||
}
|
||||
if let Some(form) = form {
|
||||
request = request.form(&form);
|
||||
}
|
||||
let envelope: Response<Value> = request.send().await?.error_for_status()?.json().await?;
|
||||
envelope
|
||||
.into_data()
|
||||
.and_then(|data| Ok(serde_json::from_value(data)?))
|
||||
}
|
||||
|
||||
async fn wbi_key(&self) -> Result<WbiKey> {
|
||||
if let Some(key) = self.wbi_key.read().expect("WBI key lock poisoned").clone() {
|
||||
return Ok(key);
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct Nav {
|
||||
wbi_img: Option<WbiImage>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct WbiImage {
|
||||
img_url: String,
|
||||
sub_url: String,
|
||||
}
|
||||
let nav: Nav = self
|
||||
.get("https://api.bilibili.com/x/web-interface/nav", Vec::new())
|
||||
.await?;
|
||||
let image = nav.wbi_img.ok_or(Error::MissingWbiKey)?;
|
||||
let key = WbiKey::from_image_urls(&image.img_url, &image.sub_url)?;
|
||||
*self.wbi_key.write().expect("WBI key lock poisoned") = Some(key.clone());
|
||||
Ok(key)
|
||||
}
|
||||
}
|
||||
|
||||
fn signed_url(url: &str, params: &[(String, String)]) -> String {
|
||||
let separator = if url.contains('?') { "&" } else { "?" };
|
||||
format!(
|
||||
"{url}{separator}{}",
|
||||
wbi::encode(
|
||||
params
|
||||
.iter()
|
||||
.map(|(key, value)| (key.as_str(), value.as_str()))
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,628 @@
|
||||
//! 高层接口分组。
|
||||
//!
|
||||
//! 所有快捷方法均返回 `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,58 @@
|
||||
use thiserror::Error;
|
||||
|
||||
/// crate 内所有可恢复操作使用的结果类型。
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
/// Bilibili 返回了非零业务状态码时的错误信息。
|
||||
#[derive(Debug, Clone, Error, PartialEq, Eq)]
|
||||
#[error("Bilibili API returned code {code}: {message}")]
|
||||
pub struct ApiError {
|
||||
/// Bilibili 响应中的 `code`。
|
||||
pub code: i64,
|
||||
/// Bilibili 响应中的 `message` 或 `msg`。
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// 调用 API、签名或解析协议时可能发生的错误。
|
||||
#[derive(Debug, Error)]
|
||||
pub enum Error {
|
||||
/// HTTP 传输或状态码错误。
|
||||
#[error(transparent)]
|
||||
Http(#[from] reqwest::Error),
|
||||
/// URL 解析错误。
|
||||
#[error(transparent)]
|
||||
Url(#[from] url::ParseError),
|
||||
/// JSON 编码或解码错误。
|
||||
#[error(transparent)]
|
||||
Json(#[from] serde_json::Error),
|
||||
/// Bilibili 返回的业务错误。
|
||||
#[error(transparent)]
|
||||
Api(#[from] ApiError),
|
||||
/// 状态变更请求缺少 `bili_jct` CSRF token。
|
||||
#[error("this request requires credentials with a bili_jct CSRF token")]
|
||||
MissingCsrf,
|
||||
/// `/x/web-interface/nav` 未返回 WBI 图片 key。
|
||||
#[error("WBI key was absent from /x/web-interface/nav")]
|
||||
MissingWbiKey,
|
||||
/// WBI 图片 URL 不符合预期格式。
|
||||
#[error("invalid WBI image URL: {0}")]
|
||||
InvalidWbiImageUrl(String),
|
||||
/// 直播 WebSocket 数据包格式无效。
|
||||
#[error("invalid WebSocket packet: {0}")]
|
||||
InvalidPacket(String),
|
||||
/// WebSocket 建连或读写错误。
|
||||
#[cfg(feature = "websocket")]
|
||||
#[error(transparent)]
|
||||
WebSocket(Box<tokio_tungstenite::tungstenite::Error>),
|
||||
/// WebSocket 压缩数据解压时发生的 I/O 错误。
|
||||
#[cfg(feature = "websocket")]
|
||||
#[error(transparent)]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
#[cfg(feature = "websocket")]
|
||||
impl Error {
|
||||
pub(crate) fn websocket(error: tokio_tungstenite::tungstenite::Error) -> Self {
|
||||
Self::WebSocket(Box::new(error))
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
//! Bilibili Web 与直播接口的异步客户端。
|
||||
//!
|
||||
//! 本 crate 不实现密码、二维码、验证码或 OAuth 登录流程。请通过 Bilibili 的正常登录页面
|
||||
//! 获得会话,再将 `SESSDATA` 和 `bili_jct` 传给 [`Credentials`]。会改变账号状态的
|
||||
//! Cookie 请求会自动携带 `csrf` 和 `csrf_token`。
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use libilibili::Client;
|
||||
//!
|
||||
//! # async fn example() -> Result<(), libilibili::Error> {
|
||||
//! let client = Client::anonymous()?;
|
||||
//! let room = client.live().room_init(5050).await?;
|
||||
//! println!("{room:#}");
|
||||
//! # Ok(()) }
|
||||
//! ```
|
||||
|
||||
mod auth;
|
||||
mod client;
|
||||
mod endpoints;
|
||||
mod error;
|
||||
mod wbi;
|
||||
|
||||
#[cfg(feature = "websocket")]
|
||||
pub mod websocket;
|
||||
|
||||
pub use auth::{Credentials, CredentialsBuilder};
|
||||
pub use client::{Client, ClientBuilder, Response};
|
||||
pub use endpoints::{CommentApi, LiveApi, SearchApi, UserApi, VideoApi};
|
||||
pub use error::{ApiError, Error, Result};
|
||||
pub use wbi::{WbiKey, WBI_MIXIN_TABLE};
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
use crate::{Error, Result};
|
||||
use md5::{Digest, Md5};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
use url::Url;
|
||||
|
||||
/// 推导 Bilibili 短期 WBI mixin key 使用的公开置换表。
|
||||
///
|
||||
/// 导出该常量便于调用方离线复现签名。
|
||||
pub const WBI_MIXIN_TABLE: [usize; 64] = [
|
||||
46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49, 33, 9, 42, 19, 29,
|
||||
28, 14, 39, 12, 38, 41, 13, 37, 48, 7, 16, 24, 55, 40, 61, 26, 17, 0, 1, 60, 51, 30, 4, 22, 25,
|
||||
54, 21, 56, 59, 6, 63, 57, 62, 11, 36, 20, 34, 44, 52,
|
||||
];
|
||||
|
||||
/// 从 `/x/web-interface/nav` 的 `data.wbi_img` 推导出的 WBI mixin key。
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct WbiKey(String);
|
||||
|
||||
impl WbiKey {
|
||||
/// 根据两张 WBI 图片的 URL 推导 mixin key。
|
||||
pub fn from_image_urls(img_url: &str, sub_url: &str) -> Result<Self> {
|
||||
let source = format!("{}{}", image_stem(img_url)?, image_stem(sub_url)?);
|
||||
let mixed: String = WBI_MIXIN_TABLE
|
||||
.iter()
|
||||
.filter_map(|&index| source.chars().nth(index))
|
||||
.collect();
|
||||
Ok(Self(mixed.chars().take(32).collect()))
|
||||
}
|
||||
/// 以字符串形式返回 mixin key。
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// 为参数加入 `wts` 与 `w_rid` 签名。
|
||||
///
|
||||
/// 已存在的 `wts` 和 `w_rid` 会被替换;业务参数按键名排序,且会移除
|
||||
/// WBI 规则中不参与签名的字符。
|
||||
pub fn sign(
|
||||
&self,
|
||||
parameters: impl IntoIterator<Item = (String, String)>,
|
||||
) -> Vec<(String, String)> {
|
||||
let mut params: BTreeMap<String, String> = parameters
|
||||
.into_iter()
|
||||
.filter(|(key, _)| key != "wts" && key != "w_rid")
|
||||
.map(|(key, value)| (key, clean_value(&value)))
|
||||
.collect();
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
.to_string();
|
||||
params.insert("wts".to_owned(), now);
|
||||
let query = encode(
|
||||
params
|
||||
.iter()
|
||||
.map(|(key, value)| (key.as_str(), value.as_str())),
|
||||
);
|
||||
let mut hasher = Md5::new();
|
||||
hasher.update(query.as_bytes());
|
||||
hasher.update(self.0.as_bytes());
|
||||
params.insert("w_rid".to_owned(), format!("{:x}", hasher.finalize()));
|
||||
params.into_iter().collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn image_stem(image_url: &str) -> Result<String> {
|
||||
let url = Url::parse(image_url).map_err(|_| Error::InvalidWbiImageUrl(image_url.to_owned()))?;
|
||||
let filename = url
|
||||
.path_segments()
|
||||
.and_then(Iterator::last)
|
||||
.and_then(|name| name.split('.').next())
|
||||
.filter(|name| !name.is_empty())
|
||||
.ok_or_else(|| Error::InvalidWbiImageUrl(image_url.to_owned()))?;
|
||||
Ok(filename.to_owned())
|
||||
}
|
||||
|
||||
fn clean_value(value: &str) -> String {
|
||||
value.chars().filter(|ch| !"!'()*".contains(*ch)).collect()
|
||||
}
|
||||
|
||||
pub(crate) fn encode<'a>(params: impl IntoIterator<Item = (&'a str, &'a str)>) -> String {
|
||||
params
|
||||
.into_iter()
|
||||
.map(|(key, value)| format!("{}={}", percent_encode(key), percent_encode(value)))
|
||||
.collect::<Vec<_>>()
|
||||
.join("&")
|
||||
}
|
||||
|
||||
fn percent_encode(value: &str) -> String {
|
||||
value
|
||||
.bytes()
|
||||
.flat_map(|byte| match byte {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||
vec![byte as char]
|
||||
}
|
||||
_ => format!("%{byte:02X}").chars().collect(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn derives_the_published_mixin_key() {
|
||||
let key = WbiKey::from_image_urls(
|
||||
"https://i0.hdslb.com/bfs/wbi/7cd084941338484aae1ad9425b84077c.png",
|
||||
"https://i0.hdslb.com/bfs/wbi/4932caff0ff746eab6f01bf08b70ac45.png",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(key.as_str(), "ea1db124af3c7062474693fa704f4ff8");
|
||||
}
|
||||
#[test]
|
||||
fn signing_sorts_and_cleans_values() {
|
||||
let key = WbiKey("01234567890123456789012345678901".into());
|
||||
let signed = key.sign([
|
||||
(String::from("z"), String::from("a!b")),
|
||||
(String::from("a"), String::from("x")),
|
||||
]);
|
||||
assert_eq!(signed[0], ("a".into(), "x".into()));
|
||||
assert_eq!(
|
||||
signed.iter().find(|(key, _)| key == "z"),
|
||||
Some(&("z".into(), "ab".into()))
|
||||
);
|
||||
assert!(signed.iter().any(|(k, _)| k == "w_rid"));
|
||||
}
|
||||
}
|
||||
+1935
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user