Skip to main content

bpi_rs/article/
card.rs

1//! 卡片信息
2//!
3//! [查看 API 文档](https://github.com/Yuelioi/bilibili-API-collect/tree/cfc5fddcc8a94b74d91970bb5b4eaeb349addc47/docs/article/card.md)
4
5use super::models::{ArticleAuthor, ArticleCategory, ArticleMedia, ArticleStats};
6use serde::{Deserialize, Serialize};
7
8/// 卡片信息响应类型
9pub type CardData = std::collections::HashMap<String, CardItem>;
10
11/// 卡片项目(可以是视频、专栏或直播间)
12#[derive(Debug, Clone, Serialize, Deserialize)]
13#[serde(untagged)]
14pub enum CardItem {
15    /// 视频卡片
16    Video(Box<VideoCard>),
17    /// 专栏卡片
18    Article(Box<ArticleCard>),
19    /// 直播间卡片
20    Live(Box<LiveCard>),
21
22    /// 未知卡片类型
23    Unknown(serde_json::Value),
24}
25
26/// 视频卡片
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct VideoCard {
29    /// 视频aid
30    pub aid: i64,
31    /// 视频bvid
32    pub bvid: String,
33    /// 视频cid
34    pub cid: i64,
35    /// 版权信息
36    pub copyright: i32,
37    /// 封面图片
38    pub pic: String,
39    /// 创建时间
40    pub ctime: i64,
41    /// 视频描述
42    pub desc: String,
43    /// 视频尺寸信息
44    pub dimension: VideoDimension,
45    /// 视频时长
46    pub duration: i64,
47    /// 动态内容
48    pub dynamic: String,
49    /// UP主信息
50    pub owner: VideoOwner,
51    /// 发布时间
52    pub pubdate: i64,
53    /// 视频权限
54    pub rights: VideoRights,
55    /// 短链接
56    pub short_link_v2: String,
57    /// 视频统计信息
58    pub stat: VideoStat,
59    /// 视频状态
60    pub state: i32,
61    /// 分区ID
62    pub tid: i32,
63    /// 视频标题
64    pub title: String,
65    /// 分区名称
66    pub tname: String,
67    /// 分P数量
68    pub videos: i32,
69    /// VT开关
70    pub vt_switch: bool,
71}
72
73/// 视频尺寸信息
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct VideoDimension {
76    /// 高度
77    pub height: i32,
78    /// 旋转角度
79    pub rotate: i32,
80    /// 宽度
81    pub width: i32,
82}
83
84/// 视频UP主信息
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct VideoOwner {
87    /// UP主头像
88    pub face: String,
89    /// UP主mid
90    pub mid: i64,
91    /// UP主昵称
92    pub name: String,
93}
94
95/// 视频权限信息
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct VideoRights {
98    /// 是否付费
99    pub arc_pay: i32,
100    /// 是否自动播放
101    pub autoplay: i32,
102    /// 是否可充电
103    pub bp: i32,
104    /// 是否可下载
105    pub download: i32,
106    /// 是否可充电
107    pub elec: i32,
108    /// 是否高清
109    pub hd5: i32,
110    /// 是否合作视频
111    pub is_cooperation: i32,
112    /// 是否电影
113    pub movie: i32,
114    /// 是否无背景
115    pub no_background: i32,
116    /// 是否禁止转载
117    pub no_reprint: i32,
118    /// 是否付费
119    pub pay: i32,
120    /// 是否付费观看
121    pub pay_free_watch: i32,
122    /// 是否UGC付费
123    pub ugc_pay: i32,
124    /// 是否UGC付费预览
125    pub ugc_pay_preview: i32,
126}
127
128/// 视频统计信息
129#[derive(Debug, Clone, Serialize, Deserialize)]
130pub struct VideoStat {
131    /// 视频aid
132    pub aid: i64,
133    /// 投币数
134    pub coin: i64,
135    /// 弹幕数
136    pub danmaku: i64,
137    /// 点踩数
138    pub dislike: i64,
139    /// 收藏数
140    pub favorite: i64,
141    /// 历史排名
142    pub his_rank: i32,
143    /// 点赞数
144    pub like: i64,
145    /// 当前排名
146    pub now_rank: i32,
147    /// 评论数
148    pub reply: i64,
149    /// 分享数
150    pub share: i64,
151    /// 播放数
152    pub view: i64,
153    /// VT值
154    pub vt: i32,
155    /// VV值
156    pub vv: i32,
157}
158
159/// 专栏卡片
160#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct ArticleCard {
162    /// 活动ID
163    pub act_id: i64,
164    /// 申请时间
165    pub apply_time: String,
166    /// 属性
167    pub attributes: i32,
168    /// 认证标记
169    #[serde(rename = "authenMark")]
170    pub authen_mark: Option<serde_json::Value>,
171    /// 作者信息
172    pub author: ArticleAuthor,
173    /// 横幅URL
174    pub banner_url: String,
175    /// 分类列表
176    pub categories: Vec<ArticleCategory>,
177    /// 主分类
178    pub category: ArticleCategory,
179    /// 审核状态
180    pub check_state: i32,
181    /// 审核时间
182    pub check_time: String,
183    /// 内容图片列表
184    pub content_pic_list: Option<serde_json::Value>,
185    /// 封面视频ID
186    pub cover_avid: i64,
187    /// 创建时间
188    pub ctime: i64,
189    /// 争议信息
190    pub dispute: Option<serde_json::Value>,
191    /// 动态内容
192    pub dynamic: String,
193    /// 专栏ID
194    pub id: i64,
195    /// 图片URL列表
196    pub image_urls: Vec<String>,
197    /// 是否点赞
198    pub is_like: bool,
199    /// 文集信息
200    pub list: Option<ArticleList>,
201    /// 媒体信息
202    pub media: ArticleMedia,
203    /// 修改时间
204    pub mtime: i64,
205    /// 原始图片URL列表
206    pub origin_image_urls: Vec<String>,
207    /// 原始模板ID
208    pub origin_template_id: i32,
209    /// 是否原创
210    pub original: i32,
211    /// 是否私密发布
212    pub private_pub: i32,
213    /// 发布时间
214    pub publish_time: i64,
215    /// 是否转载
216    pub reprint: i32,
217    /// 状态
218    pub state: i32,
219    /// 统计信息
220    pub stats: ArticleStats,
221    /// 摘要
222    pub summary: String,
223    /// 模板ID
224    pub template_id: i32,
225    /// 标题
226    pub title: String,
227    /// 顶部视频信息
228    pub top_video_info: Option<serde_json::Value>,
229    /// 类型
230    pub r#type: i32,
231    /// 字数
232    pub words: i64,
233}
234
235/// 作者VIP信息
236#[derive(Debug, Clone, Serialize, Deserialize)]
237pub struct AuthorVip {
238    /// 头像订阅
239    pub avatar_subscript: i32,
240    /// 到期时间
241    pub due_date: i64,
242    /// 标签信息
243    pub label: VipLabel,
244    /// 昵称颜色
245    pub nickname_color: String,
246    /// VIP状态
247    pub status: i32,
248    /// 主题类型
249    pub theme_type: i32,
250    /// VIP类型
251    pub r#type: i32,
252    /// 支付类型
253    pub vip_pay_type: i32,
254}
255
256/// VIP标签
257#[derive(Debug, Clone, Serialize, Deserialize)]
258pub struct VipLabel {
259    /// 标签主题
260    pub label_theme: String,
261    /// 标签路径
262    pub path: String,
263    /// 标签文本
264    pub text: String,
265}
266
267/// 专栏文集信息
268#[derive(Debug, Clone, Serialize, Deserialize)]
269pub struct ArticleList {
270    /// 申请时间
271    pub apply_time: String,
272    /// 文章数量
273    pub articles_count: i32,
274    /// 审核时间
275    pub check_time: String,
276    /// 创建时间
277    pub ctime: i64,
278    /// 文集ID
279    pub id: i64,
280    /// 文集图片
281    pub image_url: String,
282    /// 作者ID
283    pub mid: i64,
284    /// 文集名称
285    pub name: String,
286    /// 发布时间
287    pub publish_time: i64,
288    /// 阅读量
289    pub read: i64,
290    /// 原因
291    pub reason: String,
292    /// 状态
293    pub state: i32,
294    /// 摘要
295    pub summary: String,
296    /// 更新时间
297    pub update_time: i64,
298    /// 字数
299    pub words: i64,
300}
301
302/// 直播间卡片
303#[derive(Debug, Clone, Serialize, Deserialize)]
304pub struct LiveCard {
305    /// 分区完整名称
306    pub area_v2_name: String,
307    /// 直播封面
308    pub cover: String,
309    /// 主播头像
310    pub face: String,
311    /// 直播状态
312    pub live_status: i32,
313    /// 在线人数
314    pub online: i64,
315    /// 挂件RU
316    pub pendent_ru: String,
317    /// 挂件RU颜色
318    pub pendent_ru_color: String,
319    /// 挂件RU图片
320    pub pendent_ru_pic: String,
321    /// 角色
322    pub role: i32,
323    /// 直播间长ID
324    pub room_id: i64,
325    /// 直播间标题
326    pub title: String,
327    /// 主播UID
328    pub uid: i64,
329    /// 主播用户名
330    pub uname: String,
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336    use crate::article::params::ArticleCardsParams;
337    use crate::probe::contract::HttpMethod;
338    use crate::probe::endpoint_contract::EndpointContract;
339    use crate::{ApiEnvelope, BpiClient, BpiError, BpiResult};
340    use std::mem;
341
342    fn contract() -> BpiResult<EndpointContract> {
343        EndpointContract::from_slice(include_bytes!(
344            "../../tests/contracts/article/cards/contract.json"
345        ))
346    }
347
348    #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
349    #[tokio::test]
350    async fn test_get_article_cards() -> Result<(), Box<BpiError>> {
351        let bpi = BpiClient::new().expect("client should build");
352
353        let params = ArticleCardsParams::new("av2,cv1,cv2")?;
354
355        let data = bpi.article().cards(params).await?;
356        tracing::info!("{:#?}", data);
357
358        Ok(())
359    }
360
361    #[test]
362    fn card_item_keeps_large_payloads_boxed() {
363        assert!(mem::size_of::<CardItem>() <= 64);
364    }
365
366    #[test]
367    fn article_cards_contract_matches_endpoint_request() -> BpiResult<()> {
368        let contract = contract()?;
369        let params = ArticleCardsParams::new("av2,cv1,cv2")?;
370
371        assert_eq!(contract.name, "article.cards");
372        assert_eq!(contract.request.method, HttpMethod::Get);
373        assert_eq!(
374            contract.request.url.as_str(),
375            "https://api.bilibili.com/x/article/cards"
376        );
377        assert_eq!(
378            contract.request.query.get("ids").map(String::as_str),
379            Some("av2,cv1,cv2")
380        );
381        assert_eq!(
382            contract
383                .request
384                .query
385                .get("web_location")
386                .map(String::as_str),
387            Some("333.1305")
388        );
389        assert_eq!(
390            params.query_pairs(),
391            vec![
392                ("ids", "av2,cv1,cv2".to_string()),
393                ("web_location", "333.1305".to_string()),
394            ]
395        );
396        assert_eq!(contract.cases.len(), 3);
397        assert_eq!(
398            contract.cases[0].response.error.as_deref(),
399            Some("wbi_risk_control")
400        );
401        assert_eq!(
402            contract.cases[1].response.rust_model.as_deref(),
403            Some("CardData")
404        );
405        Ok(())
406    }
407
408    #[test]
409    fn article_cards_response_fixtures_parse_declared_model() -> BpiResult<()> {
410        for bytes in [
411            include_bytes!("../../tests/contracts/article/cards/responses/normal.success.json")
412                .as_slice(),
413            include_bytes!("../../tests/contracts/article/cards/responses/vip.success.json")
414                .as_slice(),
415        ] {
416            let payload = ApiEnvelope::<CardData>::from_slice(bytes)?.into_payload()?;
417
418            assert!(payload.contains_key("av2"));
419            assert!(payload.contains_key("cv1"));
420        }
421        Ok(())
422    }
423
424    #[test]
425    fn article_cards_anonymous_fixture_records_wbi_error() -> BpiResult<()> {
426        let err = ApiEnvelope::<serde_json::Value>::from_slice(include_bytes!(
427            "../../tests/contracts/article/cards/responses/anonymous.error.json"
428        ))?
429        .ensure_success()
430        .unwrap_err();
431
432        assert_eq!(err.code(), Some(-352));
433        Ok(())
434    }
435
436    fn local_probe_body(profile: &str) -> Option<serde_json::Value> {
437        let path = format!("target/bpi-probe-runs/article/read/cards/{profile}.response.json");
438        let bytes = std::fs::read(path).ok()?;
439        let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
440        value
441            .get("response")
442            .and_then(|response| response.get("body"))
443            .cloned()
444    }
445
446    #[test]
447    fn article_cards_model_matches_local_probe_outputs_when_available() -> BpiResult<()> {
448        for profile in ["normal", "vip"] {
449            let Some(body) = local_probe_body(profile) else {
450                continue;
451            };
452            let payload = serde_json::from_value::<ApiEnvelope<CardData>>(body)?.into_payload()?;
453
454            assert!(payload.contains_key("cv1"));
455        }
456        Ok(())
457    }
458}