Skip to main content

douyin_cli/
cookie.rs

1use std::collections::HashMap;
2use std::time::Duration;
3
4use reqwest::blocking::Client;
5use reqwest::header::{CONTENT_TYPE, COOKIE, HeaderMap, HeaderValue, USER_AGENT};
6use reqwest::redirect::Policy;
7use serde_json::Value;
8
9const USER_AGENT_VALUE: &str =
10    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124.0 Safari/537.36";
11
12pub fn validate(cookie: &str) -> bool {
13    let cookie = cookie.trim();
14    if cookie.is_empty() || !cookie.contains('=') {
15        return false;
16    }
17    parse(cookie)
18        .keys()
19        .any(|key| key.eq_ignore_ascii_case("sessionid") || key.eq_ignore_ascii_case("ttwid"))
20}
21
22pub fn parse(cookie: &str) -> HashMap<String, String> {
23    cookie
24        .split(';')
25        .filter_map(|item| {
26            let (key, value) = item.trim().split_once('=')?;
27            let key = key.trim();
28            let value = value.trim();
29            (!key.is_empty() && !value.is_empty()).then(|| (key.to_owned(), value.to_owned()))
30        })
31        .collect()
32}
33
34pub fn probe(cookie: &str) -> Result<bool, String> {
35    probe_sso(cookie)
36}
37
38fn client(cookie: &str) -> Result<Client, String> {
39    let mut headers = HeaderMap::new();
40    headers.insert(USER_AGENT, HeaderValue::from_static(USER_AGENT_VALUE));
41    headers.insert(
42        COOKIE,
43        HeaderValue::from_str(cookie)
44            .map_err(|error| format!("Cookie 无法作为 HTTP 请求头: {error}"))?,
45    );
46    Client::builder()
47        .default_headers(headers)
48        .redirect(Policy::none())
49        .connect_timeout(Duration::from_secs(10))
50        .timeout(Duration::from_secs(30))
51        .build()
52        .map_err(|error| format!("创建网页登录态检查客户端失败: {error}"))
53}
54
55fn probe_sso(cookie: &str) -> Result<bool, String> {
56    let response = client(cookie)?
57        .get("https://sso.douyin.com/check_login/")
58        .send()
59        .map_err(|error| format!("发送网页登录态检查请求失败: {error}"))?;
60    if !response.status().is_success() {
61        return Err(format!(
62            "网页登录态检查返回 HTTP 状态 {}",
63            response.status()
64        ));
65    }
66    let content_type = response
67        .headers()
68        .get(CONTENT_TYPE)
69        .and_then(|value| value.to_str().ok())
70        .map(str::to_owned);
71    let body = response
72        .bytes()
73        .map_err(|error| format!("读取网页登录态检查响应失败: {error}"))?;
74    parse_login_probe_response(&body, content_type.as_deref())
75}
76
77fn parse_login_probe_response(body: &[u8], content_type: Option<&str>) -> Result<bool, String> {
78    let content_type = safe_content_type(content_type);
79    let body: Value = serde_json::from_slice(body).map_err(|_| {
80        format!(
81            "网页登录态检查返回非 JSON 内容(Content-Type: {content_type}),可能遇到验证码、风控或上游接口变化"
82        )
83    })?;
84    body.get("has_login")
85        .and_then(Value::as_bool)
86        .ok_or_else(|| {
87            format!(
88                "网页登录态检查返回无法识别的 JSON 结构(Content-Type: {content_type}),可能是上游接口变化"
89            )
90        })
91}
92
93fn safe_content_type(content_type: Option<&str>) -> String {
94    let value: String = content_type
95        .unwrap_or("unknown")
96        .chars()
97        .filter(|value| {
98            value.is_ascii_alphanumeric()
99                || matches!(value, '/' | '+' | '-' | '.' | ';' | '=' | ' ')
100        })
101        .take(80)
102        .collect();
103    if value.trim().is_empty() {
104        "unknown".to_owned()
105    } else {
106        value
107    }
108}
109
110#[cfg(test)]
111mod tests {
112    use super::{parse, parse_login_probe_response, validate};
113
114    #[test]
115    fn validates_compatible_cookie_fields() {
116        assert!(validate("sessionid=abc; ttwid=def"));
117        assert!(validate("TTWID=def"));
118        assert!(!validate("foo=bar"));
119        assert!(!validate("sessionid"));
120    }
121
122    #[test]
123    fn parses_values_containing_equals_signs() {
124        let values = parse("sessionid=a=b; ttwid=c");
125        assert_eq!(values["sessionid"], "a=b");
126        assert_eq!(values["ttwid"], "c");
127    }
128
129    #[test]
130    fn login_probe_accepts_logged_in_json() {
131        assert_eq!(
132            parse_login_probe_response(br#"{"has_login":true}"#, Some("application/json")),
133            Ok(true)
134        );
135    }
136
137    #[test]
138    fn login_probe_accepts_logged_out_json() {
139        assert_eq!(
140            parse_login_probe_response(br#"{"has_login":false}"#, Some("application/json")),
141            Ok(false)
142        );
143    }
144
145    #[test]
146    fn login_probe_rejects_anonymous_search_payload() {
147        let result =
148            parse_login_probe_response(br#"{"status_code":0,"data":[]}"#, Some("application/json"));
149        assert!(result.is_err());
150    }
151
152    #[test]
153    fn login_probe_reports_html_without_echoing_body() {
154        let unique_body = "<html>UNIQUE_PRIVATE_RESPONSE_BODY</html>";
155        let error =
156            parse_login_probe_response(unique_body.as_bytes(), Some("text/html; charset=utf-8"))
157                .expect_err("HTML must not be accepted as a login response");
158        assert!(
159            error.contains("text/html; charset=utf-8")
160                && error.contains("验证码、风控或上游接口变化")
161                && !error.contains("UNIQUE_PRIVATE_RESPONSE_BODY")
162        );
163    }
164}