1use std::collections::HashMap;
2use std::time::Duration;
3
4use reqwest::blocking::Client;
5use reqwest::header::{COOKIE, HeaderMap, HeaderValue, USER_AGENT};
6use serde_json::Value;
7
8const USER_AGENT_VALUE: &str =
9 "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124.0 Safari/537.36";
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) -> bool {
34 probe_web(cookie).unwrap_or(false) || probe_sso(cookie).unwrap_or(false)
35}
36
37fn client(cookie: &str) -> Result<Client, String> {
38 let mut headers = HeaderMap::new();
39 headers.insert(USER_AGENT, HeaderValue::from_static(USER_AGENT_VALUE));
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 .connect_timeout(Duration::from_secs(10))
48 .timeout(Duration::from_secs(30))
49 .build()
50 .map_err(|error| error.to_string())
51}
52
53fn probe_web(cookie: &str) -> Result<bool, String> {
54 let response = client(cookie)?
55 .get("https://www.douyin.com/aweme/v1/web/general/search/single/")
56 .query(&[
57 ("keyword", "抖音"),
58 ("offset", "0"),
59 ("count", "1"),
60 ("search_channel", "aweme_general"),
61 ])
62 .send()
63 .map_err(|error| error.to_string())?;
64 if !response.status().is_success() {
65 return Ok(false);
66 }
67 let body: Value = response.json().map_err(|error| error.to_string())?;
68 Ok(body.get("status_code").and_then(Value::as_i64) == Some(0) && !contains_verify_check(&body))
69}
70
71fn probe_sso(cookie: &str) -> Result<bool, String> {
72 let response = client(cookie)?
73 .get("https://sso.douyin.com/check_login/")
74 .send()
75 .map_err(|error| error.to_string())?;
76 if !response.status().is_success() {
77 return Ok(false);
78 }
79 let body: Value = response.json().map_err(|error| error.to_string())?;
80 Ok(body.get("has_login").and_then(Value::as_bool) == Some(true))
81}
82
83fn contains_verify_check(value: &Value) -> bool {
84 match value {
85 Value::Object(values) => values
86 .iter()
87 .any(|(key, value)| key == "verify_check" || contains_verify_check(value)),
88 Value::Array(values) => values.iter().any(contains_verify_check),
89 Value::String(value) => value == "verify_check",
90 _ => false,
91 }
92}
93
94#[cfg(test)]
95mod tests {
96 use super::{parse, validate};
97
98 #[test]
99 fn validates_compatible_cookie_fields() {
100 assert!(validate("sessionid=abc; ttwid=def"));
101 assert!(validate("TTWID=def"));
102 assert!(!validate("foo=bar"));
103 assert!(!validate("sessionid"));
104 }
105
106 #[test]
107 fn parses_values_containing_equals_signs() {
108 let values = parse("sessionid=a=b; ttwid=c");
109 assert_eq!(values["sessionid"], "a=b");
110 assert_eq!(values["ttwid"], "c");
111 }
112}