douyin-cli 2026.7.19

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::{COOKIE, HeaderMap, HeaderValue, USER_AGENT};
use serde_json::Value;

const USER_AGENT_VALUE: &str =
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124.0 Safari/537.36";

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) -> bool {
    probe_web(cookie).unwrap_or(false) || probe_sso(cookie).unwrap_or(false)
}

fn client(cookie: &str) -> Result<Client, String> {
    let mut headers = HeaderMap::new();
    headers.insert(USER_AGENT, HeaderValue::from_static(USER_AGENT_VALUE));
    headers.insert(
        COOKIE,
        HeaderValue::from_str(cookie)
            .map_err(|error| format!("Cookie 无法作为 HTTP 请求头: {error}"))?,
    );
    Client::builder()
        .default_headers(headers)
        .connect_timeout(Duration::from_secs(10))
        .timeout(Duration::from_secs(30))
        .build()
        .map_err(|error| error.to_string())
}

fn probe_web(cookie: &str) -> Result<bool, String> {
    let response = client(cookie)?
        .get("https://www.douyin.com/aweme/v1/web/general/search/single/")
        .query(&[
            ("keyword", "抖音"),
            ("offset", "0"),
            ("count", "1"),
            ("search_channel", "aweme_general"),
        ])
        .send()
        .map_err(|error| error.to_string())?;
    if !response.status().is_success() {
        return Ok(false);
    }
    let body: Value = response.json().map_err(|error| error.to_string())?;
    Ok(body.get("status_code").and_then(Value::as_i64) == Some(0) && !contains_verify_check(&body))
}

fn probe_sso(cookie: &str) -> Result<bool, String> {
    let response = client(cookie)?
        .get("https://sso.douyin.com/check_login/")
        .send()
        .map_err(|error| error.to_string())?;
    if !response.status().is_success() {
        return Ok(false);
    }
    let body: Value = response.json().map_err(|error| error.to_string())?;
    Ok(body.get("has_login").and_then(Value::as_bool) == Some(true))
}

fn contains_verify_check(value: &Value) -> bool {
    match value {
        Value::Object(values) => values
            .iter()
            .any(|(key, value)| key == "verify_check" || contains_verify_check(value)),
        Value::Array(values) => values.iter().any(contains_verify_check),
        Value::String(value) => value == "verify_check",
        _ => false,
    }
}

#[cfg(test)]
mod tests {
    use super::{parse, 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");
    }
}