mirror of
https://github.com/amtoaer/bili-sync.git
synced 2026-09-05 15:39:07 +08:00
feat: 支持使用动态 api 获取投稿,该 api 会返回动态视频 (#485)
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use async_stream::try_stream;
|
||||
use chrono::serde::ts_seconds;
|
||||
use chrono::{DateTime, Utc};
|
||||
use futures::Stream;
|
||||
use reqwest::Method;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::bilibili::credential::encoded_query;
|
||||
use crate::bilibili::{BiliClient, MIXIN_KEY, Validate, VideoInfo};
|
||||
|
||||
pub struct Dynamic<'a> {
|
||||
client: &'a BiliClient,
|
||||
pub upper_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct DynamicItemPublished {
|
||||
#[serde(with = "ts_seconds")]
|
||||
pub_ts: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl<'a> Dynamic<'a> {
|
||||
pub fn new(client: &'a BiliClient, upper_id: String) -> Self {
|
||||
Self { client, upper_id }
|
||||
}
|
||||
|
||||
pub async fn get_dynamics(&self, offset: Option<String>) -> Result<Value> {
|
||||
self.client
|
||||
.request(
|
||||
Method::GET,
|
||||
"https://api.bilibili.com/x/polymer/web-dynamic/v1/feed/all",
|
||||
)
|
||||
.await
|
||||
.query(&encoded_query(
|
||||
vec![
|
||||
("host_mid", self.upper_id.as_str()),
|
||||
("offset", offset.as_deref().unwrap_or("")),
|
||||
],
|
||||
MIXIN_KEY.load().as_deref(),
|
||||
))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json::<serde_json::Value>()
|
||||
.await?
|
||||
.validate()
|
||||
}
|
||||
|
||||
pub fn into_video_stream(self) -> impl Stream<Item = Result<VideoInfo>> + 'a {
|
||||
try_stream! {
|
||||
let mut offset = None;
|
||||
loop {
|
||||
let mut res = self
|
||||
.get_dynamics(offset.take())
|
||||
.await
|
||||
.with_context(|| "failed to get dynamics")?;
|
||||
let items = res["data"]["items"].as_array_mut().context("items not exist")?;
|
||||
for item in items.iter_mut() {
|
||||
if item["type"].as_str().is_none_or(|t| t != "DYNAMIC_TYPE_AV") {
|
||||
continue;
|
||||
}
|
||||
let published: DynamicItemPublished = serde_json::from_value(item["modules"]["module_author"].take())
|
||||
.with_context(|| "failed to parse published time")?;
|
||||
let mut video_info: VideoInfo =
|
||||
serde_json::from_value(item["modules"]["module_dynamic"]["major"]["archive"].take())?;
|
||||
// 这些地方不使用 let else 是因为 try_stream! 宏不支持
|
||||
if let VideoInfo::Dynamic { ref mut pubtime, .. } = video_info {
|
||||
*pubtime = published.pub_ts;
|
||||
yield video_info;
|
||||
} else {
|
||||
Err(anyhow!("video info is not dynamic"))?;
|
||||
}
|
||||
}
|
||||
if let (Some(has_more), Some(new_offset)) =
|
||||
(res["data"]["has_more"].as_bool(), res["data"]["offset"].as_str())
|
||||
{
|
||||
if !has_more {
|
||||
break;
|
||||
}
|
||||
offset = Some(new_offset.to_string());
|
||||
} else {
|
||||
Err(anyhow!("no has_more or offset found"))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ pub use client::{BiliClient, Client};
|
||||
pub use collection::{Collection, CollectionItem, CollectionType};
|
||||
pub use credential::Credential;
|
||||
pub use danmaku::DanmakuOption;
|
||||
pub use dynamic::Dynamic;
|
||||
pub use error::BiliError;
|
||||
pub use favorite_list::FavoriteList;
|
||||
use favorite_list::Upper;
|
||||
@@ -23,6 +24,7 @@ mod client;
|
||||
mod collection;
|
||||
mod credential;
|
||||
mod danmaku;
|
||||
mod dynamic;
|
||||
mod error;
|
||||
mod favorite_list;
|
||||
mod me;
|
||||
@@ -135,18 +137,32 @@ pub enum VideoInfo {
|
||||
#[serde(rename = "created", with = "ts_seconds")]
|
||||
ctime: DateTime<Utc>,
|
||||
},
|
||||
// 从动态获取的视频信息(此处 pubtime 未在结构中,因此使用 default + 手动赋值)
|
||||
Dynamic {
|
||||
title: String,
|
||||
bvid: String,
|
||||
desc: String,
|
||||
cover: String,
|
||||
#[serde(default)]
|
||||
pubtime: DateTime<Utc>,
|
||||
},
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::Path;
|
||||
|
||||
use futures::StreamExt;
|
||||
|
||||
use super::*;
|
||||
use crate::config::VersionedConfig;
|
||||
use crate::database::setup_database;
|
||||
use crate::utils::init_logger;
|
||||
|
||||
#[ignore = "only for manual test"]
|
||||
#[tokio::test]
|
||||
async fn test_video_info_type() {
|
||||
async fn test_video_info_type() -> Result<()> {
|
||||
VersionedConfig::init_for_test(&setup_database(Path::new("./test.sqlite")).await?).await?;
|
||||
init_logger("None,bili_sync=debug", None);
|
||||
let bili_client = BiliClient::new();
|
||||
// 请求 UP 主视频必须要获取 mixin key,使用 key 计算请求参数的签名,否则直接提示权限不足返回空
|
||||
@@ -200,6 +216,17 @@ mod tests {
|
||||
.await;
|
||||
assert!(videos.iter().all(|v| matches!(v, VideoInfo::Submission { .. })));
|
||||
assert!(videos.iter().rev().is_sorted_by_key(|v| v.release_datetime()));
|
||||
// 测试动态
|
||||
let dynamic = Dynamic::new(&bili_client, "659898".to_string());
|
||||
let videos = dynamic
|
||||
.into_video_stream()
|
||||
.take(20)
|
||||
.filter_map(|v| futures::future::ready(v.ok()))
|
||||
.collect::<Vec<_>>()
|
||||
.await;
|
||||
assert!(videos.iter().all(|v| matches!(v, VideoInfo::Dynamic { .. })));
|
||||
assert!(videos.iter().skip(1).rev().is_sorted_by_key(|v| v.release_datetime()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[ignore = "only for manual test"]
|
||||
|
||||
@@ -6,12 +6,18 @@ use serde_json::Value;
|
||||
|
||||
use crate::bilibili::credential::encoded_query;
|
||||
use crate::bilibili::favorite_list::Upper;
|
||||
use crate::bilibili::{BiliClient, MIXIN_KEY, Validate, VideoInfo};
|
||||
use crate::bilibili::{BiliClient, Dynamic, MIXIN_KEY, Validate, VideoInfo};
|
||||
pub struct Submission<'a> {
|
||||
client: &'a BiliClient,
|
||||
pub upper_id: String,
|
||||
}
|
||||
|
||||
impl<'a> From<Submission<'a>> for Dynamic<'a> {
|
||||
fn from(submission: Submission<'a>) -> Self {
|
||||
Dynamic::new(submission.client, submission.upper_id)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Submission<'a> {
|
||||
pub fn new(client: &'a BiliClient, upper_id: String) -> Self {
|
||||
Self { client, upper_id }
|
||||
|
||||
Reference in New Issue
Block a user