Skip to main content

bpi_rs/activity/
list.rs

1//! 活动列表
2//!
3//! [查看 API 文档](https://github.com/Yuelioi/bilibili-API-collect/tree/cfc5fddcc8a94b74d91970bb5b4eaeb349addc47/docs/activity/list.md)
4
5use crate::{BpiError, BpiResult};
6use serde::{Deserialize, Serialize};
7
8const DEFAULT_PLATFORM_FILTER: &str = "1,3";
9const DEFAULT_MOLD: u32 = 0;
10const DEFAULT_HTTP_MODE: u32 = 3;
11const DEFAULT_PAGE: u32 = 1;
12const DEFAULT_PAGE_SIZE: u32 = 15;
13
14/// 活动列表数据
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct ActivityListData {
17    /// 活动列表
18    pub list: Vec<ActivityItem>,
19    /// 当前页码
20    pub num: i32,
21    /// 每页条数
22    pub size: i32,
23    /// 总条数
24    pub total: i32,
25}
26
27/// 活动项目
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct ActivityItem {
30    /// 活动 ID
31    pub id: i32,
32    /// 固定值 1
33    pub state: i32,
34    /// 开始时间 UNIX 秒级时间戳
35    pub stime: i64,
36    /// 结束时间 UNIX 秒级时间戳
37    pub etime: i64,
38    /// 创建时间? UNIX 秒级时间戳, 可能为 0
39    pub ctime: i64,
40    /// 修改时间? UNIX 秒级时间戳, 可能为 0
41    pub mtime: i64,
42    /// 活动名称
43    pub name: String,
44    /// 活动链接
45    pub h5_url: String,
46    /// 活动封面
47    pub h5_cover: String,
48    /// 页面名称
49    pub page_name: String,
50    /// 活动平台类型? 即 URL 中 `plat` 参数
51    pub plat: i32,
52    /// 活动描述
53    pub desc: String,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct ActivityListParams {
58    plat: String,
59    mold: u32,
60    http: u32,
61    pn: u32,
62    ps: u32,
63}
64
65impl Default for ActivityListParams {
66    fn default() -> Self {
67        Self {
68            plat: DEFAULT_PLATFORM_FILTER.to_string(),
69            mold: DEFAULT_MOLD,
70            http: DEFAULT_HTTP_MODE,
71            pn: DEFAULT_PAGE,
72            ps: DEFAULT_PAGE_SIZE,
73        }
74    }
75}
76
77impl ActivityListParams {
78    /// Creates activity-list parameters with Bilibili's web defaults.
79    pub fn new() -> Self {
80        Self::default()
81    }
82
83    /// Sets the platform filter, for example `1,3`.
84    pub fn platform_filter(mut self, plat: impl Into<String>) -> BpiResult<Self> {
85        let plat = plat.into();
86        validate_non_blank("plat", &plat)?;
87        self.plat = plat;
88        Ok(self)
89    }
90
91    /// Sets the API mold marker. Defaults to `0`.
92    pub fn mold(mut self, mold: u32) -> Self {
93        self.mold = mold;
94        self
95    }
96
97    /// Sets the API HTTP mode marker. Defaults to `3`.
98    pub fn http_mode(mut self, http: u32) -> Self {
99        self.http = http;
100        self
101    }
102
103    /// Sets the page number.
104    pub fn page(mut self, page: u32) -> BpiResult<Self> {
105        self.pn = validate_positive("pn", page)?;
106        Ok(self)
107    }
108
109    /// Sets the page size.
110    pub fn page_size(mut self, page_size: u32) -> BpiResult<Self> {
111        self.ps = validate_positive("ps", page_size)?;
112        Ok(self)
113    }
114
115    pub(crate) fn query_pairs(&self) -> Vec<(&'static str, String)> {
116        vec![
117            ("plat", self.plat.clone()),
118            ("mold", self.mold.to_string()),
119            ("http", self.http.to_string()),
120            ("pn", self.pn.to_string()),
121            ("ps", self.ps.to_string()),
122        ]
123    }
124}
125
126fn validate_non_blank(field: &'static str, value: &str) -> BpiResult<()> {
127    if value.trim().is_empty() {
128        return Err(BpiError::invalid_parameter(field, "value cannot be blank"));
129    }
130
131    Ok(())
132}
133
134fn validate_positive(field: &'static str, value: u32) -> BpiResult<u32> {
135    if value == 0 {
136        return Err(BpiError::invalid_parameter(field, "value must be non-zero"));
137    }
138
139    Ok(value)
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use crate::probe::contract::HttpMethod;
146    use crate::probe::endpoint_contract::EndpointContract;
147    use crate::{ApiEnvelope, BpiClient, BpiResult};
148
149    fn contract() -> BpiResult<EndpointContract> {
150        EndpointContract::from_slice(include_bytes!(
151            "../../tests/contracts/activity/list/contract.json"
152        ))
153    }
154
155    #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
156    #[tokio::test]
157    async fn test_get_activity_list() -> Result<(), Box<BpiError>> {
158        let bpi = BpiClient::new().expect("client should build");
159
160        // 测试获取活动列表
161        let params = ActivityListParams::new().page_size(4)?;
162        let data = bpi.activity().list(params).await?;
163        tracing::info!("{:#?}", data);
164
165        assert!(!data.list.is_empty());
166        assert_eq!(data.num, 1);
167        assert_eq!(data.size, 4);
168        assert!(data.total > 0);
169
170        Ok(())
171    }
172
173    #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
174    #[tokio::test]
175    async fn test_get_activity_list_simple() -> Result<(), Box<BpiError>> {
176        let bpi = BpiClient::new().expect("client should build");
177
178        // 测试简化版本获取活动列表
179        let data = bpi.activity().list_default().await?;
180        tracing::info!("{:#?}", data);
181
182        assert!(!data.list.is_empty());
183        assert_eq!(data.num, 1);
184        assert_eq!(data.size, 15);
185
186        Ok(())
187    }
188
189    #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
190    #[tokio::test]
191    async fn test_activity_item_fields() -> Result<(), Box<BpiError>> {
192        let bpi = BpiClient::new().expect("client should build");
193
194        let params = ActivityListParams::new().page_size(1)?;
195        let data = bpi.activity().list(params).await?;
196        tracing::info!("{:#?}", data);
197
198        if let Some(activity) = data.list.first() {
199            assert!(activity.id > 0);
200            assert_eq!(activity.state, 1);
201            assert!(!activity.name.is_empty());
202            assert!(!activity.page_name.is_empty());
203        }
204
205        Ok(())
206    }
207
208    #[test]
209    fn activity_list_params_serializes_defaults() {
210        let params = ActivityListParams::new();
211
212        assert_eq!(
213            params.query_pairs(),
214            vec![
215                ("plat", "1,3".to_string()),
216                ("mold", "0".to_string()),
217                ("http", "3".to_string()),
218                ("pn", "1".to_string()),
219                ("ps", "15".to_string()),
220            ]
221        );
222    }
223
224    #[test]
225    fn activity_list_params_serializes_custom_values() -> Result<(), BpiError> {
226        let params = ActivityListParams::new()
227            .platform_filter("1")?
228            .mold(2)
229            .http_mode(4)
230            .page(3)?
231            .page_size(30)?;
232
233        assert_eq!(
234            params.query_pairs(),
235            vec![
236                ("plat", "1".to_string()),
237                ("mold", "2".to_string()),
238                ("http", "4".to_string()),
239                ("pn", "3".to_string()),
240                ("ps", "30".to_string()),
241            ]
242        );
243        Ok(())
244    }
245
246    #[test]
247    fn activity_list_params_rejects_blank_platform_filter() {
248        let err = ActivityListParams::new().platform_filter("  ").unwrap_err();
249
250        assert!(matches!(
251            err,
252            BpiError::InvalidParameter { field: "plat", .. }
253        ));
254    }
255
256    #[test]
257    fn activity_list_params_rejects_zero_page() {
258        let err = ActivityListParams::new().page(0).unwrap_err();
259
260        assert!(matches!(
261            err,
262            BpiError::InvalidParameter { field: "pn", .. }
263        ));
264    }
265
266    #[test]
267    fn activity_list_contract_matches_endpoint_request() -> BpiResult<()> {
268        let contract = contract()?;
269
270        assert_eq!(contract.name, "activity.list");
271        assert_eq!(contract.request.method, HttpMethod::Get);
272        assert_eq!(
273            contract.request.url.as_str(),
274            "https://api.bilibili.com/x/activity/page/list"
275        );
276        assert_eq!(
277            contract.request.query.get("plat").map(String::as_str),
278            Some(DEFAULT_PLATFORM_FILTER)
279        );
280        assert_eq!(
281            contract.request.query.get("ps").map(String::as_str),
282            Some("1")
283        );
284        assert_eq!(contract.cases.len(), 3);
285        assert_eq!(
286            contract.cases[0].response.rust_model.as_deref(),
287            Some("ActivityListData")
288        );
289        Ok(())
290    }
291
292    #[test]
293    fn activity_list_response_fixtures_parse_declared_model() -> BpiResult<()> {
294        for bytes in [
295            include_bytes!("../../tests/contracts/activity/list/responses/anonymous.success.json")
296                .as_slice(),
297            include_bytes!("../../tests/contracts/activity/list/responses/normal.success.json")
298                .as_slice(),
299            include_bytes!("../../tests/contracts/activity/list/responses/vip.success.json")
300                .as_slice(),
301        ] {
302            let payload = ApiEnvelope::<ActivityListData>::from_slice(bytes)?.into_payload()?;
303
304            assert_eq!(payload.num, 1);
305            assert_eq!(payload.size, 1);
306            assert_eq!(payload.list.len(), 1);
307        }
308        Ok(())
309    }
310
311    fn local_probe_body(profile: &str) -> Option<serde_json::Value> {
312        let path = format!("target/bpi-probe-runs/activity/public/list/{profile}.response.json");
313        let bytes = std::fs::read(path).ok()?;
314        let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
315        value
316            .get("response")
317            .and_then(|response| response.get("body"))
318            .cloned()
319    }
320
321    #[test]
322    fn activity_list_model_matches_local_probe_outputs_when_available() -> BpiResult<()> {
323        for profile in ["anonymous", "normal", "vip"] {
324            let Some(body) = local_probe_body(profile) else {
325                continue;
326            };
327            let payload =
328                serde_json::from_value::<ApiEnvelope<ActivityListData>>(body)?.into_payload()?;
329
330            assert_eq!(payload.num, 1);
331            assert_eq!(payload.size, 1);
332            assert_eq!(payload.list.len(), 1);
333        }
334        Ok(())
335    }
336}