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}