1use super::models::{ArticleAuthor, ArticleCategory, ArticleMedia, ArticleStats};
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct ArticleViewData {
11 pub act_id: i64,
13 pub apply_time: String,
15 pub attributes: Option<i32>,
17 #[serde(rename = "authenMark")]
19 pub authen_mark: Option<serde_json::Value>,
20 pub author: ArticleAuthor,
22 pub banner_url: String,
24 pub categories: Vec<ArticleCategory>,
26 pub category: ArticleCategory,
28 pub check_state: i32,
30 pub check_time: String,
32 pub content: String,
34 pub content_pic_list: Option<serde_json::Value>,
36 pub cover_avid: i64,
38 pub ctime: i64,
40 pub dispute: Option<serde_json::Value>,
42 pub dyn_id_str: String,
44 pub dynamic: Option<String>,
46 pub id: i64,
48 pub image_urls: Vec<String>,
50 pub is_like: bool,
52 pub keywords: String,
54 pub list: Option<ArticleList>,
56 pub media: ArticleMedia,
58 pub mtime: i64,
60 pub opus: Option<ArticleOpus>,
62 pub origin_image_urls: Vec<String>,
64 pub origin_template_id: i32,
66 pub original: i32,
68 pub private_pub: i32,
70 pub publish_time: i64,
72 pub reprint: i32,
74 pub state: i32,
76 pub stats: ArticleStats,
78 pub summary: String,
80 pub tags: Vec<ArticleTag>,
82 pub template_id: i32,
84 pub title: String,
86 pub top_video_info: Option<serde_json::Value>,
88 pub total_art_num: i64,
90 pub r#type: i32,
92 pub version_id: i64,
94 pub words: i64,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct AuthorVip {
101 pub r#type: i32,
103 pub status: i32,
105 pub due_date: i64,
107 pub vip_pay_type: i32,
109 pub theme_type: i32,
111 pub label: Option<serde_json::Value>,
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct ArticleList {
118 pub id: i64,
120 pub name: String,
122 pub image_url: String,
124 pub update_time: i64,
126 pub ctime: i64,
128 pub publish_time: i64,
130 pub summary: String,
132 pub words: i64,
134 pub read: i64,
136 pub articles_count: i32,
138 pub state: i32,
140 pub reason: String,
142 pub apply_time: String,
144 pub check_time: String,
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct ArticleTag {
151 pub tid: i32,
153 pub name: String,
155 }
158
159#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct ArticleOpus {
162 #[serde(default)]
164 pub ops: Vec<OpusOperation>,
165}
166
167#[derive(Debug, Clone, Serialize, Deserialize)]
169pub struct OpusOperation {
170 pub attribute: Option<OpusAttribute>,
172 pub insert: OpusInsert,
174}
175
176#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct OpusAttribute {
179 pub align: Option<String>,
181 pub blockquote: Option<bool>,
183 pub bold: Option<bool>,
185 pub class: Option<String>,
187 pub color: Option<String>,
189 pub header: Option<i32>,
191 pub strike: Option<bool>,
193 pub link: Option<String>,
195 pub italic: Option<bool>,
197 pub list: Option<String>,
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize)]
203#[serde(untagged)]
204pub enum OpusInsert {
205 Text(String),
207 Rich(Box<OpusRichInsert>),
209}
210
211#[derive(Debug, Clone, Serialize, Deserialize)]
213pub struct OpusRichInsert {
214 pub native_image: Option<OpusImage>,
216 pub cut_off: Option<OpusCutOff>,
218 pub video_card: Option<OpusVideoCard>,
220 pub article_card: Option<OpusArticleCard>,
222 pub vote_card: Option<OpusVoteCard>,
224 pub live_card: Option<OpusLiveCard>,
226}
227
228#[derive(Debug, Clone, Serialize, Deserialize)]
230pub struct OpusImage {
231 pub alt: String,
233 pub url: String,
235 pub width: i32,
237 pub height: i32,
239 pub size: i64,
241 pub status: String,
243}
244
245#[derive(Debug, Clone, Serialize, Deserialize)]
247pub struct OpusCutOff {
248 pub r#type: String,
250 pub url: String,
252}
253
254#[derive(Debug, Clone, Serialize, Deserialize)]
256pub struct OpusVideoCard {
257 pub alt: String,
259 pub height: i32,
261 pub id: String,
263 pub size: Option<serde_json::Value>,
265 pub status: String,
267 pub tid: f64,
269 pub url: String,
271 pub width: i32,
273}
274
275#[derive(Debug, Clone, Serialize, Deserialize)]
277pub struct OpusArticleCard {
278 pub alt: String,
280 pub height: i32,
282 pub id: String,
284 pub size: Option<serde_json::Value>,
286 pub status: String,
288 pub tid: i32,
290 pub url: String,
292 pub width: i32,
294}
295
296#[derive(Debug, Clone, Serialize, Deserialize)]
298pub struct OpusVoteCard {
299 pub alt: String,
301 pub height: i32,
303 pub id: String,
305 pub size: Option<serde_json::Value>,
307 pub status: String,
309 pub tid: i32,
311 pub url: String,
313 pub width: i32,
315}
316
317#[derive(Debug, Clone, Serialize, Deserialize)]
319pub struct OpusLiveCard {
320 pub alt: String,
322 pub height: i32,
324 pub id: String,
326 pub size: Option<serde_json::Value>,
328 pub status: String,
330 pub tid: i32,
332 pub url: String,
334 pub width: i32,
336}
337
338#[cfg(test)]
339mod tests {
340 use super::*;
341 use crate::article::params::ArticleViewParams;
342 use crate::probe::contract::HttpMethod;
343 use crate::probe::endpoint_contract::EndpointContract;
344 use crate::{ApiEnvelope, BpiClient, BpiError, BpiResult};
345 use std::mem;
346
347 const TEST_CVID: i64 = 2;
348
349 fn contract() -> BpiResult<EndpointContract> {
350 EndpointContract::from_slice(include_bytes!(
351 "../../tests/contracts/article/view/contract.json"
352 ))
353 }
354
355 #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
356 #[tokio::test]
357 async fn test_article_view() -> Result<(), Box<BpiError>> {
358 let bpi = BpiClient::new().expect("client should build");
359
360 let params = ArticleViewParams::new(TEST_CVID)?;
361
362 let data = bpi.article().view(params).await?;
363 assert!(!data.title.is_empty());
364 assert!(!data.content.is_empty());
365 assert!(!data.author.name.is_empty());
366
367 Ok(())
368 }
369
370 #[test]
371 fn opus_insert_keeps_rich_payload_boxed() {
372 assert!(mem::size_of::<OpusInsert>() <= 64);
373 }
374
375 #[test]
376 fn article_view_contract_matches_endpoint_request() -> BpiResult<()> {
377 let contract = contract()?;
378 let params = ArticleViewParams::new(TEST_CVID)?;
379
380 assert_eq!(contract.name, "article.view");
381 assert_eq!(contract.request.method, HttpMethod::Get);
382 assert_eq!(
383 contract.request.url.as_str(),
384 "https://api.bilibili.com/x/article/view"
385 );
386 assert_eq!(
387 contract.request.query.get("id").map(String::as_str),
388 Some("2")
389 );
390 assert_eq!(
391 contract
392 .request
393 .query
394 .get("gaia_source")
395 .map(String::as_str),
396 Some("main_web")
397 );
398 assert_eq!(
399 params.query_pairs(),
400 vec![
401 ("id", "2".to_string()),
402 ("gaia_source", "main_web".to_string()),
403 ]
404 );
405 assert_eq!(contract.cases.len(), 3);
406 assert_eq!(
407 contract.cases[0].response.error.as_deref(),
408 Some("wbi_risk_control")
409 );
410 assert_eq!(
411 contract.cases[1].response.rust_model.as_deref(),
412 Some("ArticleViewData")
413 );
414 Ok(())
415 }
416
417 #[test]
418 fn article_view_response_fixtures_parse_declared_model() -> BpiResult<()> {
419 for bytes in [
420 include_bytes!("../../tests/contracts/article/view/responses/normal.success.json")
421 .as_slice(),
422 include_bytes!("../../tests/contracts/article/view/responses/vip.success.json")
423 .as_slice(),
424 ] {
425 let payload = ApiEnvelope::<ArticleViewData>::from_slice(bytes)?.into_payload()?;
426
427 assert_eq!(payload.id, TEST_CVID);
428 assert!(!payload.title.is_empty());
429 }
430 Ok(())
431 }
432
433 #[test]
434 fn article_view_anonymous_fixture_records_wbi_error() -> BpiResult<()> {
435 let err = ApiEnvelope::<serde_json::Value>::from_slice(include_bytes!(
436 "../../tests/contracts/article/view/responses/anonymous.error.json"
437 ))?
438 .ensure_success()
439 .unwrap_err();
440
441 assert_eq!(err.code(), Some(-352));
442 Ok(())
443 }
444
445 fn local_probe_body(profile: &str) -> Option<serde_json::Value> {
446 let path = format!("target/bpi-probe-runs/article/read/view/{profile}.response.json");
447 let bytes = std::fs::read(path).ok()?;
448 let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
449 value
450 .get("response")
451 .and_then(|response| response.get("body"))
452 .cloned()
453 }
454
455 #[test]
456 fn article_view_model_matches_local_probe_outputs_when_available() -> BpiResult<()> {
457 for profile in ["normal", "vip"] {
458 let Some(body) = local_probe_body(profile) else {
459 continue;
460 };
461 let payload =
462 serde_json::from_value::<ApiEnvelope<ArticleViewData>>(body)?.into_payload()?;
463
464 assert_eq!(payload.id, TEST_CVID);
465 }
466 Ok(())
467 }
468}