feat: 后端支持获取指定用户的投稿

This commit is contained in:
lanyeeee
2025-07-30 05:41:40 +08:00
parent c97fe48012
commit c1999aac82
10 changed files with 321 additions and 10 deletions
+11
View File
@@ -273,6 +273,7 @@ dependencies = [
"chrono",
"float-ord",
"fs4",
"md-5",
"memchr",
"notify",
"num_enum",
@@ -2169,6 +2170,16 @@ version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5"
[[package]]
name = "md-5"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
dependencies = [
"cfg-if",
"digest",
]
[[package]]
name = "memchr"
version = "2.7.5"
+1
View File
@@ -49,6 +49,7 @@ prost = { version = "0.14.1" }
yaserde = { version = "0.12.0", features = ["yaserde_derive"] }
float-ord = { version = "0.3.2" }
memchr = { version = "2.7.5" }
md-5 = { version = "0.10.6" }
[profile.release]
strip = true
+52 -8
View File
@@ -23,9 +23,10 @@ use crate::{
cheese_media_url::CheeseMediaUrl, fav_folders::FavFolders, fav_info::FavInfo,
get_bangumi_info_params::GetBangumiInfoParams, get_cheese_info_params::GetCheeseInfoParams,
get_fav_info_params::GetFavInfoParams, get_normal_info_params::GetNormalInfoParams,
normal_info::NormalInfo, normal_media_url::NormalMediaUrl, player_info::PlayerInfo,
qrcode_data::QrcodeData, qrcode_status::QrcodeStatus, subtitle::Subtitle, tags::Tags,
user_info::UserInfo, watch_later_info::WatchLaterInfo,
get_user_video_info_params::GetUserVideoInfoParams, normal_info::NormalInfo,
normal_media_url::NormalMediaUrl, player_info::PlayerInfo, qrcode_data::QrcodeData,
qrcode_status::QrcodeStatus, subtitle::Subtitle, tags::Tags, user_info::UserInfo,
user_video_info::UserVideoInfo, watch_later_info::WatchLaterInfo,
},
};
@@ -33,10 +34,10 @@ const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/
const REFERRER: &str = "https://www.bilibili.com/";
pub struct BiliClient {
app: AppHandle,
api_client: RwLock<ClientWithMiddleware>,
media_client: RwLock<ClientWithMiddleware>,
content_length_client: RwLock<Client>,
pub app: AppHandle,
pub api_client: RwLock<ClientWithMiddleware>,
pub media_client: RwLock<ClientWithMiddleware>,
pub content_length_client: RwLock<Client>,
}
impl BiliClient {
@@ -281,6 +282,49 @@ impl BiliClient {
Ok(cheese_info)
}
pub async fn get_user_video_info(
&self,
params: GetUserVideoInfoParams,
) -> anyhow::Result<UserVideoInfo> {
let mut params: Vec<(&str, String)> = vec![
("pn", params.pn.to_string()),
("ps", "42".to_string()),
("mid", params.mid.to_string()),
];
self.wbi(&mut params).await?;
let request = self
.api_client
.read()
.get("https://api.bilibili.com/x/space/wbi/arc/search")
.query(&params)
.header("cookie", self.get_cookie());
let http_resp = request.send().await?;
// 检查http响应状态码
let status = http_resp.status();
let body = http_resp.text().await?;
if status != StatusCode::OK {
return Err(anyhow!("预料之外的状态码({status}): {body}"));
}
// 尝试将body解析为BiliResp
let bili_resp: BiliResp =
serde_json::from_str(&body).context(format!("将body解析为BiliResp失败: {body}"))?;
// 检查BiliResp的code字段
if bili_resp.code != 0 {
return Err(anyhow!("预料之外的code: {bili_resp:?}"));
}
// 检查BiliResp的data是否存在
let Some(data) = bili_resp.data else {
return Err(anyhow!("BiliResp中不存在data字段: {bili_resp:?}"));
};
// 尝试将data解析为UserVideoInfo
let data_str = data.to_string();
let user_video_info: UserVideoInfo = serde_json::from_str(&data_str)
.context(format!("将data解析为UserVideoInfo失败: {data_str}"))?;
Ok(user_video_info)
}
pub async fn get_normal_url(&self, bvid: &str, cid: i64) -> anyhow::Result<NormalMediaUrl> {
let params = json!({
"bvid": bvid,
@@ -750,7 +794,7 @@ impl BiliClient {
Ok(tags)
}
fn get_cookie(&self) -> String {
pub fn get_cookie(&self) -> String {
let sessdata = self.app.get_config().read().sessdata.clone();
format!("SESSDATA={sessdata}")
}
+18 -2
View File
@@ -11,9 +11,11 @@ use crate::{
cheese_media_url::CheeseMediaUrl, create_download_task_params::CreateDownloadTaskParams,
fav_folders::FavFolders, fav_info::FavInfo, get_bangumi_info_params::GetBangumiInfoParams,
get_cheese_info_params::GetCheeseInfoParams, get_fav_info_params::GetFavInfoParams,
get_normal_info_params::GetNormalInfoParams, normal_info::NormalInfo,
get_normal_info_params::GetNormalInfoParams,
get_user_video_info_params::GetUserVideoInfoParams, normal_info::NormalInfo,
normal_media_url::NormalMediaUrl, player_info::PlayerInfo, qrcode_data::QrcodeData,
qrcode_status::QrcodeStatus, user_info::UserInfo, watch_later_info::WatchLaterInfo,
qrcode_status::QrcodeStatus, user_info::UserInfo, user_video_info::UserVideoInfo,
watch_later_info::WatchLaterInfo,
},
};
@@ -142,6 +144,20 @@ pub async fn get_cheese_info(
Ok(cheese_info)
}
#[tauri::command(async)]
#[specta::specta]
pub async fn get_user_video_info(
app: AppHandle,
params: GetUserVideoInfoParams,
) -> CommandResult<UserVideoInfo> {
let bili_client = app.get_bili_client();
let user_video_info = bili_client
.get_user_video_info(params)
.await
.map_err(|err| CommandError::from("获取用户视频信息失败", err))?;
Ok(user_video_info)
}
#[tauri::command(async)]
#[specta::specta]
pub async fn get_normal_url(
+2
View File
@@ -9,6 +9,7 @@ mod extensions;
mod logger;
mod types;
mod utils;
mod wbi;
mod protobuf {
include!("./bilibili.community.service.dm.v1.rs");
}
@@ -42,6 +43,7 @@ pub fn run() {
get_normal_info,
get_bangumi_info,
get_cheese_info,
get_user_video_info,
get_normal_url,
get_bangumi_url,
get_cheese_url,
@@ -0,0 +1,8 @@
use serde::{Deserialize, Serialize};
use specta::Type;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
pub struct GetUserVideoInfoParams {
pub pn: i64,
pub mid: i64,
}
+2
View File
@@ -11,6 +11,7 @@ pub mod get_bangumi_info_params;
pub mod get_cheese_info_params;
pub mod get_fav_info_params;
pub mod get_normal_info_params;
pub mod get_user_video_info_params;
pub mod log_level;
pub mod normal_info;
pub mod normal_media_url;
@@ -20,5 +21,6 @@ pub mod qrcode_status;
pub mod subtitle;
pub mod tags;
pub mod user_info;
pub mod user_video_info;
pub mod video_quality;
pub mod watch_later_info;
+92
View File
@@ -0,0 +1,92 @@
use serde::{Deserialize, Serialize};
use specta::Type;
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
pub struct UserVideoInfo {
pub list: UserVideoList,
pub page: PageInUserVideo,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
pub struct UserVideoList {
pub vlist: Vec<EpInUserVideo>,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
pub struct EpInUserVideo {
pub comment: i64,
pub typeid: i64,
pub play: i64,
pub pic: String,
pub subtitle: String,
pub description: String,
pub copyright: String,
pub title: String,
pub review: i64,
pub author: String,
pub mid: i64,
pub created: i64,
pub length: String,
pub video_review: i64,
pub aid: i64,
pub bvid: String,
pub hide_click: bool,
pub is_pay: i64,
pub is_union_video: i64,
pub is_steins_gate: i64,
pub is_live_playback: i64,
pub is_lesson_video: i64,
pub is_lesson_finished: i64,
pub lesson_update_info: String,
pub jump_url: String,
pub meta: Option<MetaInUserVideo>,
pub is_avoided: i64,
pub season_id: i64,
pub attribute: i64,
pub is_charging_arc: bool,
pub elec_arc_type: i64,
pub elec_arc_badge: String,
pub vt: i64,
pub enable_vt: i64,
pub vt_display: String,
pub playback_position: i64,
pub is_self_view: bool,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
pub struct MetaInUserVideo {
pub id: i64,
pub title: String,
pub cover: String,
pub mid: i64,
pub intro: String,
pub sign_state: i64,
pub attribute: i64,
pub stat: StatInUserVideo,
pub ep_count: i64,
pub first_aid: i64,
pub ptime: i64,
pub ep_num: i64,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
pub struct StatInUserVideo {
pub season_id: i64,
pub view: i64,
pub danmaku: i64,
pub reply: i64,
pub favorite: i64,
pub coin: i64,
pub share: i64,
pub like: i64,
pub mtime: i64,
pub vt: i64,
pub vv: i64,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
pub struct PageInUserVideo {
pub pn: i64,
pub ps: i64,
pub count: i64,
}
+120
View File
@@ -0,0 +1,120 @@
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{anyhow, Context};
use md5::{Digest, Md5};
use serde::Deserialize;
use crate::bili_client::{BiliClient, BiliResp};
const MIXIN_KEY_ENC_TAB: [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,
];
#[derive(Deserialize)]
struct WbiImgRespData {
img_url: String,
sub_url: String,
}
#[derive(Deserialize)]
struct WeiRespData {
wbi_img: WbiImgRespData,
}
impl BiliClient {
// 为请求参数进行 wbi 签名
pub(crate) async fn wbi(&self, params: &mut Vec<(&str, String)>) -> anyhow::Result<()> {
let (img_key, sub_key) = self.get_wbi_keys().await.context("获取wbi keys失败")?;
let mixin_key = get_mixin_key((img_key + &sub_key).as_bytes());
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
// 添加当前时间戳
params.push(("wts", timestamp.to_string()));
// 重新排序
params.sort_by(|a, b| a.0.cmp(b.0));
// 拼接参数
let query = params
.iter()
.map(|(k, v)| format!("{}={}", get_url_encoded(k), get_url_encoded(v)))
.collect::<Vec<_>>()
.join("&");
// 计算签名
let web_sign = format!("{:x}", Md5::digest(query.clone() + &mixin_key));
params.push(("w_rid", web_sign));
Ok(())
}
async fn get_wbi_keys(&self) -> anyhow::Result<(String, String)> {
let request = self
.api_client
.read()
.get("https://api.bilibili.com/x/web-interface/nav")
.header("Cookie", &self.get_cookie());
let http_resp = request.send().await?;
// 检查http响应状态码
let status = http_resp.status();
let body = http_resp.text().await?;
if status != reqwest::StatusCode::OK {
return Err(anyhow!("预料之外的状态码({status}): {body}"));
}
// 尝试将body解析为BiliResp
let bili_resp: BiliResp =
serde_json::from_str(&body).context(format!("将body解析为BiliResp失败: {body}"))?;
// 检查BiliResp的data是否存在
let Some(data) = bili_resp.data else {
return Err(anyhow!("BiliResp中不存在data字段: {bili_resp:?}"));
};
// 尝试将data解析为Data
let data_str = data.to_string();
let wei_resp_data: WeiRespData =
serde_json::from_str(&data_str).context(format!("将data解析为Data失败: {data_str}"))?;
let img_url = wei_resp_data.wbi_img.img_url;
let sub_url = wei_resp_data.wbi_img.sub_url;
let img_filename =
take_filename(&img_url).context(format!("从img_url中提取文件名失败: {img_url}"))?;
let sub_filename =
take_filename(&sub_url).context(format!("从sub_url中提取文件名失败: {sub_url}"))?;
Ok((img_filename, sub_filename))
}
}
// 对 imgKey 和 subKey 进行字符顺序打乱编码
fn get_mixin_key(orig: &[u8]) -> String {
MIXIN_KEY_ENC_TAB
.iter()
.take(32)
.map(|&i| orig[i] as char)
.collect::<String>()
}
fn get_url_encoded(s: &str) -> String {
s.chars()
.filter_map(|c| {
if c.is_ascii_alphanumeric() || "-_.~".contains(c) {
Some(c.to_string())
} else {
// 过滤 value 中的 "!'()*" 字符
if "!'()*".contains(c) {
return None;
}
let encoded = c
.encode_utf8(&mut [0; 4])
.bytes()
.fold(String::new(), |acc, b| acc + &format!("%{b:02X}"));
Some(encoded)
}
})
.collect::<String>()
}
fn take_filename(url: &str) -> Option<String> {
url.rsplit_once('/')
.and_then(|(_, s)| s.rsplit_once('.'))
.map(|(s, _)| s.to_string())
}