initial commit

This commit is contained in:
2026-07-18 12:44:28 -07:00
commit 18ae300d3f
15 changed files with 5902 additions and 0 deletions
+86
View File
@@ -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
}
}
+85
View File
@@ -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>"))
}
},
}
}
+189
View File
@@ -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
View File
@@ -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()))
)
)
}
+628
View File
@@ -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
}
}
+58
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff