Skip to main content

bpi_rs/misc/
b23tv.rs

1//! 用于生成 b23.tv 短链
2//!
3//! [查看 API 文档](https://github.com/Yuelioi/bilibili-API-collect/tree/cfc5fddcc8a94b74d91970bb5b4eaeb349addc47/docs/misc/b23tv.md)
4
5use serde::{Deserialize, Serialize};
6
7#[cfg(test)]
8const B23_SHORT_LINK_ENDPOINT: &str = "https://api.biliapi.net/x/share/click";
9
10/// 生成 b23.tv 短链 - 响应数据
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12pub struct ShortLinkData {
13    /// 原始返回内容(标题 + 短链)
14    pub content: String,
15
16    /// 恒为 0
17    pub count: i32,
18
19    /// 纯短链 URL
20    #[serde(skip_serializing, skip_deserializing)]
21    pub link: String,
22    /// 标题
23    #[serde(skip_serializing, skip_deserializing)]
24    pub title: String,
25}
26
27impl ShortLinkData {
28    pub fn extract(&mut self) {
29        if let Some(pos) = self.content.find("https://b23.tv/") {
30            self.link = self.content[pos..].to_string().trim().to_string();
31            self.title = self.content[..pos].trim().to_string();
32        } else {
33            self.link = String::new();
34            self.title = self.content.clone();
35        }
36    }
37}
38
39#[cfg(test)]
40mod tests {
41    use super::*;
42    use crate::ApiEnvelope;
43    use crate::BpiError;
44    use crate::ids::Aid;
45    use crate::misc::MiscB23ShortLinkParams;
46    use crate::probe::contract::HttpMethod;
47    use crate::probe::endpoint_contract::EndpointContract;
48
49    fn local_b23_short_link_probe_body(profile: &str) -> Option<serde_json::Value> {
50        let path = format!("target/bpi-probe-runs/misc/b23tv/short-link/{profile}.response.json");
51        let bytes = std::fs::read(path).ok()?;
52        let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
53        value
54            .get("response")
55            .and_then(|response| response.get("body"))
56            .cloned()
57    }
58
59    #[test]
60    fn misc_b23_short_link_contract_matches_endpoint_request() -> Result<(), BpiError> {
61        let contract = EndpointContract::from_slice(include_bytes!(
62            "../../tests/contracts/misc/b23tv/short-link/contract.json"
63        ))?;
64
65        assert_eq!(contract.name, "misc.b23tv.short_link");
66        assert_eq!(contract.request.method, HttpMethod::Post);
67        assert_eq!(contract.request.url.as_str(), B23_SHORT_LINK_ENDPOINT);
68        assert!(contract.request.query.is_empty());
69
70        let form = contract
71            .request
72            .form
73            .as_ref()
74            .ok_or_else(|| BpiError::unsupported_response("missing b23tv contract form"))?;
75        assert_eq!(form.get("platform").map(String::as_str), Some("unix"));
76        assert_eq!(form.get("share_channel").map(String::as_str), Some("COPY"));
77        assert_eq!(
78            form.get("share_id").map(String::as_str),
79            Some("main.ugc-video-detail.0.0.pv")
80        );
81        assert_eq!(form.get("share_mode").map(String::as_str), Some("4"));
82        assert_eq!(form.get("oid").map(String::as_str), Some("10001"));
83        assert_eq!(form.get("buvid").map(String::as_str), Some("qwq"));
84        assert_eq!(form.get("build").map(String::as_str), Some("6114514"));
85        assert_eq!(contract.cases.len(), 3);
86        Ok(())
87    }
88
89    #[test]
90    fn misc_b23_short_link_contract_covers_profiles() -> Result<(), BpiError> {
91        let contract = EndpointContract::from_slice(include_bytes!(
92            "../../tests/contracts/misc/b23tv/short-link/contract.json"
93        ))?;
94
95        let anonymous = &contract.cases[0];
96        assert_eq!(anonymous.profile.as_deref(), Some("anonymous"));
97        assert!(!anonymous.auth.requires_cookie());
98
99        for case in &contract.cases[1..] {
100            assert!(matches!(case.name.as_str(), "normal" | "vip"));
101            assert!(case.auth.requires_cookie());
102            assert_eq!(case.response.api_code, Some(0));
103            assert_eq!(case.response.rust_model.as_deref(), Some("ShortLinkData"));
104        }
105        Ok(())
106    }
107
108    #[test]
109    fn misc_b23_short_link_response_fixture_parses_declared_model() -> Result<(), BpiError> {
110        let mut data = ApiEnvelope::<ShortLinkData>::from_slice(include_bytes!(
111            "../../tests/contracts/misc/b23tv/short-link/responses/success.json"
112        ))?
113        .into_payload()?;
114
115        data.extract();
116
117        assert_eq!(data.count, 0);
118        assert_eq!(data.title, "sanitized-title");
119        assert_eq!(data.link, "https://b23.tv/sanitized");
120        Ok(())
121    }
122
123    #[test]
124    fn misc_b23_short_link_model_matches_local_probe_outputs_when_available() -> Result<(), BpiError>
125    {
126        for profile in ["anonymous", "normal", "vip"] {
127            let Some(body) = local_b23_short_link_probe_body(profile) else {
128                continue;
129            };
130
131            let mut data =
132                serde_json::from_value::<ApiEnvelope<ShortLinkData>>(body)?.into_payload()?;
133            data.extract();
134
135            assert_eq!(data.count, 0);
136            assert!(data.link.starts_with("https://b23.tv/"));
137            assert!(!data.title.trim().is_empty());
138        }
139        Ok(())
140    }
141
142    #[test]
143    fn misc_b23_short_link_params_serializes_default_form() -> Result<(), BpiError> {
144        let params = MiscB23ShortLinkParams::new(Aid::new(10001)?);
145
146        assert_eq!(
147            params.form_pairs(),
148            [
149                ("platform", "unix".to_string()),
150                ("share_channel", "COPY".to_string()),
151                ("share_id", "main.ugc-video-detail.0.0.pv".to_string()),
152                ("share_mode", "4".to_string()),
153                ("oid", "10001".to_string()),
154                ("buvid", "qwq".to_string()),
155                ("build", "6114514".to_string()),
156            ]
157        );
158        Ok(())
159    }
160
161    #[test]
162    fn misc_b23_short_link_params_serializes_custom_form() -> Result<(), BpiError> {
163        let params = MiscB23ShortLinkParams::new(Aid::new(10001)?)
164            .with_platform("web")?
165            .with_share_channel("WEIXIN")?
166            .with_share_id("custom.share.id")?
167            .with_share_mode(5)
168            .with_buvid("custom-buvid")?
169            .with_build(123456);
170
171        assert_eq!(
172            params.form_pairs(),
173            [
174                ("platform", "web".to_string()),
175                ("share_channel", "WEIXIN".to_string()),
176                ("share_id", "custom.share.id".to_string()),
177                ("share_mode", "5".to_string()),
178                ("oid", "10001".to_string()),
179                ("buvid", "custom-buvid".to_string()),
180                ("build", "123456".to_string()),
181            ]
182        );
183        Ok(())
184    }
185
186    #[test]
187    fn misc_b23_short_link_params_rejects_blank_buvid() -> Result<(), BpiError> {
188        let err = MiscB23ShortLinkParams::new(Aid::new(10001)?)
189            .with_buvid("   ")
190            .unwrap_err();
191
192        assert!(matches!(
193            err,
194            BpiError::InvalidParameter { field: "buvid", .. }
195        ));
196        Ok(())
197    }
198}