Skip to main content

bpi_rs/article/
view.rs

1//! 专栏内容
2//!
3//! [查看 API 文档](https://github.com/Yuelioi/bilibili-API-collect/tree/cfc5fddcc8a94b74d91970bb5b4eaeb349addc47/docs/article/view.md)
4
5use super::models::{ArticleAuthor, ArticleCategory, ArticleMedia, ArticleStats};
6use serde::{Deserialize, Serialize};
7
8/// 专栏内容数据
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct ArticleViewData {
11    /// 操作ID?
12    pub act_id: i64,
13    /// 应用时间?
14    pub apply_time: String,
15    /// 属性位?
16    pub attributes: Option<i32>,
17    /// 授权码?
18    #[serde(rename = "authenMark")]
19    pub authen_mark: Option<serde_json::Value>,
20    /// 作者信息
21    pub author: ArticleAuthor,
22    /// 文章头图URL 空则为无
23    pub banner_url: String,
24    /// 专栏分类信息 首项为主分区, 第二项为子分区
25    pub categories: Vec<ArticleCategory>,
26    /// 专栏分类信息 子分区
27    pub category: ArticleCategory,
28    /// 检查状态?
29    pub check_state: i32,
30    /// 检查时间?
31    pub check_time: String,
32    /// 文章内容 type字段为0为HTML, 3为JSON
33    pub content: String,
34    /// 内容图片列表?
35    pub content_pic_list: Option<serde_json::Value>,
36    /// 封面视频AV号 0为无视频
37    pub cover_avid: i64,
38    /// 创建时间 UNIX秒级时间戳
39    pub ctime: i64,
40    /// 争议信息?
41    pub dispute: Option<serde_json::Value>,
42    /// 动态opus id
43    pub dyn_id_str: String,
44    /// 动态信息? 可能不存在
45    pub dynamic: Option<String>,
46    /// 专栏文章ID
47    pub id: i64,
48    /// 图片URL
49    pub image_urls: Vec<String>,
50    /// 是否喜欢?
51    pub is_like: bool,
52    /// 关键词 以逗号分隔
53    pub keywords: String,
54    /// 文集信息
55    pub list: Option<ArticleList>,
56    /// 媒体信息?
57    pub media: ArticleMedia,
58    /// 修改时间 UNIX秒级时间戳
59    pub mtime: i64,
60    /// opus信息 当type字段为3时存在, 包含了更加详细的富文本信息
61    pub opus: Option<ArticleOpus>,
62    /// 原始图片URL
63    pub origin_image_urls: Vec<String>,
64    /// 原始模板ID?
65    pub origin_template_id: i32,
66    /// 是否原创 0: 非原创 1: 原创
67    pub original: i32,
68    /// 仅自己可见
69    pub private_pub: i32,
70    /// 发布时间 UNIX秒级时间戳
71    pub publish_time: i64,
72    /// 是否允许转载 0: 不允许 1: 允许规范转载
73    pub reprint: i32,
74    /// 专栏状态
75    pub state: i32,
76    /// 统计数据
77    pub stats: ArticleStats,
78    /// 专栏开头部分内容 纯文本
79    pub summary: String,
80    /// 专栏标签
81    pub tags: Vec<ArticleTag>,
82    /// 模板ID?
83    pub template_id: i32,
84    /// 专栏标题
85    pub title: String,
86    /// 封面食品信息?
87    pub top_video_info: Option<serde_json::Value>,
88    /// 作者总文章数
89    pub total_art_num: i64,
90    /// 类型?
91    pub r#type: i32,
92    /// 版本ID?
93    pub version_id: i64,
94    /// 文章总词数
95    pub words: i64,
96}
97
98/// 作者VIP信息
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct AuthorVip {
101    /// VIP类型
102    pub r#type: i32,
103    /// VIP状态
104    pub status: i32,
105    /// 到期时间
106    pub due_date: i64,
107    /// 支付类型
108    pub vip_pay_type: i32,
109    /// 主题类型
110    pub theme_type: i32,
111    /// 标签
112    pub label: Option<serde_json::Value>,
113}
114
115/// 专栏文集信息
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct ArticleList {
118    /// 文集ID
119    pub id: i64,
120    /// 文集名称
121    pub name: String,
122    /// 文集图片
123    pub image_url: String,
124    /// 文集更新时间
125    pub update_time: i64,
126    /// 文集创建时间
127    pub ctime: i64,
128    /// 文集发布时间
129    pub publish_time: i64,
130    /// 文集简介
131    pub summary: String,
132    /// 文集字数
133    pub words: i64,
134    /// 文集阅读量
135    pub read: i64,
136    /// 文集内文章数量
137    pub articles_count: i32,
138    /// 文集状态
139    pub state: i32,
140    /// 文集原因
141    pub reason: String,
142    /// 文集申请时间
143    pub apply_time: String,
144    /// 文集审核时间
145    pub check_time: String,
146}
147
148/// 专栏标签
149#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct ArticleTag {
151    /// 标签ID
152    pub tid: i32,
153    /// 标签名称
154    pub name: String,
155    // /// 标签类型
156    // pub r#type: i32,
157}
158
159/// 专栏Opus信息(富文本内容)
160#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct ArticleOpus {
162    /// 以JSON呈现的文本内容
163    #[serde(default)]
164    pub ops: Vec<OpusOperation>,
165}
166
167/// Opus操作
168#[derive(Debug, Clone, Serialize, Deserialize)]
169pub struct OpusOperation {
170    /// 属性
171    pub attribute: Option<OpusAttribute>,
172    /// 插入内容
173    pub insert: OpusInsert,
174}
175
176/// Opus属性
177#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct OpusAttribute {
179    /// 文字对齐
180    pub align: Option<String>,
181    /// 块级引用
182    pub blockquote: Option<bool>,
183    /// 加粗
184    pub bold: Option<bool>,
185    /// 类名
186    pub class: Option<String>,
187    /// 颜色
188    pub color: Option<String>,
189    /// 标题级别
190    pub header: Option<i32>,
191    /// 删除线
192    pub strike: Option<bool>,
193    /// 站内链接
194    pub link: Option<String>,
195    /// 斜体
196    pub italic: Option<bool>,
197    /// 列表
198    pub list: Option<String>,
199}
200
201/// Opus插入内容
202#[derive(Debug, Clone, Serialize, Deserialize)]
203#[serde(untagged)]
204pub enum OpusInsert {
205    /// 文本内容
206    Text(String),
207    /// 富文本内容
208    Rich(Box<OpusRichInsert>),
209}
210
211/// Opus富文本插入内容
212#[derive(Debug, Clone, Serialize, Deserialize)]
213pub struct OpusRichInsert {
214    /// 原生图片
215    pub native_image: Option<OpusImage>,
216    /// 分割线
217    pub cut_off: Option<OpusCutOff>,
218    /// 视频卡片
219    pub video_card: Option<OpusVideoCard>,
220    /// 专栏卡片
221    pub article_card: Option<OpusArticleCard>,
222    /// 投票卡片
223    pub vote_card: Option<OpusVoteCard>,
224    /// 直播卡片
225    pub live_card: Option<OpusLiveCard>,
226}
227
228/// Opus图片
229#[derive(Debug, Clone, Serialize, Deserialize)]
230pub struct OpusImage {
231    /// 图像的备用文本描述
232    pub alt: String,
233    /// 图像的URL
234    pub url: String,
235    /// 图像的宽度
236    pub width: i32,
237    /// 图像的高度
238    pub height: i32,
239    /// 图像的文件大小
240    pub size: i64,
241    /// 图像状态
242    pub status: String,
243}
244
245/// Opus分割线
246#[derive(Debug, Clone, Serialize, Deserialize)]
247pub struct OpusCutOff {
248    /// 类型
249    pub r#type: String,
250    /// 分割线图片URL
251    pub url: String,
252}
253
254/// Opus视频卡片
255#[derive(Debug, Clone, Serialize, Deserialize)]
256pub struct OpusVideoCard {
257    /// 备用文本
258    pub alt: String,
259    /// 卡片高度
260    pub height: i32,
261    /// 视频ID
262    pub id: String,
263    /// 大小
264    pub size: Option<serde_json::Value>,
265    /// 状态
266    pub status: String,
267    /// 类型ID
268    pub tid: f64,
269    /// 卡片图片URL
270    pub url: String,
271    /// 卡片宽度
272    pub width: i32,
273}
274
275/// Opus专栏卡片
276#[derive(Debug, Clone, Serialize, Deserialize)]
277pub struct OpusArticleCard {
278    /// 备用文本
279    pub alt: String,
280    /// 卡片高度
281    pub height: i32,
282    /// 文章ID
283    pub id: String,
284    /// 大小
285    pub size: Option<serde_json::Value>,
286    /// 状态
287    pub status: String,
288    /// 类型ID
289    pub tid: i32,
290    /// 卡片图片URL
291    pub url: String,
292    /// 卡片宽度
293    pub width: i32,
294}
295
296/// Opus投票卡片
297#[derive(Debug, Clone, Serialize, Deserialize)]
298pub struct OpusVoteCard {
299    /// 备用文本
300    pub alt: String,
301    /// 卡片高度
302    pub height: i32,
303    /// 投票ID
304    pub id: String,
305    /// 大小
306    pub size: Option<serde_json::Value>,
307    /// 状态
308    pub status: String,
309    /// 类型ID
310    pub tid: i32,
311    /// 卡片图片URL
312    pub url: String,
313    /// 卡片宽度
314    pub width: i32,
315}
316
317/// Opus直播卡片
318#[derive(Debug, Clone, Serialize, Deserialize)]
319pub struct OpusLiveCard {
320    /// 备用文本
321    pub alt: String,
322    /// 卡片高度
323    pub height: i32,
324    /// 直播间ID
325    pub id: String,
326    /// 大小
327    pub size: Option<serde_json::Value>,
328    /// 状态
329    pub status: String,
330    /// 类型ID
331    pub tid: i32,
332    /// 卡片图片URL
333    pub url: String,
334    /// 卡片宽度
335    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}