Skip to main content

bpi_rs/misc/sign/
bili_ticket.rs

1//! 用于生成 bili_ticket
2//!
3//! bili_ticket 位于请求头 Cookie 中, 非必需, 但存在可降低风控概率
4//! 是 JWT 令牌,有效时长为 259200 秒,即 3 天。
5//!
6//! [查看 API 文档](https://github.com/Yuelioi/bilibili-API-collect/tree/cfc5fddcc8a94b74d91970bb5b4eaeb349addc47/docs/misc/sign/bili_ticket.md)
7
8use crate::{ BilibiliRequest, BpiClient, BpiError, BpiResponse };
9use hmac::{ Hmac, Mac };
10use serde::{ Deserialize, Serialize };
11use sha2::Sha256;
12use std::time::{ SystemTime, UNIX_EPOCH };
13
14type HmacSha256 = Hmac<Sha256>;
15
16/// bili_ticket 响应数据
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct TicketData {
19    /// bili_ticket JWT 令牌
20    pub ticket: String,
21    /// 创建时间 UNIX 秒级时间戳
22    pub created_at: i64,
23    /// 有效时长 259200 秒 (3 天)
24    pub ttl: i32,
25    /// 空对象
26    pub context: serde_json::Value,
27    /// WBI 相关信息
28    pub nav: NavData,
29}
30
31/// WBI 导航数据
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct NavData {
34    /// img_key 值
35    pub img: String,
36    /// sub_key 值
37    pub sub: String,
38}
39
40impl BpiClient {
41    /// 生成 bili_ticket
42    ///
43    /// # 文档
44    /// [查看API文档](https://github.com/SocialSisterYi/bilibili-API-collect/tree/master/docs/misc)
45    pub async fn misc_sign_bili_ticket(&self) -> Result<BpiResponse<TicketData>, BpiError> {
46        let csrf = self.csrf()?;
47        // 获取当前时间戳
48        let timestamp = SystemTime::now()
49            .duration_since(UNIX_EPOCH)
50            .map_err(|e| BpiError::network(format!("获取时间戳失败: {}", e)))?
51            .as_secs();
52
53        // 计算 hexsign
54        let message = format!("ts{}", timestamp);
55        let hexsign = self.hmac_sha256("XgwSnGZ1p", &message)?;
56
57        // let now = Utc::now().timestamp().to_string();
58
59        // 构建请求参数
60        let params = [
61            ("key_id", "ec02"),
62            ("hexsign", &hexsign),
63            ("context[ts]", &timestamp.to_string()),
64            ("csrf", csrf.as_str()),
65        ];
66
67        // 发送请求
68        self
69            .post("https://api.bilibili.com/bapis/bilibili.api.ticket.v1.Ticket/GenWebTicket")
70            .query(&params)
71            .send_bpi("生成bili_ticket").await
72    }
73
74    /// 仅获取 bili_ticket 字符串
75    pub async fn misc_sign_bili_ticket_string(&self) -> Result<String, BpiError> {
76        let resp = self.misc_sign_bili_ticket().await?;
77        let data = resp.data.ok_or_else(BpiError::missing_data)?;
78        Ok(data.ticket)
79    }
80
81    /// 使用 HMAC-SHA256 算法计算哈希
82    fn hmac_sha256(&self, key: &str, message: &str) -> Result<String, BpiError> {
83        let mut mac = HmacSha256::new_from_slice(key.as_bytes()).map_err(|e|
84            BpiError::parse(format!("HMAC 密钥错误: {}", e))
85        )?;
86
87        mac.update(message.as_bytes());
88        let result = mac.finalize();
89        Ok(hex::encode(result.into_bytes()))
90    }
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96
97    #[tokio::test]
98    async fn test_hmac_sha256() {
99        let bpi = BpiClient::new();
100        let result = bpi.hmac_sha256("XgwSnGZ1p", "ts1234567890").unwrap();
101
102        // 验证结果是64位十六进制字符串
103        assert_eq!(result.len(), 64);
104        assert!(result.chars().all(|c| c.is_ascii_hexdigit()));
105        tracing::info!("HMAC-SHA256 测试通过: {}", result);
106    }
107
108    #[tokio::test]
109    async fn test_generate_bili_ticket() {
110        let bpi = BpiClient::new();
111
112        match bpi.misc_sign_bili_ticket().await {
113            Ok(resp) => {
114                if resp.code == 0 {
115                    let data = resp.data.unwrap();
116                    tracing::info!("Ticket: {}", data.ticket);
117                    tracing::info!("创建时间: {}", data.created_at);
118                    tracing::info!(
119                        "有效时长: {} 秒 ({:.1} 天)",
120                        data.ttl,
121                        (data.ttl as f64) / 86400.0
122                    );
123                    tracing::info!("WBI img: {}", data.nav.img);
124                    tracing::info!("WBI sub: {}", data.nav.sub);
125
126                    // 验证 ticket 是 JWT 格式
127                    assert!(data.ticket.contains('.'));
128                    assert!(data.ttl > 250000); // 大约 3 天
129                } else {
130                    panic!("API 返回错误: code={}, message={}", resp.code, resp.message);
131                }
132            }
133
134            Err(err) => {
135                panic!("生成 bili_ticket 失败: {}", err);
136            }
137        }
138    }
139
140    #[tokio::test]
141    async fn test_get_bili_ticket_string() {
142        let bpi = BpiClient::new();
143
144        match bpi.misc_sign_bili_ticket_string().await {
145            Ok(ticket) => {
146                tracing::info!("获取到的 bili_ticket: {}", ticket);
147
148                // 验证 ticket 格式
149                assert!(!ticket.is_empty());
150                assert!(ticket.contains('.'));
151
152                // JWT 应该有 3 部分(header.payload.signature)
153                let parts: Vec<&str> = ticket.split('.').collect();
154                assert_eq!(parts.len(), 3);
155            }
156            Err(err) => {
157                panic!("获取 bili_ticket 字符串失败: {}", err);
158            }
159        }
160    }
161
162    #[tokio::test]
163    async fn test_with_csrf() {
164        let bpi = BpiClient::new();
165
166        // 测试带 CSRF 的情况
167        match bpi.misc_sign_bili_ticket().await {
168            Ok(resp) => {
169                tracing::info!("带 CSRF 的 bili_ticket 生成成功: {}", resp.data.unwrap().ticket);
170            }
171            Err(err) => {
172                tracing::info!("带 CSRF 测试失败(预期可能失败): {}", err);
173                // 这里不 panic,因为没有真实的 CSRF token 可能会失败
174            }
175        }
176    }
177}