Skip to main content

bpi_rs/creativecenter/
statistics_data.rs

1//! 创作中心统计数据 API
2//!
3//! [参考文档](https://github.com/Yuelioi/bilibili-API-collect/tree/cfc5fddcc8a94b74d91970bb5b4eaeb349addc47/docs/creativecenter/statistics&data.md)
4
5use serde::{Deserialize, Serialize};
6
7/// UP主视频状态数据
8#[derive(Debug, Serialize, Clone, Deserialize)]
9pub struct UpStatData {
10    /// 新增投币数
11    #[serde(rename = "inc_coin")]
12    pub inc_coin: i64,
13
14    /// 新增充电数
15    #[serde(rename = "inc_elec")]
16    pub inc_elec: i64,
17
18    /// 新增收藏数
19    #[serde(rename = "inc_fav")]
20    pub inc_fav: i64,
21
22    /// 新增点赞数
23    #[serde(rename = "inc_like")]
24    pub inc_like: i64,
25
26    /// 新增分享数
27    #[serde(rename = "inc_share")]
28    pub inc_share: i64,
29
30    /// 新增播放数
31    #[serde(rename = "incr_click")]
32    pub incr_click: i64,
33
34    /// 新增弹幕数
35    #[serde(rename = "incr_dm")]
36    pub incr_dm: i64,
37
38    /// 新增粉丝数
39    #[serde(rename = "incr_fans")]
40    pub incr_fans: i64,
41
42    /// 新增评论数
43    #[serde(rename = "incr_reply")]
44    pub incr_reply: i64,
45
46    /// 总计播放数
47    #[serde(rename = "total_click")]
48    pub total_click: i64,
49
50    /// 总计投币数
51    #[serde(rename = "total_coin")]
52    pub total_coin: i64,
53
54    /// 总计弹幕数
55    #[serde(rename = "total_dm")]
56    pub total_dm: i64,
57
58    /// 总计充电数
59    #[serde(rename = "total_elec")]
60    pub total_elec: i64,
61
62    /// 总计粉丝数
63    #[serde(rename = "total_fans")]
64    pub total_fans: i64,
65
66    /// 总计收藏数
67    #[serde(rename = "total_fav")]
68    pub total_fav: i64,
69
70    /// 总计点赞数
71    #[serde(rename = "total_like")]
72    pub total_like: i64,
73
74    /// 总计评论数
75    #[serde(rename = "total_reply")]
76    pub total_reply: i64,
77
78    /// 总计分享数
79    #[serde(rename = "total_share")]
80    pub total_share: i64,
81}
82
83/// 单个视频对比数据
84#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
85#[serde(rename_all = "camelCase")]
86pub struct ArchiveCompareItem {
87    /// av号
88    pub aid: i64,
89    /// bv号
90    pub bvid: String,
91    /// 封面 url
92    pub cover: String,
93    /// 标题
94    pub title: String,
95    /// 发布时间(秒级时间戳)
96    pub pubtime: i64,
97    /// 视频长度(秒)
98    pub duration: i64,
99    pub stat: Stat,
100    #[serde(rename = "is_only_self")]
101    pub is_only_self: bool,
102    #[serde(rename = "hour_stat")]
103    pub hour_stat: Option<HourStat>,
104}
105
106#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
107#[serde(rename_all = "camelCase")]
108pub struct Stat {
109    #[serde(rename = "not_ready_field")]
110    pub not_ready_field: serde_json::Value,
111    /// 播放数
112    pub play: i64,
113    pub vt: i64,
114    // ===== 百分比类指标,B站返回一般是整数,100 表示 1% =====
115    /// 完播比
116    #[serde(rename = "full_play_ratio")]
117    pub full_play_ratio: i64,
118    /// 游客播放数占比
119    #[serde(rename = "play_viewer_rate")]
120    pub play_viewer_rate: i64,
121    #[serde(rename = "play_viewer_rate_med")]
122    pub play_viewer_rate_med: i64,
123    /// 粉丝观看率
124    #[serde(rename = "play_fan_rate")]
125    pub play_fan_rate: i64,
126    #[serde(rename = "play_fan_rate_med")]
127    pub play_fan_rate_med: i64,
128    #[serde(rename = "active_fans_rate")]
129    pub active_fans_rate: i64,
130    #[serde(rename = "active_fans_med")]
131    pub active_fans_med: i64,
132    /// 封标点击率
133    #[serde(rename = "tm_rate")]
134    pub tm_rate: i64,
135    /// 自己平均封标点击率
136    #[serde(rename = "tm_rate_med")]
137    pub tm_rate_med: i64,
138    /// 同类up粉丝封标点击率
139    #[serde(rename = "tm_fan_simi_rate_med")]
140    pub tm_fan_simi_rate_med: i64,
141    /// 同类up游客封标点击率
142    #[serde(rename = "tm_viewer_simi_rate_med")]
143    pub tm_viewer_simi_rate_med: i64,
144    /// 粉丝封标点击率
145    #[serde(rename = "tm_fan_rate")]
146    pub tm_fan_rate: i64,
147    /// 游客封标点击率
148    #[serde(rename = "tm_viewer_rate")]
149    pub tm_viewer_rate: i64,
150    /// 封标点击率超过n%同类稿件
151    #[serde(rename = "tm_pass_rate")]
152    pub tm_pass_rate: i64,
153    /// 粉丝封标点击率超过n%同类稿件
154    #[serde(rename = "tm_fan_pass_rate")]
155    pub tm_fan_pass_rate: i64,
156    /// 游客封标点击率超过n%同类稿件
157    #[serde(rename = "tm_viewer_pass_rate")]
158    pub tm_viewer_pass_rate: i64,
159    /// 3秒退出率
160    #[serde(rename = "crash_rate")]
161    pub crash_rate: i64,
162    #[serde(rename = "crash_rate_med")]
163    pub crash_rate_med: i64,
164    /// 同类up粉丝3秒退出率
165    #[serde(rename = "crash_fan_simi_rate_med")]
166    pub crash_fan_simi_rate_med: i64,
167    /// 同类up游客3秒退出率
168    #[serde(rename = "crash_viewer_simi_rate_med")]
169    pub crash_viewer_simi_rate_med: i64,
170    /// 粉丝3秒退出率
171    #[serde(rename = "crash_fan_rate")]
172    pub crash_fan_rate: i64,
173    /// 游客3秒退出率
174    #[serde(rename = "crash_viewer_rate")]
175    pub crash_viewer_rate: i64,
176    /// 互动率
177    #[serde(rename = "interact_rate")]
178    pub interact_rate: i64,
179    #[serde(rename = "interact_rate_med")]
180    pub interact_rate_med: i64,
181    /// 同类up粉丝互动率
182    #[serde(rename = "interact_fan_simi_rate_med")]
183    pub interact_fan_simi_rate_med: i64,
184    /// 同类up游客互动率
185    #[serde(rename = "interact_viewer_simi_rate_med")]
186    pub interact_viewer_simi_rate_med: i64,
187    /// 粉丝互动率
188    #[serde(rename = "interact_fan_rate")]
189    pub interact_fan_rate: i64,
190    /// 游客互动率
191    #[serde(rename = "interact_viewer_rate")]
192    pub interact_viewer_rate: i64,
193    /// 平均播放时间(目前总是0)
194    #[serde(rename = "avg_play_time")]
195    pub avg_play_time: i64,
196    #[serde(rename = "avg_play_time_int")]
197    pub avg_play_time_int: i64,
198    /// 涨粉
199    #[serde(rename = "total_new_attention_cnt")]
200    pub total_new_attention_cnt: i64,
201    /// 播转粉率
202    #[serde(rename = "play_trans_fan_rate")]
203    pub play_trans_fan_rate: i64,
204    /// 其他up平均播转粉率
205    #[serde(rename = "play_trans_fan_rate_med")]
206    pub play_trans_fan_rate_med: i64,
207    /// 点赞数
208    pub like: i64,
209    /// 评论数
210    pub comment: i64,
211    /// 弹幕数
212    pub dm: i64,
213    /// 收藏数
214    pub fav: i64,
215    /// 投币数
216    pub coin: i64,
217    /// 分享数
218    pub share: i64,
219    #[serde(rename = "unfollow")]
220    pub unfollow: i64,
221    #[serde(rename = "tm_star")]
222    pub tm_star: i64,
223    #[serde(rename = "tm_viewer_star")]
224    pub tm_viewer_star: i64,
225    #[serde(rename = "tm_fan_star")]
226    pub tm_fan_star: i64,
227    #[serde(rename = "crash_p50")]
228    pub crash_p50: i64,
229    #[serde(rename = "crash_viewer_p50")]
230    pub crash_viewer_p50: i64,
231    #[serde(rename = "crash_fan_p50")]
232    pub crash_fan_p50: i64,
233    #[serde(rename = "interact_p50")]
234    pub interact_p50: i64,
235    #[serde(rename = "interact_viewer_p50")]
236    pub interact_viewer_p50: i64,
237    #[serde(rename = "interact_fan_p50")]
238    pub interact_fan_p50: i64,
239    #[serde(rename = "play_trans_fan_p50")]
240    pub play_trans_fan_p50: i64,
241}
242
243#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
244#[serde(rename_all = "camelCase")]
245pub struct HourStat {
246    #[serde(rename = "not_ready_field")]
247    pub not_ready_field: serde_json::Value,
248    /// 播放数
249    pub play: i64,
250    pub vt: i64,
251    /// 点赞数
252    pub like: i64,
253    /// 评论数
254    pub comment: i64,
255    /// 弹幕数
256    pub dm: i64,
257    /// 收藏数
258    pub fav: i64,
259    /// 投币数
260    pub coin: i64,
261    /// 分享数
262    pub share: i64,
263    /// 封标点击率超过n%同类稿件
264    #[serde(rename = "tm_pass_rate")]
265    pub tm_pass_rate: i64,
266    /// 互动率
267    #[serde(rename = "interact_rate")]
268    pub interact_rate: i64,
269    #[serde(rename = "tm_star")]
270    pub tm_star: i64,
271}
272
273/// UP主视频数据比较
274#[derive(Debug, Serialize, Clone, Deserialize)]
275pub struct ArchiveCompareData {
276    pub list: Vec<ArchiveCompareItem>,
277}
278
279/// UP主专栏状态数据
280#[derive(Debug, Serialize, Clone, Deserialize)]
281pub struct UpArticleStatData {
282    /// 总计阅读数
283    pub view: i64,
284    /// 总计评论数
285    pub reply: i64,
286    /// 总计点赞数
287    pub like: i64,
288    /// 总计投币数
289    pub coin: i64,
290    /// 总计收藏数
291    pub fav: i64,
292    /// 总计分享数
293    pub share: i64,
294    /// 新增阅读数
295    #[serde(rename = "incr_view")]
296    pub incr_view: i64,
297    /// 新增评论数
298    #[serde(rename = "incr_reply")]
299    pub incr_reply: i64,
300    /// 新增点赞数
301    #[serde(rename = "incr_like")]
302    pub incr_like: i64,
303    /// 新增投币数
304    #[serde(rename = "incr_coin")]
305    pub incr_coin: i64,
306    /// 新增收藏数
307    #[serde(rename = "incr_fav")]
308    pub incr_fav: i64,
309    /// 新增分享数
310    #[serde(rename = "incr_share")]
311    pub incr_share: i64,
312}
313
314/// UP主视频数据增量趋势项
315#[derive(Debug, Serialize, Clone, Deserialize)]
316pub struct VideoTrendItem {
317    /// 对应时间戳(前一天8:00)
318    pub date_key: i64,
319    /// 增加数量,数据类型决定
320    pub total_inc: i64,
321}
322
323/// UP主专栏数据增量趋势项
324#[derive(Debug, Serialize, Clone, Deserialize)]
325pub struct ArticleTrendItem {
326    /// 对应时间戳(前一天8:00)
327    pub date_key: i64,
328    /// 增加数量,数据类型决定
329    pub total_inc: i64,
330}
331
332/// 播放来源情况(播放方式)
333#[derive(Debug, Serialize, Clone, Deserialize)]
334pub struct PageSource {
335    /// 通过动态
336    pub dynamic: i64,
337    /// 其他方式
338    pub other: i64,
339    /// 通过推荐列表
340    #[serde(rename = "related_video")]
341    pub related_video: i64,
342    /// 通过搜索
343    pub search: i64,
344    /// 空间列表播放
345    pub space: i64,
346    /// 天马来源(APP推荐信息流)
347    pub tenma: i64,
348}
349
350/// 播放平台占比
351#[derive(Debug, Serialize, Clone, Deserialize)]
352pub struct PlayProportion {
353    /// 安卓端
354    pub android: i64,
355    /// 移动端H5
356    pub h5: i64,
357    /// iOS端
358    pub ios: i64,
359    /// 站外
360    pub out: i64,
361    /// PC网页版
362    pub pc: i64,
363}
364
365/// 播放来源占比数据
366#[derive(Debug, Serialize, Clone, Deserialize)]
367pub struct PlaySourceData {
368    pub page_source: PageSource,
369    pub play_proportion: PlayProportion,
370}
371
372/// 播放地区提示信息
373#[derive(Debug, Serialize, Clone, Deserialize)]
374pub struct Period {
375    pub module_one: Option<String>,
376    pub module_two: Option<String>,
377    pub module_three: Option<String>,
378    pub module_four: Option<String>,
379}
380
381/// 播放地区情况(粉丝或路人)
382pub type ViewerAreaMap = std::collections::HashMap<String, i64>;
383
384#[derive(Debug, Serialize, Clone, Deserialize)]
385pub struct ViewerArea {
386    pub fan: ViewerAreaMap,
387    pub not_fan: ViewerAreaMap,
388}
389
390/// 播放数据情况(粉丝或路人)
391#[derive(Debug, Serialize, Clone, Deserialize)]
392pub struct ViewerBaseDetail {
393    pub male: i64,
394    pub female: i64,
395    #[serde(rename = "age_one")]
396    pub age_one: i64,
397    #[serde(rename = "age_two")]
398    pub age_two: i64,
399    #[serde(rename = "age_three")]
400    pub age_three: i64,
401    #[serde(rename = "age_four")]
402    pub age_four: i64,
403    #[serde(rename = "plat_pc")]
404    pub plat_pc: i64,
405    #[serde(rename = "plat_h5")]
406    pub plat_h5: i64,
407    #[serde(rename = "plat_out")]
408    pub plat_out: i64,
409    #[serde(rename = "plat_ios")]
410    pub plat_ios: i64,
411    #[serde(rename = "plat_android")]
412    pub plat_android: i64,
413    #[serde(rename = "plat_other_app")]
414    pub plat_other_app: i64,
415}
416
417#[derive(Debug, Serialize, Clone, Deserialize)]
418pub struct ViewerBase {
419    pub fan: ViewerBaseDetail,
420    pub not_fan: ViewerBaseDetail,
421}
422
423/// 播放分布情况
424#[derive(Debug, Serialize, Clone, Deserialize)]
425pub struct ViewerData {
426    pub period: Period,
427    pub viewer_area: ViewerArea,
428    pub viewer_base: ViewerBase,
429}
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434    use crate::creativecenter::{
435        UpArchiveCompareParams, UpArticleTrendMetric, UpArticleTrendParams, UpVideoTrendMetric,
436        UpVideoTrendParams,
437    };
438    use crate::probe::contract::HttpMethod;
439    use crate::probe::endpoint_contract::EndpointContract;
440    use crate::{ApiEnvelope, BpiClient, BpiError};
441    use std::collections::BTreeMap;
442    use tracing::info;
443
444    fn contract(name: &str) -> Result<EndpointContract, BpiError> {
445        let bytes = match name {
446            "up-stat" => include_bytes!(
447                "../../tests/contracts/creativecenter/statistics/up-stat/contract.json"
448            )
449            .as_slice(),
450            "archive-compare" => include_bytes!(
451                "../../tests/contracts/creativecenter/statistics/archive-compare/contract.json"
452            )
453            .as_slice(),
454            "article-stat" => include_bytes!(
455                "../../tests/contracts/creativecenter/statistics/article-stat/contract.json"
456            )
457            .as_slice(),
458            "video-trend" => include_bytes!(
459                "../../tests/contracts/creativecenter/statistics/video-trend/contract.json"
460            )
461            .as_slice(),
462            "article-trend" => include_bytes!(
463                "../../tests/contracts/creativecenter/statistics/article-trend/contract.json"
464            )
465            .as_slice(),
466            "play-source" => include_bytes!(
467                "../../tests/contracts/creativecenter/statistics/play-source/contract.json"
468            )
469            .as_slice(),
470            "viewer-data" => include_bytes!(
471                "../../tests/contracts/creativecenter/statistics/viewer-data/contract.json"
472            )
473            .as_slice(),
474            _ => unreachable!("unknown creativecenter statistics contract"),
475        };
476        EndpointContract::from_slice(bytes)
477    }
478
479    fn query_map<I>(params: I) -> BTreeMap<String, String>
480    where
481        I: IntoIterator<Item = (&'static str, String)>,
482    {
483        params
484            .into_iter()
485            .map(|(key, value)| (key.to_string(), value))
486            .collect()
487    }
488
489    fn live_creativecenter_tests_enabled() -> bool {
490        std::env::var_os("BPI_LIVE_TEST").is_some() && std::env::var_os("BPI_COOKIE").is_some()
491    }
492
493    #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
494    #[tokio::test]
495    async fn test_up_stat() -> Result<(), Box<BpiError>> {
496        if !live_creativecenter_tests_enabled() {
497            return Ok(());
498        }
499
500        let bpi = BpiClient::new().expect("client should build");
501        let data = bpi.creativecenter().up_stat().await?;
502        info!("UP主视频状态数据: {:?}", data);
503        Ok(())
504    }
505
506    #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
507    #[tokio::test]
508    async fn test_archive_compare() -> Result<(), Box<BpiError>> {
509        if !live_creativecenter_tests_enabled() {
510            return Ok(());
511        }
512
513        let bpi = BpiClient::new().expect("client should build");
514        let params = UpArchiveCompareParams::new().with_size(3)?;
515        let data = bpi.creativecenter().archive_compare(params).await?;
516        info!("UP主视频数据比较: {:?}", data);
517        Ok(())
518    }
519
520    #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
521    #[tokio::test]
522    async fn test_up_article_stat() -> Result<(), Box<BpiError>> {
523        if !live_creativecenter_tests_enabled() {
524            return Ok(());
525        }
526
527        let bpi = BpiClient::new().expect("client should build");
528        let data = bpi.creativecenter().article_stat().await?;
529        info!("UP主专栏状态数据: {:?}", data);
530        Ok(())
531    }
532
533    #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
534    #[tokio::test]
535    async fn test_video_trend() -> Result<(), Box<BpiError>> {
536        if !live_creativecenter_tests_enabled() {
537            return Ok(());
538        }
539
540        let bpi = BpiClient::new().expect("client should build");
541        let params = UpVideoTrendParams::new(UpVideoTrendMetric::Play);
542        let data = bpi.creativecenter().video_trend(params).await?;
543        info!("UP主视频数据增量趋势: {:?}", data);
544        Ok(())
545    }
546
547    #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
548    #[tokio::test]
549    async fn test_article_trend() -> Result<(), Box<BpiError>> {
550        if !live_creativecenter_tests_enabled() {
551            return Ok(());
552        }
553
554        let bpi = BpiClient::new().expect("client should build");
555        let params = UpArticleTrendParams::new(UpArticleTrendMetric::Read);
556        let data = bpi.creativecenter().article_trend(params).await?;
557        info!("UP主专栏数据增量趋势: {:?}", data);
558        Ok(())
559    }
560
561    #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
562    #[tokio::test]
563    async fn test_viewer_data() -> Result<(), Box<BpiError>> {
564        if !live_creativecenter_tests_enabled() {
565            return Ok(());
566        }
567
568        let bpi = BpiClient::new().expect("client should build");
569        let data = bpi.creativecenter().viewer_data().await?;
570        info!("播放分布情况: {:?}", data);
571        Ok(())
572    }
573
574    #[test]
575    fn creativecenter_statistics_contracts_match_endpoint_requests() -> Result<(), BpiError> {
576        let up_stat = contract("up-stat")?;
577        assert_eq!(up_stat.name, "creativecenter.statistics.up_stat");
578        assert_eq!(up_stat.request.method, HttpMethod::Get);
579        assert_eq!(
580            up_stat.request.url.as_str(),
581            "https://member.bilibili.com/x/web/index/stat"
582        );
583        assert!(up_stat.request.query.is_empty());
584
585        let archive_compare = contract("archive-compare")?;
586        let archive_compare_params = UpArchiveCompareParams::new().with_size(3)?;
587        assert_eq!(
588            archive_compare.name,
589            "creativecenter.statistics.archive_compare"
590        );
591        assert_eq!(
592            archive_compare.request.url.as_str(),
593            "https://member.bilibili.com/x/web/data/archive_diagnose/compare"
594        );
595        assert_eq!(
596            query_map(archive_compare_params.query_pairs()),
597            archive_compare.request.query
598        );
599
600        let article_stat = contract("article-stat")?;
601        assert_eq!(
602            article_stat.request.url.as_str(),
603            "https://member.bilibili.com/x/web/data/article"
604        );
605        assert!(article_stat.request.query.is_empty());
606
607        let video_trend = contract("video-trend")?;
608        let video_trend_params = UpVideoTrendParams::new(UpVideoTrendMetric::Play);
609        assert_eq!(
610            video_trend.request.url.as_str(),
611            "https://member.bilibili.com/x/web/data/pandect"
612        );
613        assert_eq!(
614            query_map(video_trend_params.query_pairs()),
615            video_trend.request.query
616        );
617
618        let article_trend = contract("article-trend")?;
619        let article_trend_params = UpArticleTrendParams::new(UpArticleTrendMetric::Read);
620        assert_eq!(
621            article_trend.request.url.as_str(),
622            "https://member.bilibili.com/x/web/data/article/thirty"
623        );
624        assert_eq!(
625            query_map(article_trend_params.query_pairs()),
626            article_trend.request.query
627        );
628
629        let play_source = contract("play-source")?;
630        assert_eq!(
631            play_source.request.url.as_str(),
632            "https://member.bilibili.com/x/web/data/playsource"
633        );
634        assert_eq!(
635            play_source
636                .request
637                .headers
638                .get("Origin")
639                .map(String::as_str),
640            Some("https://www.bilibili.com")
641        );
642
643        let viewer_data = contract("viewer-data")?;
644        assert_eq!(
645            viewer_data.request.url.as_str(),
646            "https://member.bilibili.com/x/web/data/base"
647        );
648        assert!(viewer_data.request.query.is_empty());
649
650        Ok(())
651    }
652
653    #[test]
654    fn creativecenter_statistics_response_fixtures_parse_declared_models() -> Result<(), BpiError> {
655        for bytes in [
656            include_bytes!(
657                "../../tests/contracts/creativecenter/statistics/up-stat/responses/normal.success.json"
658            )
659            .as_slice(),
660            include_bytes!(
661                "../../tests/contracts/creativecenter/statistics/up-stat/responses/vip.success.json"
662            )
663            .as_slice(),
664        ] {
665            let payload = ApiEnvelope::<UpStatData>::from_slice(bytes)?.into_payload()?;
666            assert_eq!(payload.total_click, 0);
667        }
668
669        for bytes in [
670            include_bytes!(
671                "../../tests/contracts/creativecenter/statistics/archive-compare/responses/normal.success.json"
672            )
673            .as_slice(),
674            include_bytes!(
675                "../../tests/contracts/creativecenter/statistics/archive-compare/responses/vip.success.json"
676            )
677            .as_slice(),
678        ] {
679            let payload = ApiEnvelope::<ArchiveCompareData>::from_slice(bytes)?.into_payload()?;
680            assert_eq!(payload.list.len(), 1);
681        }
682
683        for bytes in [
684            include_bytes!(
685                "../../tests/contracts/creativecenter/statistics/article-stat/responses/normal.success.json"
686            )
687            .as_slice(),
688            include_bytes!(
689                "../../tests/contracts/creativecenter/statistics/article-stat/responses/vip.success.json"
690            )
691            .as_slice(),
692        ] {
693            let payload = ApiEnvelope::<UpArticleStatData>::from_slice(bytes)?.into_payload()?;
694            assert_eq!(payload.view, 0);
695        }
696
697        for bytes in [
698            include_bytes!(
699                "../../tests/contracts/creativecenter/statistics/video-trend/responses/normal.success.json"
700            )
701            .as_slice(),
702            include_bytes!(
703                "../../tests/contracts/creativecenter/statistics/video-trend/responses/vip.success.json"
704            )
705            .as_slice(),
706        ] {
707            let payload =
708                ApiEnvelope::<Vec<VideoTrendItem>>::from_slice(bytes)?.into_payload()?;
709            assert_eq!(payload.len(), 1);
710        }
711
712        let normal_article_trend = ApiEnvelope::<Vec<ArticleTrendItem>>::from_slice(include_bytes!(
713            "../../tests/contracts/creativecenter/statistics/article-trend/responses/normal.success.json"
714        ))?
715        .into_optional_payload()?;
716        assert!(normal_article_trend.is_none());
717
718        let vip_article_trend = ApiEnvelope::<Vec<ArticleTrendItem>>::from_slice(include_bytes!(
719            "../../tests/contracts/creativecenter/statistics/article-trend/responses/vip.success.json"
720        ))?
721        .into_payload()?;
722        assert_eq!(vip_article_trend.len(), 1);
723
724        for bytes in [
725            include_bytes!(
726                "../../tests/contracts/creativecenter/statistics/play-source/responses/normal.success.json"
727            )
728            .as_slice(),
729            include_bytes!(
730                "../../tests/contracts/creativecenter/statistics/play-source/responses/vip.success.json"
731            )
732            .as_slice(),
733        ] {
734            let payload = ApiEnvelope::<PlaySourceData>::from_slice(bytes)?.into_optional_payload()?;
735            assert!(payload.is_none());
736        }
737
738        for bytes in [
739            include_bytes!(
740                "../../tests/contracts/creativecenter/statistics/viewer-data/responses/normal.success.json"
741            )
742            .as_slice(),
743            include_bytes!(
744                "../../tests/contracts/creativecenter/statistics/viewer-data/responses/vip.success.json"
745            )
746            .as_slice(),
747        ] {
748            let payload = ApiEnvelope::<ViewerData>::from_slice(bytes)?.into_payload()?;
749            assert_eq!(payload.viewer_area.fan.get("<redacted>"), Some(&0));
750        }
751
752        Ok(())
753    }
754
755    #[test]
756    fn creativecenter_statistics_error_fixtures_preserve_observed_api_errors()
757    -> Result<(), BpiError> {
758        for bytes in [
759            include_bytes!(
760                "../../tests/contracts/creativecenter/statistics/up-stat/responses/anonymous.requires_login.json"
761            )
762            .as_slice(),
763            include_bytes!(
764                "../../tests/contracts/creativecenter/statistics/archive-compare/responses/anonymous.requires_login.json"
765            )
766            .as_slice(),
767            include_bytes!(
768                "../../tests/contracts/creativecenter/statistics/article-stat/responses/anonymous.requires_login.json"
769            )
770            .as_slice(),
771            include_bytes!(
772                "../../tests/contracts/creativecenter/statistics/video-trend/responses/anonymous.requires_login.json"
773            )
774            .as_slice(),
775            include_bytes!(
776                "../../tests/contracts/creativecenter/statistics/article-trend/responses/anonymous.requires_login.json"
777            )
778            .as_slice(),
779            include_bytes!(
780                "../../tests/contracts/creativecenter/statistics/play-source/responses/anonymous.requires_login.json"
781            )
782            .as_slice(),
783            include_bytes!(
784                "../../tests/contracts/creativecenter/statistics/viewer-data/responses/anonymous.requires_login.json"
785            )
786            .as_slice(),
787        ] {
788            let err = ApiEnvelope::<serde_json::Value>::from_slice(bytes)
789                .and_then(ApiEnvelope::ensure_success)
790                .unwrap_err();
791            assert!(err.requires_login());
792        }
793        Ok(())
794    }
795
796    fn local_probe_body(endpoint: &str, profile: &str) -> Option<serde_json::Value> {
797        let path = format!(
798            "target/bpi-probe-runs/creativecenter/statistics-read/{endpoint}/{profile}.response.json"
799        );
800        let bytes = std::fs::read(path).ok()?;
801        let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
802        value
803            .get("response")
804            .and_then(|response| response.get("body"))
805            .cloned()
806    }
807
808    #[test]
809    fn creativecenter_statistics_models_match_local_probe_outputs_when_available()
810    -> Result<(), BpiError> {
811        for profile in ["normal", "vip"] {
812            if let Some(body) = local_probe_body("up-stat", profile) {
813                let _payload =
814                    serde_json::from_value::<ApiEnvelope<UpStatData>>(body)?.into_payload()?;
815            }
816
817            if let Some(body) = local_probe_body("archive-compare", profile) {
818                let payload = serde_json::from_value::<ApiEnvelope<ArchiveCompareData>>(body)?
819                    .into_payload()?;
820                assert!(!payload.list.is_empty());
821            }
822
823            if let Some(body) = local_probe_body("article-stat", profile) {
824                let _payload = serde_json::from_value::<ApiEnvelope<UpArticleStatData>>(body)?
825                    .into_payload()?;
826            }
827
828            if let Some(body) = local_probe_body("video-trend", profile) {
829                let payload = serde_json::from_value::<ApiEnvelope<Vec<VideoTrendItem>>>(body)?
830                    .into_payload()?;
831                assert!(!payload.is_empty());
832            }
833
834            if let Some(body) = local_probe_body("article-trend", profile) {
835                let payload = serde_json::from_value::<ApiEnvelope<Vec<ArticleTrendItem>>>(body)?
836                    .into_optional_payload()?;
837                if profile == "vip" {
838                    assert!(payload.as_ref().is_some_and(|items| !items.is_empty()));
839                } else {
840                    assert!(payload.is_none());
841                }
842            }
843
844            if let Some(body) = local_probe_body("play-source", profile) {
845                let payload = serde_json::from_value::<ApiEnvelope<PlaySourceData>>(body)?
846                    .into_optional_payload()?;
847                assert!(payload.is_none());
848            }
849
850            if let Some(body) = local_probe_body("viewer-data", profile) {
851                let payload =
852                    serde_json::from_value::<ApiEnvelope<ViewerData>>(body)?.into_payload()?;
853                assert!(!payload.viewer_area.fan.is_empty());
854            }
855        }
856        Ok(())
857    }
858}