1use super::models::{ArticleAuthor, ArticleCategory, ArticleMedia, ArticleStats};
6use serde::{Deserialize, Serialize};
7
8pub type CardData = std::collections::HashMap<String, CardItem>;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
13#[serde(untagged)]
14pub enum CardItem {
15 Video(Box<VideoCard>),
17 Article(Box<ArticleCard>),
19 Live(Box<LiveCard>),
21
22 Unknown(serde_json::Value),
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct VideoCard {
29 pub aid: i64,
31 pub bvid: String,
33 pub cid: i64,
35 pub copyright: i32,
37 pub pic: String,
39 pub ctime: i64,
41 pub desc: String,
43 pub dimension: VideoDimension,
45 pub duration: i64,
47 pub dynamic: String,
49 pub owner: VideoOwner,
51 pub pubdate: i64,
53 pub rights: VideoRights,
55 pub short_link_v2: String,
57 pub stat: VideoStat,
59 pub state: i32,
61 pub tid: i32,
63 pub title: String,
65 pub tname: String,
67 pub videos: i32,
69 pub vt_switch: bool,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct VideoDimension {
76 pub height: i32,
78 pub rotate: i32,
80 pub width: i32,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct VideoOwner {
87 pub face: String,
89 pub mid: i64,
91 pub name: String,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct VideoRights {
98 pub arc_pay: i32,
100 pub autoplay: i32,
102 pub bp: i32,
104 pub download: i32,
106 pub elec: i32,
108 pub hd5: i32,
110 pub is_cooperation: i32,
112 pub movie: i32,
114 pub no_background: i32,
116 pub no_reprint: i32,
118 pub pay: i32,
120 pub pay_free_watch: i32,
122 pub ugc_pay: i32,
124 pub ugc_pay_preview: i32,
126}
127
128#[derive(Debug, Clone, Serialize, Deserialize)]
130pub struct VideoStat {
131 pub aid: i64,
133 pub coin: i64,
135 pub danmaku: i64,
137 pub dislike: i64,
139 pub favorite: i64,
141 pub his_rank: i32,
143 pub like: i64,
145 pub now_rank: i32,
147 pub reply: i64,
149 pub share: i64,
151 pub view: i64,
153 pub vt: i32,
155 pub vv: i32,
157}
158
159#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct ArticleCard {
162 pub act_id: i64,
164 pub apply_time: String,
166 pub attributes: i32,
168 #[serde(rename = "authenMark")]
170 pub authen_mark: Option<serde_json::Value>,
171 pub author: ArticleAuthor,
173 pub banner_url: String,
175 pub categories: Vec<ArticleCategory>,
177 pub category: ArticleCategory,
179 pub check_state: i32,
181 pub check_time: String,
183 pub content_pic_list: Option<serde_json::Value>,
185 pub cover_avid: i64,
187 pub ctime: i64,
189 pub dispute: Option<serde_json::Value>,
191 pub dynamic: String,
193 pub id: i64,
195 pub image_urls: Vec<String>,
197 pub is_like: bool,
199 pub list: Option<ArticleList>,
201 pub media: ArticleMedia,
203 pub mtime: i64,
205 pub origin_image_urls: Vec<String>,
207 pub origin_template_id: i32,
209 pub original: i32,
211 pub private_pub: i32,
213 pub publish_time: i64,
215 pub reprint: i32,
217 pub state: i32,
219 pub stats: ArticleStats,
221 pub summary: String,
223 pub template_id: i32,
225 pub title: String,
227 pub top_video_info: Option<serde_json::Value>,
229 pub r#type: i32,
231 pub words: i64,
233}
234
235#[derive(Debug, Clone, Serialize, Deserialize)]
237pub struct AuthorVip {
238 pub avatar_subscript: i32,
240 pub due_date: i64,
242 pub label: VipLabel,
244 pub nickname_color: String,
246 pub status: i32,
248 pub theme_type: i32,
250 pub r#type: i32,
252 pub vip_pay_type: i32,
254}
255
256#[derive(Debug, Clone, Serialize, Deserialize)]
258pub struct VipLabel {
259 pub label_theme: String,
261 pub path: String,
263 pub text: String,
265}
266
267#[derive(Debug, Clone, Serialize, Deserialize)]
269pub struct ArticleList {
270 pub apply_time: String,
272 pub articles_count: i32,
274 pub check_time: String,
276 pub ctime: i64,
278 pub id: i64,
280 pub image_url: String,
282 pub mid: i64,
284 pub name: String,
286 pub publish_time: i64,
288 pub read: i64,
290 pub reason: String,
292 pub state: i32,
294 pub summary: String,
296 pub update_time: i64,
298 pub words: i64,
300}
301
302#[derive(Debug, Clone, Serialize, Deserialize)]
304pub struct LiveCard {
305 pub area_v2_name: String,
307 pub cover: String,
309 pub face: String,
311 pub live_status: i32,
313 pub online: i64,
315 pub pendent_ru: String,
317 pub pendent_ru_color: String,
319 pub pendent_ru_pic: String,
321 pub role: i32,
323 pub room_id: i64,
325 pub title: String,
327 pub uid: i64,
329 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}