Skip to main content

bpi_rs/comment/
list.rs

1//! 评论查询 API
2//!
3//! [参考文档](https://github.com/Yuelioi/bilibili-API-collect/tree/cfc5fddcc8a94b74d91970bb5b4eaeb349addc47/docs/comment/list.md)
4
5use crate::{BpiError, BpiResult};
6use serde::{Deserialize, Serialize};
7
8use super::types::{
9    Comment, // 评论条目对象,包含评论内容、发送者信息、回复等
10    Config,
11    Control,
12    Cursor,
13    PageInfo,
14    Top,
15    Upper,
16};
17
18/// Target comment area.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub struct CommentTarget {
21    r#type: i32,
22    oid: i64,
23}
24
25impl CommentTarget {
26    pub fn new(r#type: i32, oid: i64) -> BpiResult<Self> {
27        if r#type <= 0 {
28            return Err(BpiError::invalid_parameter(
29                "type",
30                "value must be greater than zero",
31            ));
32        }
33        if oid <= 0 {
34            return Err(BpiError::invalid_parameter(
35                "oid",
36                "value must be greater than zero",
37            ));
38        }
39        Ok(Self { r#type, oid })
40    }
41
42    fn query_pairs(&self) -> Vec<(&'static str, String)> {
43        vec![
44            ("type", self.r#type.to_string()),
45            ("oid", self.oid.to_string()),
46        ]
47    }
48}
49
50/// Sort order for `/x/v2/reply`.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum CommentSort {
53    Time,
54    Like,
55    Replies,
56}
57
58impl CommentSort {
59    fn as_i32(self) -> i32 {
60        match self {
61            Self::Time => 0,
62            Self::Like => 1,
63            Self::Replies => 2,
64        }
65    }
66}
67
68/// Parameters for `/x/v2/reply`.
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub struct CommentListParams {
71    target: CommentTarget,
72    page: Option<u32>,
73    page_size: Option<u32>,
74    sort: Option<CommentSort>,
75    nohot: Option<bool>,
76}
77
78impl CommentListParams {
79    pub fn new(target: CommentTarget) -> Self {
80        Self {
81            target,
82            page: None,
83            page_size: None,
84            sort: None,
85            nohot: None,
86        }
87    }
88
89    pub fn with_page(mut self, page: u32) -> BpiResult<Self> {
90        self.page = Some(validate_positive("pn", page)?);
91        Ok(self)
92    }
93
94    pub fn with_page_size(mut self, page_size: u32) -> BpiResult<Self> {
95        let page_size = validate_positive("ps", page_size)?;
96        if page_size > 20 {
97            return Err(BpiError::invalid_parameter(
98                "ps",
99                "value must be less than or equal to 20",
100            ));
101        }
102        self.page_size = Some(page_size);
103        Ok(self)
104    }
105
106    pub fn with_sort(mut self, sort: CommentSort) -> Self {
107        self.sort = Some(sort);
108        self
109    }
110
111    pub fn without_hot(mut self, nohot: bool) -> Self {
112        self.nohot = Some(nohot);
113        self
114    }
115
116    pub(crate) fn query_pairs(&self) -> Vec<(&'static str, String)> {
117        let mut params = self.target.query_pairs();
118        if let Some(page) = self.page {
119            params.push(("pn", page.to_string()));
120        }
121        if let Some(page_size) = self.page_size {
122            params.push(("ps", page_size.to_string()));
123        }
124        if let Some(sort) = self.sort {
125            params.push(("sort", sort.as_i32().to_string()));
126        }
127        if let Some(nohot) = self.nohot {
128            params.push(("nohot", i32::from(nohot).to_string()));
129        }
130        params
131    }
132}
133
134/// Parameters for `/x/v2/reply/reply`.
135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
136pub struct CommentRepliesParams {
137    target: CommentTarget,
138    root: i64,
139    page: Option<u32>,
140    page_size: Option<u32>,
141}
142
143impl CommentRepliesParams {
144    pub fn new(target: CommentTarget, root: i64) -> BpiResult<Self> {
145        if root <= 0 {
146            return Err(BpiError::invalid_parameter(
147                "root",
148                "value must be greater than zero",
149            ));
150        }
151        Ok(Self {
152            target,
153            root,
154            page: None,
155            page_size: None,
156        })
157    }
158
159    pub fn with_page(mut self, page: u32) -> BpiResult<Self> {
160        self.page = Some(validate_positive("pn", page)?);
161        Ok(self)
162    }
163
164    pub fn with_page_size(mut self, page_size: u32) -> BpiResult<Self> {
165        self.page_size = Some(validate_positive("ps", page_size)?);
166        Ok(self)
167    }
168
169    pub(crate) fn query_pairs(&self) -> Vec<(&'static str, String)> {
170        let mut params = self.target.query_pairs();
171        params.push(("root", self.root.to_string()));
172        if let Some(page) = self.page {
173            params.push(("pn", page.to_string()));
174        }
175        if let Some(page_size) = self.page_size {
176            params.push(("ps", page_size.to_string()));
177        }
178        params
179    }
180}
181
182/// Parameters for `/x/v2/reply/hot`.
183#[derive(Debug, Clone, Copy, PartialEq, Eq)]
184pub struct CommentHotParams {
185    target: CommentTarget,
186    root: i64,
187    page: Option<u32>,
188    page_size: Option<u32>,
189}
190
191impl CommentHotParams {
192    pub fn new(target: CommentTarget, root: i64) -> BpiResult<Self> {
193        if root <= 0 {
194            return Err(BpiError::invalid_parameter(
195                "root",
196                "value must be greater than zero",
197            ));
198        }
199        Ok(Self {
200            target,
201            root,
202            page: None,
203            page_size: None,
204        })
205    }
206
207    pub fn with_page(mut self, page: u32) -> BpiResult<Self> {
208        self.page = Some(validate_positive("pn", page)?);
209        Ok(self)
210    }
211
212    pub fn with_page_size(mut self, page_size: u32) -> BpiResult<Self> {
213        self.page_size = Some(validate_positive("ps", page_size)?);
214        Ok(self)
215    }
216
217    pub(crate) fn query_pairs(&self) -> Vec<(&'static str, String)> {
218        let mut params = self.target.query_pairs();
219        params.push(("root", self.root.to_string()));
220        if let Some(page) = self.page {
221            params.push(("pn", page.to_string()));
222        }
223        if let Some(page_size) = self.page_size {
224            params.push(("ps", page_size.to_string()));
225        }
226        params
227    }
228}
229
230/// Parameters for `/x/v2/reply/count`.
231#[derive(Debug, Clone, Copy, PartialEq, Eq)]
232pub struct CommentCountParams {
233    target: CommentTarget,
234}
235
236impl CommentCountParams {
237    pub fn new(target: CommentTarget) -> Self {
238        Self { target }
239    }
240
241    pub(crate) fn query_pairs(&self) -> Vec<(&'static str, String)> {
242        self.target.query_pairs()
243    }
244}
245
246#[derive(Debug, Clone, Serialize, Deserialize)]
247pub struct CommentListData {
248    pub page: Option<PageInfo>,
249    pub cursor: Option<Cursor>,        // 评论列表游标
250    pub replies: Option<Vec<Comment>>, // 评论列表,禁用时为 null
251    pub top: Option<Top>,              // 评论列表顶部信息
252    pub top_replies: Option<Vec<Comment>>,
253    pub effects: Option<serde_json::Value>,
254    pub assist: Option<u64>,    // 待确认
255    pub blacklist: Option<u64>, // 待确认
256    pub vote: Option<u64>,      // 投票评论?
257    pub config: Option<Config>, // 评论区显示控制
258    pub upper: Option<Upper>,   // 置顶评论
259
260    pub control: Option<Control>, // 评论区输入属性
261    pub note: Option<u32>,
262    pub cm_info: Option<serde_json::Value>, // 评论区相关信息
263
264                                            // pub page: Option<PageInfo>, // 页信息
265                                            // pub hots: Option<Vec<Comment>>, // 热评列表,禁用时为 null
266                                            // pub notice: Option<Notice>, // 评论区公告信息,无效时为 null
267                                            // pub mode: Option<u64>, // 评论区类型 id
268                                            // pub support_mode: Option<Vec<u64>>, // 评论区支持的类型 id
269                                            // pub folder: Option<Folder>, // 折叠相关信息
270                                            // pub lottery_card: Option<()>, // 待确认
271                                            // pub show_bvid: Option<bool>, // 是否显示 bvid
272}
273
274/// 公告信息
275#[derive(Debug, Clone, Serialize, Deserialize)]
276pub struct Notice {
277    pub content: Option<String>,
278    pub id: Option<u64>,
279    pub link: Option<String>,
280    pub title: Option<String>,
281}
282
283#[derive(Debug, Clone, Deserialize, Serialize)]
284pub struct HotCommentData {
285    pub page: HotCommentPage,
286    pub replies: Vec<Comment>, // 热评列表
287}
288
289#[derive(Debug, Clone, Deserialize, Serialize)]
290pub struct HotCommentPage {
291    pub acount: i64, // 总评论数
292    pub count: i64,  // 热评数
293    pub num: i32,    // 当前页码
294    pub size: i32,   // 每页项数
295}
296
297#[derive(Debug, Clone, Deserialize, Serialize)]
298pub struct CountData {
299    pub count: u64,
300}
301
302fn validate_positive(field: &'static str, value: u32) -> BpiResult<u32> {
303    if value == 0 {
304        return Err(BpiError::invalid_parameter(
305            field,
306            "value must be greater than zero",
307        ));
308    }
309    Ok(value)
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315    use crate::ApiEnvelope;
316    use crate::BpiClient;
317    use crate::probe::contract::HttpMethod;
318    use crate::probe::endpoint_contract::EndpointContract;
319    use std::collections::BTreeMap;
320    use tracing::info;
321
322    const TEST_TYPE: i32 = 1;
323    const TEST_OID: i64 = 23199;
324    const TEST_ROOT_RPID: i64 = 2554491176;
325
326    fn target() -> BpiResult<CommentTarget> {
327        CommentTarget::new(TEST_TYPE, TEST_OID)
328    }
329
330    fn contract(name: &str) -> BpiResult<EndpointContract> {
331        let bytes = match name {
332            "list" => {
333                include_bytes!("../../tests/contracts/comment/read/list/contract.json").as_slice()
334            }
335            "replies" => include_bytes!("../../tests/contracts/comment/read/replies/contract.json")
336                .as_slice(),
337            "hot" => {
338                include_bytes!("../../tests/contracts/comment/read/hot/contract.json").as_slice()
339            }
340            "count" => {
341                include_bytes!("../../tests/contracts/comment/read/count/contract.json").as_slice()
342            }
343            _ => unreachable!("unknown comment read contract"),
344        };
345        EndpointContract::from_slice(bytes)
346    }
347
348    fn query_map(params: Vec<(&'static str, String)>) -> BTreeMap<String, String> {
349        params
350            .into_iter()
351            .map(|(key, value)| (key.to_string(), value))
352            .collect()
353    }
354
355    #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
356    #[tokio::test]
357    async fn test_comment_list() -> Result<(), Box<BpiError>> {
358        let bpi = BpiClient::new().expect("client should build");
359
360        let result = bpi
361            .comment()
362            .list(
363                CommentListParams::new(CommentTarget::new(TEST_TYPE, TEST_OID)?)
364                    .with_page(1)?
365                    .with_page_size(5)?
366                    .with_sort(CommentSort::Time)
367                    .without_hot(false),
368            )
369            .await?;
370        let data = result;
371        info!("总评论数: {}", data.replies.unwrap().len());
372
373        Ok(())
374    }
375
376    #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
377    #[tokio::test]
378    async fn test_comment_replies() -> Result<(), Box<BpiError>> {
379        let bpi = BpiClient::new().expect("client should build");
380
381        let result = bpi
382            .comment()
383            .replies(
384                CommentRepliesParams::new(
385                    CommentTarget::new(TEST_TYPE, TEST_OID)?,
386                    TEST_ROOT_RPID,
387                )?
388                .with_page(1)?
389                .with_page_size(5)?,
390            )
391            .await?;
392        let data = result;
393        info!("总评论数: {}", data.replies.unwrap().len());
394
395        Ok(())
396    }
397
398    #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
399    #[tokio::test]
400    async fn test_comment_hot() -> Result<(), Box<BpiError>> {
401        let bpi = BpiClient::new().expect("client should build");
402        let root_rpid = 654321;
403
404        let result = bpi
405            .comment()
406            .hot(
407                CommentHotParams::new(CommentTarget::new(TEST_TYPE, TEST_OID)?, root_rpid)?
408                    .with_page(1)?
409                    .with_page_size(5)?,
410            )
411            .await?;
412        let data = result.ok_or_else(|| BpiError::unsupported_response("missing hot comments"))?;
413
414        info!("热评数量: {}", data.replies.len());
415        for comment in data.replies.iter() {
416            info!("热评内容: {}", comment.content.message);
417        }
418
419        Ok(())
420    }
421
422    #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
423    #[tokio::test]
424    async fn test_comment_count() -> Result<(), Box<BpiError>> {
425        let bpi = BpiClient::new().expect("client should build");
426
427        let result = bpi
428            .comment()
429            .count(CommentCountParams::new(CommentTarget::new(
430                TEST_TYPE, TEST_OID,
431            )?))
432            .await?;
433
434        let data = result;
435        info!("评论总数: {}", data.count);
436
437        Ok(())
438    }
439
440    #[test]
441    fn comment_target_rejects_invalid_identifiers() {
442        let type_err = CommentTarget::new(0, TEST_OID).unwrap_err();
443        assert!(matches!(
444            type_err,
445            BpiError::InvalidParameter { field: "type", .. }
446        ));
447
448        let oid_err = CommentTarget::new(TEST_TYPE, 0).unwrap_err();
449        assert!(matches!(
450            oid_err,
451            BpiError::InvalidParameter { field: "oid", .. }
452        ));
453    }
454
455    #[test]
456    fn comment_list_params_serializes_query() -> BpiResult<()> {
457        let params = CommentListParams::new(target()?)
458            .with_page(1)?
459            .with_page_size(5)?
460            .with_sort(CommentSort::Time)
461            .without_hot(false);
462
463        assert_eq!(
464            params.query_pairs(),
465            vec![
466                ("type", "1".to_string()),
467                ("oid", "23199".to_string()),
468                ("pn", "1".to_string()),
469                ("ps", "5".to_string()),
470                ("sort", "0".to_string()),
471                ("nohot", "0".to_string()),
472            ]
473        );
474        Ok(())
475    }
476
477    #[test]
478    fn comment_list_params_rejects_large_page_size() -> BpiResult<()> {
479        let err = CommentListParams::new(target()?)
480            .with_page_size(21)
481            .unwrap_err();
482
483        assert!(matches!(
484            err,
485            BpiError::InvalidParameter { field: "ps", .. }
486        ));
487        Ok(())
488    }
489
490    #[test]
491    fn comment_replies_params_serializes_query() -> BpiResult<()> {
492        let params = CommentRepliesParams::new(target()?, TEST_ROOT_RPID)?
493            .with_page(1)?
494            .with_page_size(5)?;
495
496        assert_eq!(
497            params.query_pairs(),
498            vec![
499                ("type", "1".to_string()),
500                ("oid", "23199".to_string()),
501                ("root", "2554491176".to_string()),
502                ("pn", "1".to_string()),
503                ("ps", "5".to_string()),
504            ]
505        );
506        Ok(())
507    }
508
509    #[test]
510    fn comment_hot_params_serializes_query() -> BpiResult<()> {
511        let params = CommentHotParams::new(target()?, TEST_ROOT_RPID)?
512            .with_page(1)?
513            .with_page_size(5)?;
514
515        assert_eq!(
516            params.query_pairs(),
517            vec![
518                ("type", "1".to_string()),
519                ("oid", "23199".to_string()),
520                ("root", "2554491176".to_string()),
521                ("pn", "1".to_string()),
522                ("ps", "5".to_string()),
523            ]
524        );
525        Ok(())
526    }
527
528    #[test]
529    fn comment_count_params_serializes_query() -> BpiResult<()> {
530        let params = CommentCountParams::new(target()?);
531
532        assert_eq!(
533            params.query_pairs(),
534            vec![("type", "1".to_string()), ("oid", "23199".to_string())]
535        );
536        Ok(())
537    }
538
539    #[test]
540    fn comment_read_contracts_match_endpoint_requests() -> BpiResult<()> {
541        let list = contract("list")?;
542        let list_params = CommentListParams::new(target()?)
543            .with_page(1)?
544            .with_page_size(5)?
545            .with_sort(CommentSort::Time)
546            .without_hot(false);
547        assert_eq!(list.name, "comment.read.list");
548        assert_eq!(list.request.method, HttpMethod::Get);
549        assert_eq!(
550            list.request.url.as_str(),
551            "https://api.bilibili.com/x/v2/reply"
552        );
553        assert_eq!(query_map(list_params.query_pairs()), list.request.query);
554
555        let replies = contract("replies")?;
556        let replies_params = CommentRepliesParams::new(target()?, TEST_ROOT_RPID)?
557            .with_page(1)?
558            .with_page_size(5)?;
559        assert_eq!(replies.name, "comment.read.replies");
560        assert_eq!(
561            replies.request.url.as_str(),
562            "https://api.bilibili.com/x/v2/reply/reply"
563        );
564        assert_eq!(
565            query_map(replies_params.query_pairs()),
566            replies.request.query
567        );
568
569        let hot = contract("hot")?;
570        let hot_params = CommentHotParams::new(target()?, TEST_ROOT_RPID)?
571            .with_page(1)?
572            .with_page_size(5)?;
573        assert_eq!(hot.name, "comment.read.hot");
574        assert_eq!(
575            hot.request.url.as_str(),
576            "https://api.bilibili.com/x/v2/reply/hot"
577        );
578        assert_eq!(query_map(hot_params.query_pairs()), hot.request.query);
579
580        let count = contract("count")?;
581        let count_params = CommentCountParams::new(target()?);
582        assert_eq!(count.name, "comment.read.count");
583        assert_eq!(
584            count.request.url.as_str(),
585            "https://api.bilibili.com/x/v2/reply/count"
586        );
587        assert_eq!(query_map(count_params.query_pairs()), count.request.query);
588        Ok(())
589    }
590
591    #[test]
592    fn comment_read_response_fixtures_parse_declared_models() -> BpiResult<()> {
593        for bytes in [
594            include_bytes!(
595                "../../tests/contracts/comment/read/list/responses/anonymous.success.json"
596            )
597            .as_slice(),
598            include_bytes!("../../tests/contracts/comment/read/list/responses/normal.success.json")
599                .as_slice(),
600            include_bytes!("../../tests/contracts/comment/read/list/responses/vip.success.json")
601                .as_slice(),
602            include_bytes!(
603                "../../tests/contracts/comment/read/replies/responses/anonymous.success.json"
604            )
605            .as_slice(),
606            include_bytes!(
607                "../../tests/contracts/comment/read/replies/responses/normal.success.json"
608            )
609            .as_slice(),
610            include_bytes!("../../tests/contracts/comment/read/replies/responses/vip.success.json")
611                .as_slice(),
612        ] {
613            let payload = ApiEnvelope::<CommentListData>::from_slice(bytes)?.into_payload()?;
614            assert!(payload.page.is_some());
615        }
616
617        for bytes in [
618            include_bytes!(
619                "../../tests/contracts/comment/read/count/responses/anonymous.success.json"
620            )
621            .as_slice(),
622            include_bytes!(
623                "../../tests/contracts/comment/read/count/responses/normal.success.json"
624            )
625            .as_slice(),
626            include_bytes!("../../tests/contracts/comment/read/count/responses/vip.success.json")
627                .as_slice(),
628        ] {
629            let payload = ApiEnvelope::<CountData>::from_slice(bytes)?.into_payload()?;
630            assert_eq!(payload.count, 10);
631        }
632
633        for bytes in [
634            include_bytes!(
635                "../../tests/contracts/comment/read/hot/responses/anonymous.success.json"
636            )
637            .as_slice(),
638            include_bytes!("../../tests/contracts/comment/read/hot/responses/normal.success.json")
639                .as_slice(),
640            include_bytes!("../../tests/contracts/comment/read/hot/responses/vip.success.json")
641                .as_slice(),
642        ] {
643            let payload =
644                ApiEnvelope::<HotCommentData>::from_slice(bytes)?.into_optional_payload()?;
645            assert!(payload.is_none());
646        }
647        Ok(())
648    }
649
650    fn local_probe_body(endpoint: &str, profile: &str) -> Option<serde_json::Value> {
651        let path = format!("target/bpi-probe-runs/comment/read/{endpoint}/{profile}.response.json");
652        let bytes = std::fs::read(path).ok()?;
653        let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
654        value
655            .get("response")
656            .and_then(|response| response.get("body"))
657            .cloned()
658    }
659
660    #[test]
661    fn comment_read_models_match_local_probe_outputs_when_available() -> BpiResult<()> {
662        for profile in ["anonymous", "normal", "vip"] {
663            for endpoint in ["list", "replies"] {
664                let Some(body) = local_probe_body(endpoint, profile) else {
665                    continue;
666                };
667                let payload =
668                    serde_json::from_value::<ApiEnvelope<CommentListData>>(body)?.into_payload()?;
669                assert!(payload.page.is_some());
670            }
671
672            let Some(count_body) = local_probe_body("count", profile) else {
673                continue;
674            };
675            let count =
676                serde_json::from_value::<ApiEnvelope<CountData>>(count_body)?.into_payload()?;
677            assert_eq!(count.count, 10);
678
679            let Some(hot_body) = local_probe_body("hot", profile) else {
680                continue;
681            };
682            let hot = serde_json::from_value::<ApiEnvelope<HotCommentData>>(hot_body)?
683                .into_optional_payload()?;
684            assert!(hot.is_none());
685        }
686        Ok(())
687    }
688}