Skip to main content

bpi_rs/article/
info.rs

1//! 专栏基本信息
2//!
3//! [查看 API 文档](https://github.com/Yuelioi/bilibili-API-collect/tree/cfc5fddcc8a94b74d91970bb5b4eaeb349addc47/docs/article/info.md)
4
5use crate::article::models::ArticleStats;
6use serde::{Deserialize, Serialize};
7
8/// 专栏基本信息数据
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct ArticleInfoData {
11    /// 是否点赞 0:未点赞 1:已点赞 需要登录(Cookie) 未登录为0
12    pub like: i32,
13    /// 是否关注文章作者 false:未关注 true:已关注 需要登录(Cookie) 未登录为false
14    pub attention: bool,
15    /// 是否收藏 false:未收藏 true:已收藏 需要登录(Cookie) 未登录为false
16    pub favorite: bool,
17    /// 为文章投币数
18    pub coin: i32,
19    /// 状态数信息
20    pub stats: ArticleStats,
21    /// 文章标题
22    pub title: String,
23    /// 文章头图url
24    pub banner_url: String,
25    /// 文章作者mid
26    pub mid: i64,
27    /// 文章作者昵称
28    pub author_name: String,
29    /// true 作用尚不明确
30    pub is_author: bool,
31    /// 动态封面
32    pub image_urls: Vec<String>,
33    /// 封面图片
34    pub origin_image_urls: Vec<String>,
35    /// true 作用尚不明确
36    pub shareable: bool,
37    /// true 作用尚不明确
38    pub show_later_watch: bool,
39    /// true 作用尚不明确
40    pub show_small_window: bool,
41    /// 是否收于文集 false:否 true:是
42    pub in_list: bool,
43    /// 上一篇文章cvid 无为0
44    pub pre: i64,
45    /// 下一篇文章cvid 无为0
46    pub next: i64,
47    /// 分享方式列表
48    pub share_channels: Vec<ShareChannel>,
49    /// 文章类别 0:文章 2:笔记
50    pub r#type: i32,
51    /// 视频URL
52    #[serde(default)]
53    pub video_url: String,
54    /// 位置信息
55    #[serde(default)]
56    pub location: String,
57    /// 是否禁用分享
58    #[serde(default)]
59    pub disable_share: bool,
60}
61
62/// 分享方式
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct ShareChannel {
65    /// 分享名称
66    pub name: String,
67    /// 分享图片url
68    pub picture: String,
69    /// 分享代号
70    pub share_channel: String,
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76    use crate::article::params::ArticleInfoParams;
77    use crate::probe::contract::HttpMethod;
78    use crate::probe::endpoint_contract::EndpointContract;
79    use crate::{ApiEnvelope, BpiClient, BpiError, BpiResult};
80
81    const TEST_CVID: i64 = 2;
82
83    fn contract() -> BpiResult<EndpointContract> {
84        EndpointContract::from_slice(include_bytes!(
85            "../../tests/contracts/article/info/contract.json"
86        ))
87    }
88
89    #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
90    #[tokio::test]
91    async fn test_article_info() -> Result<(), Box<BpiError>> {
92        let bpi = BpiClient::new().expect("client should build");
93
94        let params = ArticleInfoParams::new(TEST_CVID)?;
95
96        let data = bpi.article().info(params).await?;
97        assert!(!data.title.is_empty());
98        assert!(!data.author_name.is_empty());
99        assert!(data.mid > 0);
100
101        Ok(())
102    }
103
104    #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
105    #[tokio::test]
106    async fn test_article_stats() -> Result<(), Box<BpiError>> {
107        let bpi = BpiClient::new().expect("client should build");
108
109        let params = ArticleInfoParams::new(TEST_CVID)?;
110        let data = bpi.article().info(params).await?;
111        let stats = &data.stats;
112        assert!(stats.view >= 0);
113        assert!(stats.favorite >= 0);
114        assert!(stats.like >= 0);
115        assert!(stats.reply >= 0);
116
117        Ok(())
118    }
119
120    #[test]
121    fn article_info_contract_matches_endpoint_request() -> BpiResult<()> {
122        let contract = contract()?;
123        let params = ArticleInfoParams::new(TEST_CVID)?;
124
125        assert_eq!(contract.name, "article.info");
126        assert_eq!(contract.request.method, HttpMethod::Get);
127        assert_eq!(
128            contract.request.url.as_str(),
129            "https://api.bilibili.com/x/article/viewinfo"
130        );
131        assert_eq!(
132            contract.request.query.get("id").map(String::as_str),
133            Some("2")
134        );
135        assert_eq!(params.query_pairs(), vec![("id", "2".to_string())]);
136        assert_eq!(contract.cases.len(), 3);
137        assert_eq!(
138            contract.cases[0].response.rust_model.as_deref(),
139            Some("ArticleInfoData")
140        );
141        Ok(())
142    }
143
144    #[test]
145    fn article_info_response_fixtures_parse_declared_model() -> BpiResult<()> {
146        for bytes in [
147            include_bytes!("../../tests/contracts/article/info/responses/anonymous.success.json")
148                .as_slice(),
149            include_bytes!("../../tests/contracts/article/info/responses/normal.success.json")
150                .as_slice(),
151            include_bytes!("../../tests/contracts/article/info/responses/vip.success.json")
152                .as_slice(),
153        ] {
154            let payload = ApiEnvelope::<ArticleInfoData>::from_slice(bytes)?.into_payload()?;
155
156            assert!(!payload.title.is_empty());
157            assert!(!payload.author_name.is_empty());
158            assert!(payload.mid > 0);
159        }
160        Ok(())
161    }
162
163    fn local_probe_body(profile: &str) -> Option<serde_json::Value> {
164        let path = format!("target/bpi-probe-runs/article/read/info/{profile}.response.json");
165        let bytes = std::fs::read(path).ok()?;
166        let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
167        value
168            .get("response")
169            .and_then(|response| response.get("body"))
170            .cloned()
171    }
172
173    #[test]
174    fn article_info_model_matches_local_probe_outputs_when_available() -> BpiResult<()> {
175        for profile in ["anonymous", "normal", "vip"] {
176            let Some(body) = local_probe_body(profile) else {
177                continue;
178            };
179            let payload =
180                serde_json::from_value::<ApiEnvelope<ArticleInfoData>>(body)?.into_payload()?;
181
182            assert!(payload.mid > 0);
183        }
184        Ok(())
185    }
186}