douyin-cli 2026.8.23

A Rust CLI for Douyin OpenAPI and web workflows
Documentation
use std::collections::HashMap;
use std::time::Duration;

use reqwest::blocking::Client;
use reqwest::header::{CONTENT_TYPE, COOKIE, HeaderMap, HeaderValue, USER_AGENT};
use reqwest::redirect::Policy;
use serde_json::Value;

use crate::net::DEFAULT_USER_AGENT;

pub fn validate(cookie: &str) -> bool {
    let cookie = cookie.trim();
    if cookie.is_empty() || !cookie.contains('=') {
        return false;
    }
    parse(cookie)
        .keys()
        .any(|key| key.eq_ignore_ascii_case("sessionid") || key.eq_ignore_ascii_case("ttwid"))
}

pub fn parse(cookie: &str) -> HashMap<String, String> {
    cookie
        .split(';')
        .filter_map(|item| {
            let (key, value) = item.trim().split_once('=')?;
            let key = key.trim();
            let value = value.trim();
            (!key.is_empty() && !value.is_empty()).then(|| (key.to_owned(), value.to_owned()))
        })
        .collect()
}

pub fn probe(cookie: &str) -> Result<bool, String> {
    probe_sso(cookie)
}

fn client(cookie: &str) -> Result<Client, String> {
    let mut headers = HeaderMap::new();
    headers.insert(USER_AGENT, HeaderValue::from_static(DEFAULT_USER_AGENT));
    headers.insert(
        COOKIE,
        HeaderValue::from_str(cookie)
            .map_err(|error| format!("Cookie 无法作为 HTTP 请求头: {error}"))?,
    );
    Client::builder()
        .default_headers(headers)
        .redirect(Policy::none())
        .connect_timeout(Duration::from_secs(10))
        .timeout(Duration::from_secs(30))
        .build()
        .map_err(|error| format!("创建网页登录态检查客户端失败: {error}"))
}

fn probe_sso(cookie: &str) -> Result<bool, String> {
    let response = client(cookie)?
        .get("https://sso.douyin.com/check_login/")
        .send()
        .map_err(|error| format!("发送网页登录态检查请求失败: {error}"))?;
    if !response.status().is_success() {
        return Err(format!(
            "网页登录态检查返回 HTTP 状态 {}",
            response.status()
        ));
    }
    let content_type = response
        .headers()
        .get(CONTENT_TYPE)
        .and_then(|value| value.to_str().ok())
        .map(str::to_owned);
    let body = response
        .bytes()
        .map_err(|error| format!("读取网页登录态检查响应失败: {error}"))?;
    parse_login_probe_response(&body, content_type.as_deref())
}

fn parse_login_probe_response(body: &[u8], content_type: Option<&str>) -> Result<bool, String> {
    let content_type = safe_content_type(content_type);
    let body: Value = serde_json::from_slice(body).map_err(|_| {
        format!(
            "网页登录态检查返回非 JSON 内容(Content-Type: {content_type}),可能遇到验证码、风控或上游接口变化"
        )
    })?;
    body.get("has_login")
        .and_then(Value::as_bool)
        .ok_or_else(|| {
            format!(
                "网页登录态检查返回无法识别的 JSON 结构(Content-Type: {content_type}),可能是上游接口变化"
            )
        })
}

fn safe_content_type(content_type: Option<&str>) -> String {
    let value: String = content_type
        .unwrap_or("unknown")
        .chars()
        .filter(|value| {
            value.is_ascii_alphanumeric()
                || matches!(value, '/' | '+' | '-' | '.' | ';' | '=' | ' ')
        })
        .take(80)
        .collect();
    if value.trim().is_empty() {
        "unknown".to_owned()
    } else {
        value
    }
}

#[cfg(test)]
mod tests {
    use super::{parse, parse_login_probe_response, validate};

    #[test]
    fn validates_compatible_cookie_fields() {
        assert!(validate("sessionid=abc; ttwid=def"));
        assert!(validate("TTWID=def"));
        assert!(!validate("foo=bar"));
        assert!(!validate("sessionid"));
    }

    #[test]
    fn parses_values_containing_equals_signs() {
        let values = parse("sessionid=a=b; ttwid=c");
        assert_eq!(values["sessionid"], "a=b");
        assert_eq!(values["ttwid"], "c");
    }

    #[test]
    fn login_probe_accepts_logged_in_json() {
        assert_eq!(
            parse_login_probe_response(br#"{"has_login":true}"#, Some("application/json")),
            Ok(true)
        );
    }

    #[test]
    fn login_probe_accepts_logged_out_json() {
        assert_eq!(
            parse_login_probe_response(br#"{"has_login":false}"#, Some("application/json")),
            Ok(false)
        );
    }

    #[test]
    fn login_probe_rejects_anonymous_search_payload() {
        let result =
            parse_login_probe_response(br#"{"status_code":0,"data":[]}"#, Some("application/json"));
        assert!(result.is_err());
    }

    #[test]
    fn login_probe_reports_html_without_echoing_body() {
        let unique_body = "<html>UNIQUE_PRIVATE_RESPONSE_BODY</html>";
        let error =
            parse_login_probe_response(unique_body.as_bytes(), Some("text/html; charset=utf-8"))
                .expect_err("HTML must not be accepted as a login response");
        assert!(
            error.contains("text/html; charset=utf-8")
                && error.contains("验证码、风控或上游接口变化")
                && !error.contains("UNIQUE_PRIVATE_RESPONSE_BODY")
        );
    }
}