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