Skip to main content

douyin_cli/
crawler.rs

1use std::collections::HashMap;
2use std::fs::{self, File};
3use std::io::{self, Write};
4use std::path::{Path, PathBuf};
5use std::time::Duration;
6
7use clap::{Args, ValueEnum};
8use percent_encoding::percent_decode_str;
9use reqwest::blocking::Client;
10use reqwest::header::{
11    ACCEPT, ACCEPT_LANGUAGE, COOKIE, HeaderMap, HeaderValue, REFERER, USER_AGENT,
12};
13use serde_json::{Map, Value, json};
14
15use crate::comments::{DEFAULT_USER_AGENT, sign};
16use crate::{cookie, settings};
17
18const BASE_URL: &str = "https://www.douyin.com";
19const USER_ID_PREFIX: &str = "MS4wLjABAAAA";
20
21#[derive(Debug, Args)]
22pub struct CrawlArgs {
23    /// 作品/账号/话题/音乐 URL、ID、搜索关键词或目标文件;可多次传入
24    #[arg(short = 'u', long = "urls")]
25    urls: Vec<String>,
26    /// 限制最大采集数量,0 表示不限制
27    #[arg(short, long, default_value_t = 0)]
28    limit: usize,
29    /// 不下载文件,仅采集数据
30    #[arg(long)]
31    no_download: bool,
32    /// 采集类型
33    #[arg(short = 't', long = "type", value_enum, default_value_t = CrawlType::Post)]
34    crawl_type: CrawlType,
35    /// 下载和数据输出根目录
36    #[arg(short = 'p', long = "path", default_value_os_t = default_download_root())]
37    output_path: PathBuf,
38    /// 本次运行使用的 Cookie;默认读取保存的 Cookie
39    #[arg(short, long, env = "DOUYIN_COOKIE")]
40    cookie: Option<String>,
41    /// 搜索排序:0=综合,1=最多点赞,2=最新
42    #[arg(long, value_parser = ["0", "1", "2"])]
43    sort_type: Option<String>,
44    /// 发布时间:0=不限,1=一天内,7=一周内,180=半年内
45    #[arg(long, value_parser = ["0", "1", "7", "180"])]
46    publish_time: Option<String>,
47    /// 视频时长:空=不限,0-1、1-5、5-10000
48    #[arg(long, value_parser = ["", "0-1", "1-5", "5-10000"])]
49    filter_duration: Option<String>,
50    /// 为每个作品保存标题文本
51    #[arg(long)]
52    download_title: bool,
53    /// 下载作品封面
54    #[arg(long)]
55    download_cover: bool,
56}
57
58#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
59enum CrawlType {
60    Post,
61    Favorite,
62    Music,
63    Hashtag,
64    Search,
65    Following,
66    Follower,
67    Collection,
68    Mix,
69    Aweme,
70}
71
72impl CrawlType {
73    fn as_str(self) -> &'static str {
74        match self {
75            Self::Post => "post",
76            Self::Favorite => "favorite",
77            Self::Music => "music",
78            Self::Hashtag => "hashtag",
79            Self::Search => "search",
80            Self::Following => "following",
81            Self::Follower => "follower",
82            Self::Collection => "collection",
83            Self::Mix => "mix",
84            Self::Aweme => "aweme",
85        }
86    }
87
88    fn is_user_list(self) -> bool {
89        matches!(self, Self::Following | Self::Follower)
90    }
91
92    fn is_account_only(self) -> bool {
93        matches!(
94            self,
95            Self::Favorite | Self::Collection | Self::Following | Self::Follower
96        )
97    }
98}
99
100impl CrawlArgs {
101    pub fn should_run(&self) -> bool {
102        !self.urls.is_empty()
103            || self.limit != 0
104            || self.no_download
105            || self.crawl_type != CrawlType::Post
106            || self.output_path != default_download_root()
107            || self.sort_type.is_some()
108            || self.publish_time.is_some()
109            || self.filter_duration.is_some()
110            || self.download_title
111            || self.download_cover
112    }
113}
114
115pub fn run(args: CrawlArgs) -> Result<(), String> {
116    let settings_data = settings::load().map_err(|error| error.to_string())?;
117    let cookie_value = args
118        .cookie
119        .clone()
120        .or_else(|| {
121            settings_data
122                .get("cookie")
123                .and_then(Value::as_str)
124                .map(str::to_owned)
125        })
126        .filter(|value| !value.trim().is_empty())
127        .ok_or_else(|| "未登录。请先运行: douyin auth cookie-login".to_owned())?;
128    if !cookie::validate(&cookie_value) {
129        return Err("Cookie 格式校验失败".to_owned());
130    }
131    let user_agent = settings_data
132        .get("userAgent")
133        .and_then(Value::as_str)
134        .filter(|value| !value.is_empty())
135        .unwrap_or(DEFAULT_USER_AGENT);
136    let filename_fields = settings_data
137        .get("filenameFields")
138        .and_then(Value::as_array)
139        .map(|values| {
140            values
141                .iter()
142                .filter_map(Value::as_str)
143                .map(str::to_owned)
144                .collect()
145        })
146        .unwrap_or_else(|| vec!["id".to_owned(), "title".to_owned()]);
147    let filename_separator = settings_data
148        .get("filenameSeparator")
149        .and_then(Value::as_str)
150        .filter(|value| !value.is_empty())
151        .unwrap_or("_")
152        .to_owned();
153    let download_title = args.download_title
154        || settings_data
155            .get("enableDownloadTitle")
156            .and_then(Value::as_bool)
157            .unwrap_or(false);
158    let download_cover = args.download_cover
159        || settings_data
160            .get("enableDownloadCover")
161            .and_then(Value::as_bool)
162            .unwrap_or(false);
163
164    let web = WebClient::new(&cookie_value, user_agent)?;
165    let targets = resolve_targets(&args.urls, args.crawl_type)?;
166    let mut successes = 0_usize;
167    let mut failures = 0_usize;
168    for target in targets {
169        eprintln!(
170            "开始采集:{} ({})",
171            if target.is_empty() {
172                "本账号"
173            } else {
174                &target
175            },
176            args.crawl_type.as_str()
177        );
178        match crawl_target(
179            &web,
180            &target,
181            &args,
182            &filename_fields,
183            &filename_separator,
184            download_title,
185            download_cover,
186        ) {
187            Ok(count) => {
188                successes += 1;
189                eprintln!("采集完成:{count} 条结果");
190            }
191            Err(error) => {
192                failures += 1;
193                eprintln!("采集失败:{error}");
194            }
195        }
196    }
197    eprintln!("任务完成:成功 {successes} 个,失败 {failures} 个");
198    if failures > 0 {
199        Err(format!("{failures} 个采集任务失败"))
200    } else {
201        Ok(())
202    }
203}
204
205fn resolve_targets(inputs: &[String], crawl_type: CrawlType) -> Result<Vec<String>, String> {
206    if inputs.is_empty() {
207        if crawl_type.is_account_only() {
208            return Ok(vec![String::new()]);
209        }
210        eprint!(
211            "采集类型 {},请输入目标关键词/URL链接/ID或文件路径: ",
212            crawl_type.as_str()
213        );
214        io::stderr().flush().map_err(|error| error.to_string())?;
215        let mut input = String::new();
216        io::stdin()
217            .read_line(&mut input)
218            .map_err(|error| error.to_string())?;
219        let input = input.trim();
220        if input.is_empty() {
221            return Err("未输入目标".to_owned());
222        }
223        return resolve_targets(&[input.to_owned()], crawl_type);
224    }
225    let mut targets = Vec::new();
226    for input in inputs {
227        let path = Path::new(input);
228        if path.is_file() {
229            let text = fs::read_to_string(path)
230                .map_err(|error| format!("读取目标文件 {} 失败: {error}", path.display()))?;
231            targets.extend(
232                text.lines()
233                    .map(str::trim)
234                    .filter(|line| !line.is_empty())
235                    .map(str::to_owned),
236            );
237        } else {
238            targets.push(input.trim().to_owned());
239        }
240    }
241    if targets.is_empty() {
242        Err("未找到可采集目标".to_owned())
243    } else {
244        Ok(targets)
245    }
246}
247
248fn crawl_target(
249    web: &WebClient,
250    input: &str,
251    args: &CrawlArgs,
252    filename_fields: &[String],
253    filename_separator: &str,
254    download_title: bool,
255    download_cover: bool,
256) -> Result<usize, String> {
257    let target = Target::parse(web, input, args.crawl_type)?;
258    let title = web
259        .target_title(&target)
260        .unwrap_or_else(|| target.id.clone());
261    let directory_name = sanitize_filename(&format!("{}_{}", target.kind.as_str(), title), 100);
262    fs::create_dir_all(&args.output_path).map_err(|error| error.to_string())?;
263    let data_stem = args.output_path.join(directory_name);
264    let mut results = if target.kind == CrawlType::Aweme {
265        let raw = web.fetch_json(
266            "/aweme/v1/web/aweme/detail/",
267            vec![("aweme_id".to_owned(), target.id.clone())],
268            None,
269        )?;
270        let detail = raw.get("aweme_detail").cloned().unwrap_or(Value::Null);
271        parse_aweme(&detail, target.kind).into_iter().collect()
272    } else {
273        crawl_pages(web, &target, args.limit, args)?
274    };
275    if target.kind == CrawlType::Post {
276        merge_incremental(&mut results, &data_stem.with_extension("json"))?;
277        results.sort_by(|left, right| string_field(right, "id").cmp(string_field(left, "id")));
278    }
279    save_json(
280        &data_stem.with_extension("json"),
281        &Value::Array(results.clone()),
282    )?;
283    let manifest_path = data_stem.with_extension("txt");
284    let download_options = DownloadOptions {
285        kind: target.kind,
286        fields: filename_fields,
287        separator: filename_separator,
288        download_title,
289        download_cover,
290    };
291    write_download_manifest(&results, &data_stem, &manifest_path, &download_options)?;
292    if !args.no_download && !target.kind.is_user_list() {
293        download_items(web, &results, &data_stem, &download_options)?;
294    } else if args.no_download {
295        eprintln!("已跳过下载(--no-download)");
296    }
297    Ok(results.len())
298}
299
300fn crawl_pages(
301    web: &WebClient,
302    target: &Target,
303    limit: usize,
304    args: &CrawlArgs,
305) -> Result<Vec<Value>, String> {
306    let mut cursor = 0_i64;
307    let mut log_id = String::new();
308    let mut has_more = true;
309    let mut results = Vec::new();
310    let mut retries = 0_u8;
311    while has_more && !limit_reached(results.len(), limit) {
312        let request = list_request(target, cursor, &log_id, args)?;
313        let response = match web.fetch_json(request.path, request.params, request.form) {
314            Ok(value) => {
315                retries = 0;
316                value
317            }
318            Err(error) if retries < 9 => {
319                retries += 1;
320                eprintln!("采集请求失败,重试 {retries}/10:{error}");
321                continue;
322            }
323            Err(error) => return Err(error),
324        };
325        cursor = ["max_cursor", "cursor", "min_time"]
326            .into_iter()
327            .find_map(|key| {
328                response
329                    .get(key)
330                    .and_then(value_i64)
331                    .filter(|value| *value != 0)
332            })
333            .unwrap_or(cursor);
334        if log_id.is_empty() {
335            log_id = response
336                .pointer("/log_pb/impr_id")
337                .and_then(Value::as_str)
338                .unwrap_or("")
339                .to_owned();
340        }
341        let items = ["aweme_list", "user_list", "data", "followings", "followers"]
342            .into_iter()
343            .find_map(|key| {
344                response
345                    .get(key)
346                    .and_then(Value::as_array)
347                    .filter(|values| !values.is_empty())
348            })
349            .cloned()
350            .unwrap_or_default();
351        has_more = truthy(response.get("has_more"));
352        if items.is_empty() {
353            if has_more && retries < 9 {
354                retries += 1;
355                continue;
356            }
357            break;
358        }
359        for raw in items {
360            let item = raw
361                .get(if target.kind.is_user_list() {
362                    "user_info"
363                } else {
364                    "aweme_info"
365                })
366                .unwrap_or(&raw);
367            let parsed = if target.kind.is_user_list() {
368                Some(parse_user(item))
369            } else {
370                parse_aweme(item, target.kind)
371            };
372            if let Some(parsed) = parsed {
373                results.push(parsed);
374            }
375            if limit_reached(results.len(), limit) {
376                has_more = false;
377                break;
378            }
379        }
380        eprintln!("采集中,已采集到 {} 条结果", results.len());
381    }
382    Ok(results)
383}
384
385struct ListRequest {
386    path: &'static str,
387    params: Vec<(String, String)>,
388    form: Option<Vec<(String, String)>>,
389}
390
391fn list_request(
392    target: &Target,
393    cursor: i64,
394    log_id: &str,
395    args: &CrawlArgs,
396) -> Result<ListRequest, String> {
397    let count = "18".to_owned();
398    let value = match target.kind {
399        CrawlType::Post => ListRequest {
400            path: "/aweme/v1/web/aweme/post/",
401            params: pairs([
402                ("publish_video_strategy_type", "2"),
403                ("max_cursor", &cursor.to_string()),
404                ("locate_query", "false"),
405                ("show_live_replay_strategy", "1"),
406                ("need_time_list", "0"),
407                ("time_list_query", "0"),
408                ("whale_cut_token", ""),
409                ("count", &count),
410                ("sec_user_id", &target.id),
411            ]),
412            form: None,
413        },
414        CrawlType::Favorite => ListRequest {
415            path: "/aweme/v1/web/aweme/favorite/",
416            params: pairs([
417                ("sec_user_id", &target.id),
418                ("max_cursor", &cursor.to_string()),
419                ("min_cursor", "0"),
420                ("whale_cut_token", ""),
421                ("cut_version", "1"),
422                ("count", &count),
423                ("publish_video_strategy_type", "2"),
424            ]),
425            form: None,
426        },
427        CrawlType::Collection => ListRequest {
428            path: "/aweme/v1/web/aweme/listcollection/",
429            params: pairs([
430                ("sec_user_id", &target.id),
431                ("publish_video_strategy_type", "2"),
432            ]),
433            form: Some(pairs([("cursor", &cursor.to_string()), ("count", &count)])),
434        },
435        CrawlType::Music => ListRequest {
436            path: "/aweme/v1/web/music/aweme/",
437            params: pairs([
438                ("cursor", &cursor.to_string()),
439                ("count", &count),
440                ("music_id", &target.id),
441            ]),
442            form: None,
443        },
444        CrawlType::Hashtag => ListRequest {
445            path: "/aweme/v1/web/challenge/aweme/",
446            params: pairs([
447                ("cursor", &cursor.to_string()),
448                ("count", &count),
449                ("sort_type", "1"),
450                ("ch_id", &target.id),
451            ]),
452            form: None,
453        },
454        CrawlType::Mix => ListRequest {
455            path: "/aweme/v1/web/mix/aweme/",
456            params: pairs([
457                ("cursor", &cursor.to_string()),
458                ("count", &count),
459                ("mix_id", &target.id),
460            ]),
461            form: None,
462        },
463        CrawlType::Search => {
464            let filters = json!({
465                "sort_type": args.sort_type.as_deref().unwrap_or("0"),
466                "publish_time": args.publish_time.as_deref().unwrap_or("0"),
467                "content_type": "1",
468                "filter_duration": args.filter_duration.as_deref().unwrap_or("0"),
469                "search_range": "0"
470            });
471            ListRequest {
472                path: "/aweme/v1/web/general/search/single/",
473                params: vec![
474                    ("search_channel".to_owned(), "aweme_general".to_owned()),
475                    ("enable_history".to_owned(), "1".to_owned()),
476                    ("filter_selected".to_owned(), filters.to_string()),
477                    ("keyword".to_owned(), target.id.clone()),
478                    ("search_source".to_owned(), "tab_search".to_owned()),
479                    ("query_correct_type".to_owned(), "1".to_owned()),
480                    ("is_filter_search".to_owned(), "1".to_owned()),
481                    ("from_group_id".to_owned(), String::new()),
482                    ("disable_rs".to_owned(), "0".to_owned()),
483                    ("offset".to_owned(), cursor.to_string()),
484                    ("count".to_owned(), count),
485                    ("need_filter_settings".to_owned(), "0".to_owned()),
486                    ("list_type".to_owned(), "multi".to_owned()),
487                    ("search_id".to_owned(), log_id.to_owned()),
488                ],
489                form: None,
490            }
491        }
492        CrawlType::Following => ListRequest {
493            path: "/aweme/v1/web/user/following/list/",
494            params: pairs([
495                ("sec_user_id", &target.id),
496                ("offset", "0"),
497                ("min_time", "0"),
498                ("max_time", &cursor.to_string()),
499                ("count", "20"),
500                ("gps_access", "0"),
501                ("is_top", "1"),
502            ]),
503            form: None,
504        },
505        CrawlType::Follower => ListRequest {
506            path: "/aweme/v1/web/user/follower/list/",
507            params: pairs([
508                ("sec_user_id", &target.id),
509                ("offset", "0"),
510                ("min_time", "0"),
511                ("max_time", &cursor.to_string()),
512                ("count", "20"),
513                ("gps_access", "0"),
514                ("is_top", "1"),
515                ("source_type", "3"),
516            ]),
517            form: None,
518        },
519        CrawlType::Aweme => return Err("aweme 类型不使用列表接口".to_owned()),
520    };
521    Ok(value)
522}
523
524fn pairs<const N: usize>(items: [(&str, &str); N]) -> Vec<(String, String)> {
525    items
526        .into_iter()
527        .map(|(key, value)| (key.to_owned(), value.to_owned()))
528        .collect()
529}
530
531struct Target {
532    id: String,
533    url: String,
534    kind: CrawlType,
535}
536
537impl Target {
538    fn parse(web: &WebClient, input: &str, requested: CrawlType) -> Result<Self, String> {
539        if input.is_empty() {
540            let id = web.self_uid()?;
541            return Ok(Self {
542                id,
543                url: format!("{BASE_URL}/user/self"),
544                kind: requested,
545            });
546        }
547        if let Ok(mut url) = reqwest::Url::parse(input) {
548            if !url
549                .host_str()
550                .is_some_and(|host| host.ends_with("douyin.com"))
551            {
552                return Err(format!("目标不是抖音链接: {input}"));
553            }
554            if url.host_str() == Some("v.douyin.com") {
555                url = web.redirect_url(url)?;
556            }
557            let parts: Vec<_> = url
558                .path_segments()
559                .into_iter()
560                .flatten()
561                .filter(|part| !part.is_empty())
562                .collect();
563            let id = percent_decode_str(parts.last().copied().unwrap_or(""))
564                .decode_utf8_lossy()
565                .into_owned();
566            let marker = parts.iter().rev().nth(1).copied().unwrap_or("");
567            let kind = match marker {
568                "video" | "note" => CrawlType::Aweme,
569                "music" => CrawlType::Music,
570                "hashtag" => CrawlType::Hashtag,
571                "collection" => CrawlType::Mix,
572                "search" => CrawlType::Search,
573                _ => requested,
574            };
575            if id.is_empty() {
576                return Err(format!("无法从链接识别目标 ID: {input}"));
577            }
578            return Ok(Self {
579                id,
580                url: url.into(),
581                kind,
582            });
583        }
584        let valid = match requested {
585            CrawlType::Search => true,
586            CrawlType::Aweme | CrawlType::Music | CrawlType::Hashtag | CrawlType::Mix => {
587                input.chars().all(|value| value.is_ascii_digit())
588            }
589            _ => input.starts_with(USER_ID_PREFIX),
590        };
591        if !valid {
592            return Err(format!("目标输入错误: {input}"));
593        }
594        let url = match requested {
595            CrawlType::Search => format!("{BASE_URL}/search/{input}"),
596            CrawlType::Aweme => format!("{BASE_URL}/note/{input}"),
597            CrawlType::Mix => format!("{BASE_URL}/collection/{input}"),
598            CrawlType::Music => format!("{BASE_URL}/music/{input}"),
599            CrawlType::Hashtag => format!("{BASE_URL}/hashtag/{input}"),
600            _ => format!("{BASE_URL}/user/{input}"),
601        };
602        Ok(Self {
603            id: input.to_owned(),
604            url,
605            kind: requested,
606        })
607    }
608}
609
610struct WebClient {
611    client: Client,
612    user_agent: String,
613}
614
615impl WebClient {
616    fn new(cookie: &str, user_agent: &str) -> Result<Self, String> {
617        let mut headers = HeaderMap::new();
618        headers.insert(
619            ACCEPT,
620            HeaderValue::from_static("application/json, text/plain, */*"),
621        );
622        headers.insert(ACCEPT_LANGUAGE, HeaderValue::from_static("zh-CN,zh;q=0.9"));
623        headers.insert(REFERER, HeaderValue::from_static("https://www.douyin.com/"));
624        headers.insert(
625            USER_AGENT,
626            HeaderValue::from_str(user_agent).map_err(|error| error.to_string())?,
627        );
628        headers.insert(
629            COOKIE,
630            HeaderValue::from_str(cookie).map_err(|error| error.to_string())?,
631        );
632        let client = Client::builder()
633            .default_headers(headers)
634            .connect_timeout(Duration::from_secs(10))
635            .timeout(Duration::from_secs(60))
636            .build()
637            .map_err(|error| error.to_string())?;
638        Ok(Self {
639            client,
640            user_agent: user_agent.to_owned(),
641        })
642    }
643
644    fn fetch_json(
645        &self,
646        path: &str,
647        mut params: Vec<(String, String)>,
648        form: Option<Vec<(String, String)>>,
649    ) -> Result<Value, String> {
650        params.extend([
651            ("device_platform".to_owned(), "webapp".to_owned()),
652            ("aid".to_owned(), "6383".to_owned()),
653            ("channel".to_owned(), "channel_pc_web".to_owned()),
654        ]);
655        if matches!(
656            path,
657            "/aweme/v1/web/aweme/detail/"
658                | "/aweme/v1/web/music/aweme/"
659                | "/aweme/v1/web/user/follower/list/"
660        ) {
661            let query = encode_query(&params);
662            params.push((
663                "a_bogus".to_owned(),
664                sign("sign_datail", &query, &self.user_agent)?,
665            ));
666        }
667        let request = if let Some(form) = form {
668            self.client
669                .post(format!("{BASE_URL}{path}"))
670                .query(&params)
671                .form(&form)
672        } else {
673            self.client.get(format!("{BASE_URL}{path}")).query(&params)
674        };
675        let response = request.send().map_err(|error| error.to_string())?;
676        let status = response.status();
677        let text = response.text().map_err(|error| error.to_string())?;
678        if !status.is_success() {
679            return Err(format!("网页接口请求失败: {status} {text}"));
680        }
681        if text.is_empty() {
682            return Err("响应体为空,Cookie 可能已失效".to_owned());
683        }
684        let value: Value = serde_json::from_str(&text)
685            .map_err(|error| format!("网页接口响应不是 JSON: {error}"))?;
686        if contains_verify_check(&value) {
687            return Err("触发验证码,请在浏览器完成验证".to_owned());
688        }
689        if value.get("status_code").and_then(value_i64).unwrap_or(0) != 0 {
690            return Err(format!("网页接口返回失败状态: {text}"));
691        }
692        Ok(value)
693    }
694
695    fn redirect_url(&self, url: reqwest::Url) -> Result<reqwest::Url, String> {
696        self.client
697            .get(url)
698            .send()
699            .map(|response| response.url().clone())
700            .map_err(|error| error.to_string())
701    }
702
703    fn get_html(&self, url: &str) -> Result<String, String> {
704        let response = self
705            .client
706            .get(url)
707            .send()
708            .map_err(|error| error.to_string())?;
709        if !response.status().is_success() {
710            return Err(format!("HTML 请求失败: {}", response.status()));
711        }
712        response.text().map_err(|error| error.to_string())
713    }
714
715    fn self_uid(&self) -> Result<String, String> {
716        let html = self.get_html(&format!("{BASE_URL}/user/self"))?;
717        extract_escaped_value(&html, "secUid").ok_or_else(|| "无法从账号页面提取 secUid".to_owned())
718    }
719
720    fn target_title(&self, target: &Target) -> Option<String> {
721        if target.kind == CrawlType::Search || target.kind == CrawlType::Aweme {
722            return Some(target.id.clone());
723        }
724        let html = self.get_html(&target.url).ok()?;
725        let key = match target.kind {
726            CrawlType::Mix => "mixName",
727            CrawlType::Music => "title",
728            CrawlType::Hashtag => "chaName",
729            _ => "nickname",
730        };
731        extract_escaped_value(&html, key).map(|value| sanitize_filename(&value, 100))
732    }
733}
734
735fn encode_query(params: &[(String, String)]) -> String {
736    params
737        .iter()
738        .map(|(key, value)| {
739            let encoded: String = url::form_urlencoded::byte_serialize(value.as_bytes()).collect();
740            format!("{key}={encoded}")
741        })
742        .collect::<Vec<_>>()
743        .join("&")
744}
745
746fn extract_escaped_value(text: &str, key: &str) -> Option<String> {
747    for marker in [format!("{key}\\\":\\\""), format!("\"{key}\":\"")] {
748        let Some(position) = text.find(&marker) else {
749            continue;
750        };
751        let start = position + marker.len();
752        let tail = &text[start..];
753        let Some(end) = tail.find(if marker.contains("\\\"") {
754            "\\\""
755        } else {
756            "\""
757        }) else {
758            continue;
759        };
760        let value = &tail[..end];
761        if !value.is_empty() {
762            return Some(value.replace("\\u002F", "/"));
763        }
764    }
765    None
766}
767
768fn parse_aweme(item: &Value, crawl_type: CrawlType) -> Option<Value> {
769    let kind = item
770        .get("aweme_type")
771        .or_else(|| item.get("awemeType"))
772        .and_then(value_i64)?;
773    let mut output = item
774        .get("statistics")
775        .or_else(|| item.get("stats"))
776        .and_then(Value::as_object)
777        .cloned()
778        .unwrap_or_default();
779    for key in [
780        "playCount",
781        "downloadCount",
782        "forwardCount",
783        "collectCount",
784        "digest",
785        "exposure_count",
786        "live_watch_count",
787        "play_count",
788        "download_count",
789        "forward_count",
790        "lose_count",
791        "lose_comment_count",
792    ] {
793        output.remove(key);
794    }
795    let video = item.get("video").unwrap_or(&Value::Null);
796    let download = if kind <= 66 || matches!(kind, 69 | 107) {
797        last_url(video.pointer("/play_addr/url_list"))
798            .or_else(|| last_url(item.pointer("/download/urlList")))
799            .map(|value| Value::String(value.replace("watermark=1", "watermark=0")))?
800    } else if kind == 68 {
801        let values: Vec<_> = item
802            .get("images")?
803            .as_array()?
804            .iter()
805            .filter_map(|image| last_url(image.get("url_list").or_else(|| image.get("urlList"))))
806            .map(Value::String)
807            .collect();
808        if values.is_empty() {
809            return None;
810        }
811        Value::Array(values)
812    } else {
813        return None;
814    };
815    output.insert("download_addr".to_owned(), download);
816    copy_alias(item, &mut output, "id", &["aweme_id", "awemeId"]);
817    copy_alias(item, &mut output, "time", &["create_time", "createTime"]);
818    output.insert("type".to_owned(), json!(kind));
819    output.insert(
820        "desc".to_owned(),
821        json!(sanitize_filename(
822            item.get("desc").and_then(Value::as_str).unwrap_or(""),
823            100
824        )),
825    );
826    output.insert(
827        "duration".to_owned(),
828        item.get("duration")
829            .or_else(|| video.get("duration"))
830            .cloned()
831            .unwrap_or(Value::Null),
832    );
833    if let Some(music) = item.get("music") {
834        output.insert(
835            "music_title".to_owned(),
836            json!(sanitize_filename(
837                music.get("title").and_then(Value::as_str).unwrap_or(""),
838                100
839            )),
840        );
841        if let Some(uri) = music
842            .pointer("/play_url/uri")
843            .or_else(|| music.pointer("/playUrl/uri"))
844        {
845            output.insert("music_url".to_owned(), uri.clone());
846        }
847    }
848    let cover = last_url(video.pointer("/cover/url_list"))
849        .or_else(|| {
850            video
851                .get("dynamicCover")
852                .and_then(Value::as_str)
853                .map(|value| format!("https:{value}"))
854        })
855        .unwrap_or_default();
856    output.insert("cover".to_owned(), json!(cover));
857    if let Some(author) = item.get("author").or_else(|| item.get("authorInfo")) {
858        output.insert(
859            "author_avatar".to_owned(),
860            json!(
861                last_url(
862                    author
863                        .get("avatar_thumb")
864                        .or_else(|| author.get("avatarThumb"))
865                        .and_then(|value| value.get("url_list").or_else(|| value.get("urlList")))
866                )
867                .unwrap_or_default()
868            ),
869        );
870        for (target, aliases) in [
871            ("author_nickname", &["nickname"][..]),
872            ("author_uid", &["sec_uid", "secUid"]),
873            ("author_unique_id", &["unique_id", "uniqueId"]),
874            ("author_short_id", &["short_id", "shortId"]),
875        ] {
876            copy_alias(author, &mut output, target, aliases);
877        }
878        output.insert(
879            "author_signature".to_owned(),
880            json!(sanitize_filename(
881                author
882                    .get("signature")
883                    .and_then(Value::as_str)
884                    .unwrap_or(""),
885                100
886            )),
887        );
888    }
889    if let Some(tags) = item
890        .get("text_extra")
891        .or_else(|| item.get("textExtra"))
892        .and_then(Value::as_array)
893    {
894        output.insert("text_extra".to_owned(), Value::Array(tags.iter().map(|tag| json!({
895            "tag_id": tag.get("hashtag_id").or_else(|| tag.get("hashtagId")).cloned().unwrap_or(Value::Null),
896            "tag_name": tag.get("hashtag_name").or_else(|| tag.get("hashtagName")).cloned().unwrap_or(Value::Null)
897        })).collect()));
898    }
899    if crawl_type == CrawlType::Mix
900        && let Some(number) = item.pointer("/mix_info/statis/current_episode")
901    {
902        output.insert("no".to_owned(), number.clone());
903    }
904    Some(Value::Object(output))
905}
906
907fn parse_user(item: &Value) -> Value {
908    let mut output = Map::new();
909    output.insert(
910        "nickname".to_owned(),
911        json!(sanitize_filename(
912            item.get("nickname").and_then(Value::as_str).unwrap_or(""),
913            100
914        )),
915    );
916    output.insert(
917        "signature".to_owned(),
918        json!(sanitize_filename(
919            item.get("signature").and_then(Value::as_str).unwrap_or(""),
920            100
921        )),
922    );
923    output.insert(
924        "avatar".to_owned(),
925        json!(
926            item.pointer("/avatar_thumb/url_list/0")
927                .and_then(Value::as_str)
928                .unwrap_or("")
929        ),
930    );
931    for key in [
932        "sec_uid",
933        "uid",
934        "short_id",
935        "unique_id",
936        "unique_id_modify_time",
937        "aweme_count",
938        "favoriting_count",
939        "follower_count",
940        "following_count",
941        "constellation",
942        "create_time",
943        "enterprise_verify_reason",
944        "is_gov_media_vip",
945        "live_status",
946        "total_favorited",
947        "share_qrcode_uri",
948    ] {
949        if let Some(value) = item.get(key).filter(|value| !value.is_null()) {
950            output.insert(key.to_owned(), value.clone());
951        }
952    }
953    if let Some(room_id) = item.get("room_id").filter(|value| !value.is_null()) {
954        output.insert("live_room_id".to_owned(), room_id.clone());
955        let id = room_id
956            .as_str()
957            .map(str::to_owned)
958            .unwrap_or_else(|| room_id.to_string());
959        output.insert(
960            "live_room_url".to_owned(),
961            json!([
962                format!("http://pull-flv-f26.douyincdn.com/media/stream-{id}.flv"),
963                format!("http://pull-hls-f26.douyincdn.com/media/stream-{id}.m3u8")
964            ]),
965        );
966    }
967    if item
968        .pointer("/original_musician/music_count")
969        .and_then(value_i64)
970        .unwrap_or(0)
971        > 0
972    {
973        output.insert(
974            "original_musician".to_owned(),
975            item["original_musician"].clone(),
976        );
977    }
978    Value::Object(output)
979}
980
981fn merge_incremental(results: &mut Vec<Value>, path: &Path) -> Result<(), String> {
982    if !path.exists() {
983        return Ok(());
984    }
985    let old: Value =
986        serde_json::from_str(&fs::read_to_string(path).map_err(|error| error.to_string())?)
987            .map_err(|error| format!("旧采集数据无效: {error}"))?;
988    let old_values = old.as_array().cloned().unwrap_or_default();
989    let old_ids: HashMap<_, _> = old_values
990        .iter()
991        .filter_map(|value| value.get("id").map(|id| (id.to_string(), ())))
992        .collect();
993    results.retain(|value| {
994        value
995            .get("id")
996            .is_none_or(|id| !old_ids.contains_key(&id.to_string()))
997    });
998    results.extend(old_values);
999    Ok(())
1000}
1001
1002struct DownloadOptions<'a> {
1003    kind: CrawlType,
1004    fields: &'a [String],
1005    separator: &'a str,
1006    download_title: bool,
1007    download_cover: bool,
1008}
1009
1010fn write_download_manifest(
1011    results: &[Value],
1012    data_stem: &Path,
1013    manifest: &Path,
1014    options: &DownloadOptions<'_>,
1015) -> Result<(), String> {
1016    let mut lines = String::new();
1017    if options.kind.is_user_list() {
1018        for value in results
1019            .iter()
1020            .filter_map(|value| value.get("sec_uid").and_then(Value::as_str))
1021        {
1022            lines.push_str(&format!("{BASE_URL}/user/{value}\n"));
1023        }
1024    } else {
1025        for item in results {
1026            let filename = item_filename(item, options.kind, options.fields, options.separator);
1027            let item_dir = item_directory(data_stem, item, options.kind, &filename);
1028            match item.get("download_addr") {
1029                Some(Value::Array(urls)) => {
1030                    for (index, url) in urls.iter().filter_map(Value::as_str).enumerate() {
1031                        lines.push_str(&format!(
1032                            "{url}\n dir={}\n out={}_{}.jpeg\n",
1033                            item_dir.display(),
1034                            string_field(item, "id"),
1035                            index + 1
1036                        ));
1037                    }
1038                }
1039                Some(Value::String(url)) => lines.push_str(&format!(
1040                    "{url}\n dir={}\n out={filename}.mp4\n",
1041                    data_stem.display()
1042                )),
1043                _ => {}
1044            }
1045            if options.download_cover
1046                && let Some(url) = item
1047                    .get("cover")
1048                    .and_then(Value::as_str)
1049                    .filter(|value| !value.is_empty())
1050            {
1051                lines.push_str(&format!(
1052                    "{url}\n dir={}\n out={}_cover.jpg\n",
1053                    item_dir.display(),
1054                    string_field(item, "id")
1055                ));
1056            }
1057            if options.download_title {
1058                write_title(item, &item_dir)?;
1059            }
1060        }
1061    }
1062    if !lines.is_empty() {
1063        fs::write(manifest, lines).map_err(|error| error.to_string())?;
1064    }
1065    Ok(())
1066}
1067
1068fn download_items(
1069    web: &WebClient,
1070    results: &[Value],
1071    data_stem: &Path,
1072    options: &DownloadOptions<'_>,
1073) -> Result<(), String> {
1074    fs::create_dir_all(data_stem).map_err(|error| error.to_string())?;
1075    for item in results {
1076        let filename = item_filename(item, options.kind, options.fields, options.separator);
1077        let item_dir = item_directory(data_stem, item, options.kind, &filename);
1078        match item.get("download_addr") {
1079            Some(Value::Array(urls)) => {
1080                fs::create_dir_all(&item_dir).map_err(|error| error.to_string())?;
1081                for (index, url) in urls.iter().filter_map(Value::as_str).enumerate() {
1082                    download_file(
1083                        &web.client,
1084                        url,
1085                        &item_dir.join(format!("{}_{}.jpeg", string_field(item, "id"), index + 1)),
1086                    )?;
1087                }
1088            }
1089            Some(Value::String(url)) if url.starts_with("http") => {
1090                download_file(&web.client, url, &data_stem.join(format!("{filename}.mp4")))?;
1091            }
1092            _ => {}
1093        }
1094        if options.download_cover
1095            && let Some(url) = item
1096                .get("cover")
1097                .and_then(Value::as_str)
1098                .filter(|value| !value.is_empty())
1099        {
1100            fs::create_dir_all(&item_dir).map_err(|error| error.to_string())?;
1101            download_file(
1102                &web.client,
1103                url,
1104                &item_dir.join(format!("{}_cover.jpg", string_field(item, "id"))),
1105            )?;
1106        }
1107        if options.download_title {
1108            write_title(item, &item_dir)?;
1109        }
1110    }
1111    Ok(())
1112}
1113
1114fn download_file(client: &Client, url: &str, path: &Path) -> Result<(), String> {
1115    if path.exists() {
1116        return Ok(());
1117    }
1118    if let Some(parent) = path.parent() {
1119        fs::create_dir_all(parent).map_err(|error| error.to_string())?;
1120    }
1121    eprintln!("下载: {}", path.display());
1122    let mut response = client
1123        .get(url)
1124        .send()
1125        .map_err(|error| format!("下载 {url} 失败: {error}"))?;
1126    if !response.status().is_success() {
1127        return Err(format!("下载 {url} 失败: {}", response.status()));
1128    }
1129    persist_download(&mut response, path)
1130}
1131
1132fn persist_download(reader: &mut impl io::Read, path: &Path) -> Result<(), String> {
1133    if let Some(parent) = path.parent() {
1134        fs::create_dir_all(parent).map_err(|error| error.to_string())?;
1135    }
1136    let temporary = path.with_extension(format!(
1137        "{}.part",
1138        path.extension()
1139            .and_then(|value| value.to_str())
1140            .unwrap_or("download")
1141    ));
1142    let mut file = File::create(&temporary).map_err(|error| error.to_string())?;
1143    io::copy(reader, &mut file).map_err(|error| error.to_string())?;
1144    file.flush().map_err(|error| error.to_string())?;
1145    fs::rename(temporary, path).map_err(|error| error.to_string())
1146}
1147
1148fn write_title(item: &Value, directory: &Path) -> Result<(), String> {
1149    fs::create_dir_all(directory).map_err(|error| error.to_string())?;
1150    fs::write(
1151        directory.join(format!("{}_title.txt", string_field(item, "id"))),
1152        string_field(item, "desc"),
1153    )
1154    .map_err(|error| error.to_string())
1155}
1156
1157fn item_directory(data_stem: &Path, item: &Value, kind: CrawlType, filename: &str) -> PathBuf {
1158    if item.get("download_addr").is_some_and(Value::is_array) {
1159        if kind == CrawlType::Aweme {
1160            data_stem.parent().unwrap_or(data_stem).join(filename)
1161        } else {
1162            data_stem.join(filename)
1163        }
1164    } else {
1165        data_stem.to_owned()
1166    }
1167}
1168
1169fn item_filename(item: &Value, kind: CrawlType, fields: &[String], separator: &str) -> String {
1170    let mut parts = Vec::new();
1171    for field in fields {
1172        let value = match field.as_str() {
1173            "id" => string_field(item, "id").to_owned(),
1174            "title" => string_field(item, "desc").to_owned(),
1175            "author" => string_field(item, "author_nickname").to_owned(),
1176            "type" => {
1177                if item.get("type").and_then(value_i64) == Some(68) {
1178                    "图文".to_owned()
1179                } else {
1180                    "视频".to_owned()
1181                }
1182            }
1183            "duration" => item
1184                .get("duration")
1185                .and_then(value_i64)
1186                .map(|ms| format!("{:02}-{:02}", ms / 60_000, (ms / 1_000) % 60))
1187                .unwrap_or_default(),
1188            "music" => string_field(item, "music_title").to_owned(),
1189            "no" => item.get("no").map(value_text).unwrap_or_default(),
1190            _ => String::new(),
1191        };
1192        if !value.is_empty() {
1193            parts.push(value);
1194        }
1195    }
1196    let fallback = string_field(item, "id");
1197    let joined = parts.join(separator);
1198    let base = sanitize_filename(if joined.is_empty() { fallback } else { &joined }, 200);
1199    if kind == CrawlType::Mix {
1200        item.get("no")
1201            .map(|value| format!("第{}集{separator}{base}", value_text(value)))
1202            .unwrap_or(base)
1203    } else {
1204        base
1205    }
1206}
1207
1208fn save_json(path: &Path, value: &Value) -> Result<(), String> {
1209    if let Some(parent) = path.parent() {
1210        fs::create_dir_all(parent).map_err(|error| error.to_string())?;
1211    }
1212    let mut text = serde_json::to_string_pretty(value).map_err(|error| error.to_string())?;
1213    text.push('\n');
1214    fs::write(path, text).map_err(|error| error.to_string())
1215}
1216
1217fn default_download_root() -> PathBuf {
1218    std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" })
1219        .map(PathBuf::from)
1220        .unwrap_or_else(|| PathBuf::from("."))
1221        .join("Downloads")
1222        .join("douyin")
1223}
1224
1225fn sanitize_filename(text: &str, max_bytes: usize) -> String {
1226    let filtered: String = text
1227        .trim()
1228        .chars()
1229        .filter(|value| {
1230            !matches!(value, '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*')
1231                && !value.is_control()
1232        })
1233        .collect();
1234    let collapsed = filtered.split_whitespace().collect::<Vec<_>>().join(" ");
1235    let source = if collapsed.is_empty() {
1236        "无标题"
1237    } else {
1238        &collapsed
1239    };
1240    if source.len() <= max_bytes {
1241        return source.to_owned();
1242    }
1243    let mut end = max_bytes.saturating_sub(3).min(source.len());
1244    while !source.is_char_boundary(end) {
1245        end -= 1;
1246    }
1247    format!("{}...", source[..end].trim())
1248}
1249
1250fn copy_alias(source: &Value, target: &mut Map<String, Value>, key: &str, aliases: &[&str]) {
1251    if let Some(value) = aliases.iter().find_map(|alias| source.get(alias)).cloned() {
1252        target.insert(key.to_owned(), value);
1253    }
1254}
1255
1256fn last_url(value: Option<&Value>) -> Option<String> {
1257    value?.as_array()?.last()?.as_str().map(str::to_owned)
1258}
1259
1260fn value_i64(value: &Value) -> Option<i64> {
1261    value
1262        .as_i64()
1263        .or_else(|| value.as_u64().and_then(|value| i64::try_from(value).ok()))
1264        .or_else(|| value.as_str()?.parse().ok())
1265}
1266
1267fn value_text(value: &Value) -> String {
1268    value
1269        .as_str()
1270        .map(str::to_owned)
1271        .unwrap_or_else(|| value.to_string())
1272}
1273
1274fn string_field<'a>(value: &'a Value, key: &str) -> &'a str {
1275    value.get(key).and_then(Value::as_str).unwrap_or("")
1276}
1277
1278fn truthy(value: Option<&Value>) -> bool {
1279    value.is_some_and(|value| {
1280        value
1281            .as_bool()
1282            .unwrap_or_else(|| value_i64(value).unwrap_or(0) != 0)
1283    })
1284}
1285
1286fn limit_reached(length: usize, limit: usize) -> bool {
1287    limit > 0 && length >= limit
1288}
1289
1290fn contains_verify_check(value: &Value) -> bool {
1291    match value {
1292        Value::Object(values) => values
1293            .iter()
1294            .any(|(key, value)| key == "verify_check" || contains_verify_check(value)),
1295        Value::Array(values) => values.iter().any(contains_verify_check),
1296        Value::String(value) => value == "verify_check",
1297        _ => false,
1298    }
1299}
1300
1301#[cfg(test)]
1302mod tests {
1303    use std::fs;
1304    use std::io::Cursor;
1305
1306    use super::{
1307        CrawlType, Target, WebClient, extract_escaped_value, item_filename, parse_aweme,
1308        parse_user, persist_download, sanitize_filename,
1309    };
1310    use serde_json::json;
1311
1312    #[test]
1313    fn parses_video_and_image_awemes() {
1314        let video = parse_aweme(&json!({
1315            "aweme_type":4,"aweme_id":"1","create_time":10,"desc":"标题",
1316            "statistics":{"digg_count":2},"video":{"play_addr":{"url_list":["https://video"]},"duration":12000},
1317            "author":{"nickname":"作者","sec_uid":"sec","avatar_thumb":{"url_list":["https://avatar"]}}
1318        }), CrawlType::Post).unwrap();
1319        assert_eq!(video["download_addr"], "https://video");
1320        assert_eq!(video["author_nickname"], "作者");
1321        let image = parse_aweme(&json!({
1322            "aweme_type":68,"aweme_id":"2","desc":"图集","images":[{"url_list":["https://image"]}]
1323        }), CrawlType::Aweme).unwrap();
1324        assert_eq!(image["download_addr"][0], "https://image");
1325    }
1326
1327    #[test]
1328    fn parses_user_and_filename() {
1329        let user = parse_user(&json!({
1330            "nickname":"用户","signature":"签名","avatar_thumb":{"url_list":["https://avatar"]},"sec_uid":"sec"
1331        }));
1332        assert_eq!(user["sec_uid"], "sec");
1333        let item =
1334            json!({"id":"1","desc":"标题","author_nickname":"作者","duration":65000,"type":4});
1335        assert_eq!(
1336            item_filename(
1337                &item,
1338                CrawlType::Post,
1339                &["id".to_owned(), "title".to_owned()],
1340                "_"
1341            ),
1342            "1_标题"
1343        );
1344    }
1345
1346    #[test]
1347    fn sanitizes_cross_platform_filenames_by_utf8_bytes() {
1348        assert_eq!(sanitize_filename(" a:/b*? ", 100), "ab");
1349        assert!(sanitize_filename("很长的中文标题", 10).len() <= 10);
1350    }
1351
1352    #[test]
1353    fn target_auto_detects_and_decodes_search_urls() {
1354        let web = WebClient::new("sessionid=test", super::DEFAULT_USER_AGENT).unwrap();
1355        let target = Target::parse(
1356            &web,
1357            "https://www.douyin.com/search/%E4%BA%8C%E6%89%8B%E8%BD%A6",
1358            CrawlType::Post,
1359        )
1360        .unwrap();
1361        assert_eq!(target.kind, CrawlType::Search);
1362        assert_eq!(target.id, "二手车");
1363    }
1364
1365    #[test]
1366    fn escaped_value_falls_back_to_plain_json() {
1367        assert_eq!(
1368            extract_escaped_value(r#"<script>{"nickname":"测试用户"}</script>"#, "nickname"),
1369            Some("测试用户".to_owned())
1370        );
1371        assert_eq!(
1372            extract_escaped_value(r#"nickname\":\"转义用户\""#, "nickname"),
1373            Some("转义用户".to_owned())
1374        );
1375    }
1376
1377    #[test]
1378    fn native_downloader_atomically_writes_stream() {
1379        let directory =
1380            std::env::temp_dir().join(format!("douyin-rust-download-test-{}", std::process::id()));
1381        fs::create_dir_all(&directory).unwrap();
1382        let path = directory.join("sample.bin");
1383        let mut body = Cursor::new(b"media");
1384        persist_download(&mut body, &path).unwrap();
1385        assert_eq!(fs::read(&path).unwrap(), b"media");
1386        fs::remove_file(path).unwrap();
1387        fs::remove_dir(directory).unwrap();
1388    }
1389}