Skip to main content

bpi_rs/video/
recommend.rs

1//! 视频推荐相关接口
2//!
3//! [查看 API 文档](https://github.com/SocialSisterYi/bilibili-API-collect/tree/master/docs/video)
4use serde::{Deserialize, Serialize};
5
6pub(crate) const HOMEPAGE_RECOMMENDATIONS_ENDPOINT: &str =
7    "https://api.bilibili.com/x/web-interface/wbi/index/top/feed/rcmd";
8pub(crate) const RELATED_VIDEOS_ENDPOINT: &str =
9    "https://api.bilibili.com/x/web-interface/archive/related";
10
11// --- 视频推荐相关数据结构体 ---
12
13/// 视频作者信息
14#[derive(Debug, Clone, Deserialize, Serialize)]
15pub struct Owner {
16    /// UP主mid
17    pub mid: u64,
18    /// UP昵称
19    pub name: String,
20    /// 头像URL
21    pub face: String,
22}
23
24/// 视频统计数据
25#[derive(Debug, Clone, Deserialize, Serialize)]
26pub struct Stat {
27    /// 播放量
28    pub view: u64,
29    /// 视频aid
30    pub aid: u64,
31    /// 弹幕数
32    pub danmaku: u64,
33    /// 评论数
34    pub reply: u64,
35    /// 收藏数
36    pub favorite: u64,
37    /// 硬币数
38    pub coin: u64,
39    /// 分享数
40    pub share: u64,
41    /// 当前排名
42    pub now_rank: u64,
43    /// 历史最高排名
44    pub his_rank: u64,
45    /// 点赞数
46    pub like: u64,
47    /// 点踩数
48    pub dislike: u64,
49}
50
51/// 主页推荐视频/直播统计数据
52#[derive(Debug, Clone, Deserialize, Serialize)]
53pub struct HomeRmdStat {
54    /// 播放量
55    pub view: u64,
56    /// 弹幕数
57    pub danmaku: u64,
58    /// 点赞数
59    pub like: u64,
60}
61
62/// 视频版权信息
63#[derive(Debug, Clone, Deserialize, Serialize)]
64pub struct Rights {
65    pub bp: u8,
66    pub elec: u8,
67    pub download: u8,
68    pub movie: u8,
69    pub pay: u8,
70    pub hd5: u8,
71    pub no_reprint: u8,
72    pub autoplay: u8,
73    pub ugc_pay: u8,
74    pub is_cooperation: u8,
75    pub ugc_pay_preview: u8,
76    pub no_background: u8,
77}
78
79/// 视频分辨率信息
80#[derive(Debug, Clone, Deserialize, Serialize)]
81pub struct Dimension {
82    pub width: u32,
83    pub height: u32,
84    pub rotate: u8,
85}
86
87/// 单视频推荐列表项
88#[derive(Debug, Clone, Deserialize, Serialize)]
89pub struct RelatedVideo {
90    pub aid: u64,
91    pub videos: u32,
92    pub tid: u32,
93    pub tname: String,
94    pub copyright: u8,
95    pub pic: String,
96    pub title: String,
97    pub pubdate: u64,
98    pub ctime: u64,
99    pub desc: String,
100    pub state: i8,
101    pub duration: u64,
102    pub rights: Rights,
103    pub owner: Owner,
104    pub stat: Stat,
105    pub dynamic: String,
106    pub cid: u64,
107    pub dimension: Dimension,
108    pub bvid: String,
109    #[serde(default)]
110    pub short_link_v2: String,
111}
112
113/// 首页推荐视频列表项中的推荐理由
114#[derive(Debug, Clone, Deserialize, Serialize)]
115pub struct RcmdReason {
116    /// 原因类型, 0: 无, 1: 已关注, 3: 高点赞量
117    #[serde(rename = "reason_type")]
118    pub reason_type: u8,
119    /// 原因描述
120    pub content: Option<String>,
121}
122
123/// 首页推荐视频列表项
124#[derive(Debug, Clone, Deserialize, Serialize)]
125pub struct RcmdItem {
126    pub av_feature: Option<serde_json::Value>,
127    /// 商业推广信息,若无则为 null
128    pub business_info: Option<serde_json::Value>,
129    /// 视频bvid
130    pub bvid: String,
131    /// 稿件cid
132    pub cid: u64,
133    /// 视频时长
134    pub duration: u64,
135    /// 目标类型, "av": 视频, "ogv": 边栏, "live": 直播
136    pub goto: String,
137    /// 视频aid / 直播间id
138    pub id: u64,
139    /// 是否已关注, 0: 未关注, 1: 已关注
140    pub is_followed: u8,
141    pub is_stock: u8,
142    /// UP主信息
143    pub owner: Owner,
144    /// 封面
145    pub pic: String,
146    pub pos: u8,
147    /// 发布时间
148    pub pubdate: u64,
149    /// 推荐理由
150    pub rcmd_reason: Option<RcmdReason>,
151    /// 直播间信息
152    pub room_info: Option<serde_json::Value>,
153    pub show_info: u8,
154    /// 视频状态信息
155    pub stat: Option<HomeRmdStat>,
156    /// 标题
157    pub title: String,
158    pub track_id: String,
159    /// 目标页 URI
160    pub uri: String,
161}
162
163/// 首页推荐列表响应数据
164#[derive(Debug, Clone, Deserialize, Serialize)]
165pub struct RcmdFeedResponseData {
166    /// 推荐列表
167    pub item: Vec<RcmdItem>,
168    /// 用户mid,未登录为0
169    pub mid: u64,
170    pub preload_expose_pct: f32,
171    pub preload_floor_expose_pct: f32,
172}
173
174// --- 测试模块 ---
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use crate::ids::Aid;
180    use crate::probe::contract::HttpMethod;
181    use crate::probe::endpoint_contract::EndpointContract;
182    use crate::video::params::{VideoHomepageRecommendationsParams, VideoRelatedParams};
183    use crate::{ApiEnvelope, BpiClient, BpiError, BpiResult};
184    use tracing::info;
185
186    const TEST_AID: u64 = 10001;
187
188    #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
189    #[tokio::test]
190    async fn test_video_related_videos_by_aid() -> Result<(), BpiError> {
191        let bpi = BpiClient::new().expect("client should build");
192        let data = bpi
193            .video()
194            .related_videos(VideoRelatedParams::from_aid(Aid::new(TEST_AID)?))
195            .await?;
196
197        info!("单视频推荐列表: {:?}", data);
198
199        assert!(!data.is_empty());
200        assert!(data.len() <= 40);
201
202        Ok(())
203    }
204
205    #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
206    #[tokio::test]
207    async fn test_video_homepage_recommendations() -> Result<(), BpiError> {
208        let bpi = BpiClient::new().expect("client should build");
209        let params = VideoHomepageRecommendationsParams::new()
210            .page_size(12)?
211            .fresh_idx(1)?
212            .fetch_row(1)?;
213        let data = bpi.video().homepage_recommendations(params).await?;
214
215        info!("首页推荐列表: {:?}", data);
216
217        assert!(!data.item.is_empty());
218        assert!(data.item.len() <= 30);
219
220        Ok(())
221    }
222
223    fn contract(endpoint: &str) -> BpiResult<EndpointContract> {
224        let bytes = match endpoint {
225            "related-videos" => include_bytes!(
226                "../../tests/contracts/video/player-read/related-videos/contract.json"
227            )
228            .as_slice(),
229            "homepage-recommendations" => include_bytes!(
230                "../../tests/contracts/video/player-read/homepage-recommendations/contract.json"
231            )
232            .as_slice(),
233            _ => unreachable!("unknown video recommend contract"),
234        };
235
236        EndpointContract::from_slice(bytes)
237    }
238
239    #[test]
240    fn video_related_videos_contract_matches_endpoint_request() -> BpiResult<()> {
241        let contract = contract("related-videos")?;
242        let params = VideoRelatedParams::from_bvid("BV1xx411c7mD".parse()?);
243
244        assert_eq!(contract.name, "video.related_videos");
245        assert_eq!(contract.request.method, HttpMethod::Get);
246        assert_eq!(contract.request.url.as_str(), RELATED_VIDEOS_ENDPOINT);
247        assert_eq!(
248            contract.request.query.get("bvid").map(String::as_str),
249            Some("BV1xx411c7mD")
250        );
251        assert_eq!(
252            params.query_pairs(),
253            vec![("bvid", "BV1xx411c7mD".to_string())]
254        );
255        assert_eq!(contract.cases.len(), 3);
256        Ok(())
257    }
258
259    #[test]
260    fn video_related_videos_response_fixture_parses_declared_model() -> BpiResult<()> {
261        let payload = ApiEnvelope::<Vec<RelatedVideo>>::from_slice(include_bytes!(
262            "../../tests/contracts/video/player-read/related-videos/responses/success.json"
263        ))?
264        .into_payload()?;
265
266        assert_eq!(payload.len(), 1);
267        Ok(())
268    }
269
270    #[test]
271    fn video_homepage_recommendations_contract_matches_endpoint_request() -> BpiResult<()> {
272        let contract = contract("homepage-recommendations")?;
273        let params = VideoHomepageRecommendationsParams::new();
274
275        assert_eq!(contract.name, "video.homepage_recommendations");
276        assert_eq!(contract.request.method, HttpMethod::Get);
277        assert_eq!(
278            contract.request.url.as_str(),
279            HOMEPAGE_RECOMMENDATIONS_ENDPOINT
280        );
281        assert!(contract.request.auth.requires_wbi());
282        assert_eq!(
283            contract.request.query.get("ps").map(String::as_str),
284            Some("12")
285        );
286        assert_eq!(params.query_pairs().len(), 6);
287        assert_eq!(contract.cases.len(), 3);
288        Ok(())
289    }
290
291    #[test]
292    fn video_homepage_recommendations_response_fixture_parses_declared_model() -> BpiResult<()> {
293        let payload = ApiEnvelope::<RcmdFeedResponseData>::from_slice(include_bytes!(
294            "../../tests/contracts/video/player-read/homepage-recommendations/responses/success.json"
295        ))?
296        .into_payload()?;
297
298        assert_eq!(payload.item.len(), 1);
299        Ok(())
300    }
301
302    fn local_probe_body(endpoint: &str, profile: &str) -> Option<serde_json::Value> {
303        let path =
304            format!("target/bpi-probe-runs/video/player-read/{endpoint}/{profile}.response.json");
305        let bytes = std::fs::read(path).ok()?;
306        let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
307        value
308            .get("response")
309            .and_then(|response| response.get("body"))
310            .cloned()
311    }
312
313    #[test]
314    fn video_recommend_models_match_local_probe_outputs_when_available() -> BpiResult<()> {
315        for profile in ["anonymous", "normal", "vip"] {
316            if let Some(body) = local_probe_body("related-videos", profile) {
317                let payload = serde_json::from_value::<ApiEnvelope<Vec<RelatedVideo>>>(body)?
318                    .into_payload()?;
319
320                assert!(!payload.is_empty());
321            }
322
323            if let Some(body) = local_probe_body("homepage-recommendations", profile) {
324                let payload = serde_json::from_value::<ApiEnvelope<RcmdFeedResponseData>>(body)?
325                    .into_payload()?;
326
327                assert!(!payload.item.is_empty());
328            }
329        }
330        Ok(())
331    }
332}