Skip to main content

douyin_cli/
crawler.rs

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