bpi_rs/misc/sign/
bili_ticket.rs

1//! 用于生成 bili_ticket
2//!
3//! bili_ticket 位于请求头 Cookie 中, 非必需, 但存在可降低风控概率
4//! 是 JWT 令牌,有效时长为 259200 秒,即 3 天。
5//!
6//! https://github.com/SocialSisterYi/bilibili-API-collect/blob/master/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    /// 文档: https://github.com/SocialSisterYi/bilibili-API-collect/tree/master/docs/misc
44    pub async fn misc_sign_bili_ticket(&self) -> Result<BpiResponse<TicketData>, BpiError> {
45        let csrf = self.csrf()?;
46        // 获取当前时间戳
47        let timestamp = SystemTime::now()
48            .duration_since(UNIX_EPOCH)
49            .map_err(|e| BpiError::network(format!("获取时间戳失败: {}", e)))?
50            .as_secs();
51
52        // 计算 hexsign
53        let message = format!("ts{}", timestamp);
54        let hexsign = self.hmac_sha256("XgwSnGZ1p", &message)?;
55
56        // let now = Utc::now().timestamp().to_string();
57
58        // 构建请求参数
59        let params = [
60            ("key_id", "ec02"),
61            ("hexsign", &hexsign),
62            ("context[ts]", &timestamp.to_string()),
63            ("csrf", csrf.as_str()),
64        ];
65
66        // 发送请求
67        self.post("https://api.bilibili.com/bapis/bilibili.api.ticket.v1.Ticket/GenWebTicket")
68            .query(&params)
69            .send_bpi("生成bili_ticket")
70            .await
71    }
72
73    /// 仅获取 bili_ticket 字符串
74    pub async fn misc_sign_bili_ticket_string(&self) -> Result<String, BpiError> {
75        let resp = self.misc_sign_bili_ticket().await?;
76        let data = resp.data.ok_or_else(BpiError::missing_data)?;
77        Ok(data.ticket)
78    }
79
80    /// 使用 HMAC-SHA256 算法计算哈希
81    fn hmac_sha256(&self, key: &str, message: &str) -> Result<String, BpiError> {
82        let mut mac = HmacSha256::new_from_slice(key.as_bytes())
83            .map_err(|e| BpiError::parse(format!("HMAC 密钥错误: {}", e)))?;
84
85        mac.update(message.as_bytes());
86        let result = mac.finalize();
87        Ok(hex::encode(result.into_bytes()))
88    }
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94
95    #[tokio::test]
96    async fn test_hmac_sha256() {
97        let bpi = BpiClient::new();
98        let result = bpi.hmac_sha256("XgwSnGZ1p", "ts1234567890").unwrap();
99
100        // 验证结果是64位十六进制字符串
101        assert_eq!(result.len(), 64);
102        assert!(result.chars().all(|c| c.is_ascii_hexdigit()));
103        tracing::info!("HMAC-SHA256 测试通过: {}", result);
104    }
105
106    #[tokio::test]
107    async fn test_generate_bili_ticket() {
108        let bpi = BpiClient::new();
109
110        match bpi.misc_sign_bili_ticket().await {
111            Ok(resp) => {
112                if resp.code == 0 {
113                    let data = resp.data.unwrap();
114                    tracing::info!("Ticket: {}", data.ticket);
115                    tracing::info!("创建时间: {}", data.created_at);
116                    tracing::info!(
117                        "有效时长: {} 秒 ({:.1} 天)",
118                        data.ttl,
119                        (data.ttl as f64) / 86400.0
120                    );
121                    tracing::info!("WBI img: {}", data.nav.img);
122                    tracing::info!("WBI sub: {}", data.nav.sub);
123
124                    // 验证 ticket 是 JWT 格式
125                    assert!(data.ticket.contains('.'));
126                    assert!(data.ttl > 250000); // 大约 3 天
127                } else {
128                    panic!("API 返回错误: code={}, message={}", resp.code, resp.message);
129                }
130            }
131
132            Err(err) => {
133                panic!("生成 bili_ticket 失败: {}", err);
134            }
135        }
136    }
137
138    #[tokio::test]
139    async fn test_get_bili_ticket_string() {
140        let bpi = BpiClient::new();
141
142        match bpi.misc_sign_bili_ticket_string().await {
143            Ok(ticket) => {
144                tracing::info!("获取到的 bili_ticket: {}", ticket);
145
146                // 验证 ticket 格式
147                assert!(!ticket.is_empty());
148                assert!(ticket.contains('.'));
149
150                // JWT 应该有 3 部分(header.payload.signature)
151                let parts: Vec<&str> = ticket.split('.').collect();
152                assert_eq!(parts.len(), 3);
153            }
154            Err(err) => {
155                panic!("获取 bili_ticket 字符串失败: {}", err);
156            }
157        }
158    }
159
160    #[tokio::test]
161    async fn test_with_csrf() {
162        let bpi = BpiClient::new();
163
164        // 测试带 CSRF 的情况
165        match bpi.misc_sign_bili_ticket().await {
166            Ok(resp) => {
167                tracing::info!(
168                    "带 CSRF 的 bili_ticket 生成成功: {}",
169                    resp.data.unwrap().ticket
170                );
171            }
172            Err(err) => {
173                tracing::info!("带 CSRF 测试失败(预期可能失败): {}", err);
174                // 这里不 panic,因为没有真实的 CSRF token 可能会失败
175            }
176        }
177    }
178}
179
180// 使用示例文档
181impl BpiClient {
182    /// 使用示例:
183    ///
184    /// ```rust
185    /// use your_crate::{Bpi, BpiResult};
186    ///
187    /// async fn example() -> BpiResult<()> {
188    ///     let bpi = BpiClient::new();
189    ///
190    ///     // 方法1: 获取完整信息
191    ///     let ticket_response = bpi.generate_bili_ticket(None).await?;
192    ///     tracing::info!("bili_ticket: {}", ticket_data.ticket);
193    ///     tracing::info!("有效期: {} 秒", ticket_data.ttl);
194    ///
195    ///     // 方法2: 仅获取 ticket 字符串
196    ///     let ticket = bpi.get_bili_ticket_string(None).await?;
197    ///     tracing::info!("bili_ticket: {}", ticket);
198    ///
199    ///     // 方法3: 带 CSRF token
200    ///     let ticket_with_csrf = bpi.generate_bili_ticket(Some("your_bili_jct")).await?;
201    ///
202    ///     Ok(())
203    /// }
204    /// ```
205    pub fn _usage_example() {}
206}