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}