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