Skip to main content

bpi_rs/live/
recommend.rs

1use serde::{Deserialize, Serialize};
2
3// ================= 数据结构 =================
4
5#[derive(Debug, Serialize, Clone, Deserialize)]
6pub struct WatchedShow {
7    /// 开关
8    pub switch: bool,
9    /// 看过人数
10    pub num: i32,
11    /// 小文本
12    pub text_small: String,
13    /// 大文本
14    pub text_large: String,
15    /// 图标URL
16    pub icon: String,
17    /// 图标位置
18    pub icon_location: i32,
19    /// Web端图标URL
20    pub icon_web: String,
21}
22
23#[derive(Debug, Serialize, Clone, Deserialize)]
24pub struct RecommendRoom {
25    /// 头像框
26    pub head_box: Option<serde_json::Value>,
27    /// 分区ID
28    pub area_v2_id: i32,
29    /// 父分区ID
30    pub area_v2_parent_id: i32,
31    /// 分区名称
32    pub area_v2_name: String,
33    /// 父分区名称
34    pub area_v2_parent_name: String,
35    /// 广播类型
36    pub broadcast_type: i32,
37    /// 封面URL
38    pub cover: String,
39    /// 直播间链接
40    pub link: String,
41    /// 观看人数
42    pub online: i32,
43    /// 挂件信息
44    #[serde(rename = "pendant_Info")]
45    pub pendant_info: serde_json::Value,
46    /// 直播间ID
47    pub roomid: i64,
48    /// 直播间标题
49    pub title: String,
50    /// 主播用户名
51    pub uname: String,
52    /// 主播头像URL
53    pub face: String,
54    /// 认证信息
55    pub verify: serde_json::Value,
56    /// 主播用户mid
57    pub uid: i64,
58    /// 关键帧URL
59    pub keyframe: String,
60    /// 是否自动播放
61    pub is_auto_play: i32,
62    /// 头像框类型
63    pub head_box_type: i32,
64    /// 标记
65    pub flag: i32,
66    /// 会话ID
67    pub session_id: String,
68    /// 展示回调URL
69    pub show_callback: String,
70    /// 点击回调URL
71    pub click_callback: String,
72    /// 特殊ID
73    pub special_id: i32,
74    /// 观看展示
75    pub watched_show: WatchedShow,
76    /// 是否为NFT头像
77    pub is_nft: i32,
78    /// NFT标记
79    pub nft_dmark: String,
80    /// 是否为广告
81    pub is_ad: bool,
82    /// 广告透明内容
83    pub ad_transparent_content: Option<serde_json::Value>,
84    /// 显示广告图标
85    pub show_ad_icon: bool,
86    /// 状态
87    pub status: bool,
88    /// 关注者数量
89    pub followers: i32,
90}
91
92#[derive(Debug, Serialize, Clone, Deserialize)]
93pub struct RecommendData {
94    /// 推荐房间列表
95    pub recommend_room_list: Vec<RecommendRoom>,
96    /// 置顶直播间号
97    pub top_room_id: i64,
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103    use crate::probe::contract::HttpMethod;
104    use crate::probe::endpoint_contract::EndpointContract;
105    use crate::{ApiEnvelope, BpiClient, BpiError, BpiResult};
106
107    fn contract() -> BpiResult<EndpointContract> {
108        EndpointContract::from_slice(include_bytes!(
109            "../../tests/contracts/live/public-core/recommend/contract.json"
110        ))
111    }
112
113    #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
114    #[tokio::test]
115    async fn test_get_live_recommend() -> Result<(), Box<BpiError>> {
116        let bpi = BpiClient::new().expect("client should build");
117        let data = bpi.live().recommend().await?;
118
119        assert!(!data.recommend_room_list.is_empty());
120        Ok(())
121    }
122
123    #[test]
124    fn live_recommend_contract_matches_endpoint_request() -> BpiResult<()> {
125        let contract = contract()?;
126
127        assert_eq!(contract.name, "live.recommend");
128        assert_eq!(contract.request.method, HttpMethod::Get);
129        assert_eq!(
130            contract.request.url.as_str(),
131            "https://api.live.bilibili.com/xlive/web-interface/v1/webMain/getMoreRecList"
132        );
133        assert_eq!(
134            contract.request.query.get("platform").map(String::as_str),
135            Some("web")
136        );
137        assert_eq!(
138            contract
139                .request
140                .query
141                .get("web_location")
142                .map(String::as_str),
143            Some("333.1007")
144        );
145        assert_eq!(contract.cases.len(), 3);
146        assert_eq!(
147            contract.cases[0].response.rust_model.as_deref(),
148            Some("RecommendData")
149        );
150        Ok(())
151    }
152
153    #[test]
154    fn live_recommend_response_fixture_parses_declared_model() -> BpiResult<()> {
155        let payload = ApiEnvelope::<RecommendData>::from_slice(include_bytes!(
156            "../../tests/contracts/live/public-core/recommend/responses/success.json"
157        ))?
158        .into_payload()?;
159
160        assert_eq!(payload.recommend_room_list.len(), 1);
161        assert_eq!(payload.recommend_room_list[0].roomid, 1);
162        Ok(())
163    }
164
165    fn local_probe_body(profile: &str) -> Option<serde_json::Value> {
166        let path =
167            format!("target/bpi-probe-runs/live/public-core/recommend/{profile}.response.json");
168        let bytes = std::fs::read(path).ok()?;
169        let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
170        value
171            .get("response")
172            .and_then(|response| response.get("body"))
173            .cloned()
174    }
175
176    #[test]
177    fn live_recommend_model_matches_local_probe_outputs_when_available() -> BpiResult<()> {
178        for profile in ["anonymous", "normal", "vip"] {
179            if let Some(body) = local_probe_body(profile) {
180                let payload =
181                    serde_json::from_value::<ApiEnvelope<RecommendData>>(body)?.into_payload()?;
182                assert!(!payload.recommend_room_list.is_empty());
183            }
184        }
185        Ok(())
186    }
187}