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 #[arg(short = 'u', long = "urls")]
26 urls: Vec<String>,
27 #[arg(short, long, default_value_t = 0)]
29 limit: usize,
30 #[arg(long)]
32 no_download: bool,
33 #[arg(short = 't', long = "type", value_enum, default_value_t = CrawlType::Post)]
35 crawl_type: CrawlType,
36 #[arg(short = 'p', long = "path", default_value_os_t = default_download_root())]
38 output_path: PathBuf,
39 #[arg(short, long, env = "DOUYIN_COOKIE")]
41 cookie: Option<String>,
42 #[arg(long, value_parser = ["0", "1", "2"])]
44 sort_type: Option<String>,
45 #[arg(long, value_parser = ["0", "1", "7", "180", "182"])]
47 publish_time: Option<String>,
48 #[arg(long, value_parser = ["", "0-1", "1-5", "5-10000"])]
50 filter_duration: Option<String>,
51 #[arg(long)]
53 download_title: bool,
54 #[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(¶ms);
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(¶ms)
675 .form(&form)
676 } else {
677 self.client.get(format!("{BASE_URL}{path}")).query(¶ms)
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}