1use std::fs;
2use std::path::{Path, PathBuf};
3use std::process::Command;
4use std::thread;
5use std::time::Duration;
6
7use clap::{Args, ValueEnum};
8use reqwest::blocking::Client;
9use reqwest::header::{
10 ACCEPT, ACCEPT_LANGUAGE, COOKIE, HeaderMap, HeaderValue, REFERER, USER_AGENT,
11};
12use serde_json::{Map, Value, json};
13
14use crate::{cookie, settings};
15
16const BASE_URL: &str = "https://www.douyin.com";
17const COMMENT_LIST: &str = "/aweme/v1/web/comment/list/";
18const COMMENT_REPLIES: &str = "/aweme/v1/web/comment/list/reply/";
19pub(crate) const DEFAULT_USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36";
20const SIGN_SCRIPT: &str = include_str!("../assets/douyin.js");
21
22#[derive(Debug, Args)]
23pub struct CommentArgs {
24 target: String,
26 #[arg(short, long, default_value_t = 100)]
28 limit: usize,
29 #[arg(long, default_value_t = 20)]
31 count: usize,
32 #[arg(long)]
34 with_replies: bool,
35 #[arg(long, default_value_t = 20)]
37 reply_limit: usize,
38 #[arg(long = "sleep", default_value_t = 0.8)]
40 sleep_seconds: f64,
41 #[arg(short, long)]
43 output: Option<PathBuf>,
44 #[arg(long = "format", value_enum, default_value_t = OutputFormat::Raw)]
46 output_format: OutputFormat,
47 #[arg(long, default_value = "user")]
48 comment_role: String,
49 #[arg(long, default_value = "assistant")]
50 reply_role: String,
51 #[arg(long, default_value_t = 0)]
52 min_comment_digg: i64,
53 #[arg(long, default_value_t = 0)]
54 min_reply_digg: i64,
55 #[arg(long)]
56 include_single_comments: bool,
57 #[arg(short, long, env = "DOUYIN_COOKIE")]
59 cookie: Option<String>,
60}
61
62#[derive(Clone, Debug, ValueEnum)]
63enum OutputFormat {
64 Raw,
65 ChatmlJsonl,
66 ChatmlJson,
67}
68
69pub fn run(args: CommentArgs) -> Result<(), String> {
70 let saved = settings::load().map_err(|error| error.to_string())?;
71 let cookie_value = args
72 .cookie
73 .clone()
74 .or_else(|| {
75 saved
76 .get("cookie")
77 .and_then(Value::as_str)
78 .map(str::to_owned)
79 })
80 .filter(|value| !value.trim().is_empty())
81 .ok_or_else(|| "未登录。请先运行: douyin auth cookie-login".to_owned())?;
82 if !cookie::validate(&cookie_value) {
83 return Err("Cookie 格式校验失败".to_owned());
84 }
85 let user_agent = saved
86 .get("userAgent")
87 .and_then(Value::as_str)
88 .filter(|value| !value.is_empty())
89 .unwrap_or(DEFAULT_USER_AGENT);
90 let aweme_id = extract_aweme_id(&args.target)?;
91 let crawler = CommentCrawler::new(&cookie_value, user_agent)?;
92 let data = crawler.crawl(&aweme_id, &args)?;
93 let output = match args.output_format {
94 OutputFormat::Raw => {
95 serde_json::to_string_pretty(&data).map_err(|error| error.to_string())?
96 }
97 OutputFormat::ChatmlJson => serde_json::to_string_pretty(&format_chatml(&data, &args))
98 .map_err(|error| error.to_string())?,
99 OutputFormat::ChatmlJsonl => format_chatml(&data, &args)
100 .iter()
101 .map(serde_json::to_string)
102 .collect::<Result<Vec<_>, _>>()
103 .map_err(|error| error.to_string())?
104 .join("\n"),
105 };
106 write_output(&output, args.output.as_deref())?;
107 if let Some(path) = args.output {
108 eprintln!("评论已保存: {}", path.display());
109 }
110 Ok(())
111}
112
113struct CommentCrawler {
114 client: Client,
115 user_agent: String,
116}
117
118impl CommentCrawler {
119 fn new(cookie: &str, user_agent: &str) -> Result<Self, String> {
120 let mut headers = HeaderMap::new();
121 headers.insert(
122 ACCEPT,
123 HeaderValue::from_static("application/json, text/plain, */*"),
124 );
125 headers.insert(ACCEPT_LANGUAGE, HeaderValue::from_static("zh-CN,zh;q=0.9"));
126 headers.insert(REFERER, HeaderValue::from_static("https://www.douyin.com/"));
127 headers.insert(
128 USER_AGENT,
129 HeaderValue::from_str(user_agent).map_err(|error| error.to_string())?,
130 );
131 headers.insert(
132 COOKIE,
133 HeaderValue::from_str(cookie).map_err(|error| error.to_string())?,
134 );
135 let client = Client::builder()
136 .default_headers(headers)
137 .connect_timeout(Duration::from_secs(10))
138 .timeout(Duration::from_secs(30))
139 .build()
140 .map_err(|error| error.to_string())?;
141 Ok(Self {
142 client,
143 user_agent: user_agent.to_owned(),
144 })
145 }
146
147 fn crawl(&self, aweme_id: &str, args: &CommentArgs) -> Result<Value, String> {
148 let mut comments = Vec::new();
149 let mut cursor = 0_i64;
150 let mut has_more = true;
151 while has_more && !reached_limit(comments.len(), args.limit) {
152 let page = self.fetch_page(
153 COMMENT_LIST,
154 vec![
155 ("aweme_id", aweme_id.to_owned()),
156 ("cursor", cursor.to_string()),
157 ("count", args.count.to_string()),
158 ("item_type", "0".to_owned()),
159 ],
160 )?;
161 let values = page
162 .get("comments")
163 .and_then(Value::as_array)
164 .cloned()
165 .unwrap_or_default();
166 if values.is_empty() {
167 break;
168 }
169 for raw in values {
170 let mut comment = normalize_comment(&raw);
171 if args.with_replies {
172 let comment_id = comment.get("id").and_then(Value::as_str).unwrap_or("");
173 comment["replies"] =
174 Value::Array(self.crawl_replies(aweme_id, comment_id, args)?);
175 }
176 comments.push(comment);
177 if reached_limit(comments.len(), args.limit) {
178 break;
179 }
180 }
181 cursor = page.get("cursor").and_then(Value::as_i64).unwrap_or(0);
182 has_more = truthy(page.get("has_more"));
183 pause(has_more, args.sleep_seconds);
184 }
185 Ok(json!({"aweme_id": aweme_id, "comments": comments}))
186 }
187
188 fn crawl_replies(
189 &self,
190 aweme_id: &str,
191 comment_id: &str,
192 args: &CommentArgs,
193 ) -> Result<Vec<Value>, String> {
194 let mut replies = Vec::new();
195 let mut cursor = 0_i64;
196 let mut has_more = true;
197 while has_more && !reached_limit(replies.len(), args.reply_limit) {
198 let page = self.fetch_page(
199 COMMENT_REPLIES,
200 vec![
201 ("item_id", aweme_id.to_owned()),
202 ("comment_id", comment_id.to_owned()),
203 ("cursor", cursor.to_string()),
204 ("count", args.count.to_string()),
205 ("item_type", "0".to_owned()),
206 ],
207 )?;
208 let values = page
209 .get("comments")
210 .and_then(Value::as_array)
211 .cloned()
212 .unwrap_or_default();
213 if values.is_empty() {
214 break;
215 }
216 for raw in values {
217 replies.push(normalize_comment(&raw));
218 if reached_limit(replies.len(), args.reply_limit) {
219 break;
220 }
221 }
222 cursor = page.get("cursor").and_then(Value::as_i64).unwrap_or(0);
223 has_more = truthy(page.get("has_more"));
224 pause(has_more, args.sleep_seconds);
225 }
226 Ok(replies)
227 }
228
229 fn fetch_page(&self, path: &str, mut params: Vec<(&str, String)>) -> Result<Value, String> {
230 params.extend([
231 ("device_platform", "webapp".to_owned()),
232 ("aid", "6383".to_owned()),
233 ("channel", "channel_pc_web".to_owned()),
234 ]);
235 let query = params
236 .iter()
237 .map(|(key, value)| {
238 let encoded: String =
239 url::form_urlencoded::byte_serialize(value.as_bytes()).collect();
240 format!("{key}={encoded}")
241 })
242 .collect::<Vec<_>>()
243 .join("&");
244 let sign_function = if path.contains("reply") {
245 "sign_reply"
246 } else {
247 "sign_datail"
248 };
249 let signature = sign(sign_function, &query, &self.user_agent)?;
250 params.push(("a_bogus", signature));
251 let response = self
252 .client
253 .get(format!("{BASE_URL}{path}"))
254 .query(¶ms)
255 .send()
256 .map_err(|error| error.to_string())?;
257 let status = response.status();
258 let text = response.text().map_err(|error| error.to_string())?;
259 if !status.is_success() {
260 return Err(format!("评论请求失败: {status} {text}"));
261 }
262 if text.is_empty() {
263 return Err("响应体为空,Cookie 可能已失效".to_owned());
264 }
265 let data: Value =
266 serde_json::from_str(&text).map_err(|error| format!("评论响应不是 JSON: {error}"))?;
267 if contains_verify_check(&data) {
268 return Err("触发验证码,请完成验证后再继续".to_owned());
269 }
270 if data.get("status_code").and_then(Value::as_i64).unwrap_or(0) != 0 {
271 return Err(format!("评论接口返回失败状态: {text}"));
272 }
273 Ok(data)
274 }
275}
276
277pub(crate) fn sign(function: &str, query: &str, user_agent: &str) -> Result<String, String> {
278 let query = serde_json::to_string(query).map_err(|error| error.to_string())?;
279 let user_agent = serde_json::to_string(user_agent).map_err(|error| error.to_string())?;
280 let script = format!("{SIGN_SCRIPT}\nprocess.stdout.write({function}({query}, {user_agent}));");
281 let output = Command::new("node")
282 .arg("-e")
283 .arg(script)
284 .output()
285 .map_err(|error| {
286 format!("无法启动 Node.js 签名运行时: {error}。评论抓取需要 node 命令。")
287 })?;
288 if !output.status.success() {
289 return Err(format!(
290 "生成 a_bogus 失败: {}",
291 String::from_utf8_lossy(&output.stderr).trim()
292 ));
293 }
294 let value = String::from_utf8(output.stdout).map_err(|error| error.to_string())?;
295 if value.trim().is_empty() {
296 Err("生成 a_bogus 得到空结果".to_owned())
297 } else {
298 Ok(value)
299 }
300}
301
302pub fn extract_aweme_id(target: &str) -> Result<String, String> {
303 let target = target.trim();
304 if target.chars().all(|value| value.is_ascii_digit()) && !target.is_empty() {
305 return Ok(target.to_owned());
306 }
307 let mut url = reqwest::Url::parse(target).map_err(|_| format!("无法识别作品 ID: {target}"))?;
308 if url.host_str() == Some("v.douyin.com") {
309 url = Client::builder()
310 .timeout(Duration::from_secs(15))
311 .build()
312 .map_err(|error| error.to_string())?
313 .get(url)
314 .send()
315 .map_err(|error| error.to_string())?
316 .url()
317 .clone();
318 }
319 let parts: Vec<_> = url
320 .path_segments()
321 .into_iter()
322 .flatten()
323 .filter(|value| !value.is_empty())
324 .collect();
325 for marker in ["video", "note"] {
326 if let Some(index) = parts.iter().position(|value| *value == marker)
327 && let Some(value) = parts
328 .get(index + 1)
329 .filter(|value| value.chars().all(|c| c.is_ascii_digit()))
330 {
331 return Ok((*value).to_owned());
332 }
333 }
334 parts
335 .last()
336 .filter(|value| value.chars().all(|c| c.is_ascii_digit()))
337 .map(|value| (*value).to_owned())
338 .ok_or_else(|| format!("无法识别作品 ID: {target}"))
339}
340
341pub fn normalize_comment(comment: &Value) -> Value {
342 let user = comment.get("user").and_then(Value::as_object);
343 json!({
344 "id": first_string(comment, &["cid", "comment_id"]),
345 "text": first_string(comment, &["text"]),
346 "create_time": comment.get("create_time").cloned().unwrap_or(Value::Null),
347 "digg_count": comment.get("digg_count").cloned().unwrap_or_else(|| json!(0)),
348 "reply_comment_total": comment.get("reply_comment_total").cloned().unwrap_or_else(|| json!(0)),
349 "ip_label": first_string(comment, &["ip_label"]),
350 "user": {
351 "uid": object_string(user, "uid"), "sec_uid": object_string(user, "sec_uid"),
352 "nickname": object_string(user, "nickname"), "unique_id": object_string(user, "unique_id")
353 }
354 })
355}
356
357fn format_chatml(data: &Value, args: &CommentArgs) -> Vec<Value> {
358 let aweme_id = data.get("aweme_id").and_then(Value::as_str).unwrap_or("");
359 let mut records = Vec::new();
360 for comment in data
361 .get("comments")
362 .and_then(Value::as_array)
363 .into_iter()
364 .flatten()
365 {
366 let text = comment
367 .get("text")
368 .and_then(Value::as_str)
369 .unwrap_or("")
370 .trim();
371 if text.is_empty() || digg(comment) < args.min_comment_digg {
372 continue;
373 }
374 let replies = comment
375 .get("replies")
376 .and_then(Value::as_array)
377 .cloned()
378 .unwrap_or_default();
379 if replies.is_empty() && args.include_single_comments {
380 records.push(json!({
381 "messages":[{"role":args.comment_role,"content":text}],
382 "metadata": metadata(aweme_id, comment, None)
383 }));
384 } else {
385 for reply in replies {
386 let reply_text = reply
387 .get("text")
388 .and_then(Value::as_str)
389 .unwrap_or("")
390 .trim();
391 if reply_text.is_empty() || digg(&reply) < args.min_reply_digg {
392 continue;
393 }
394 records.push(json!({
395 "messages":[{"role":args.comment_role,"content":text},{"role":args.reply_role,"content":reply_text}],
396 "metadata": metadata(aweme_id, comment, Some(&reply))
397 }));
398 }
399 }
400 }
401 records
402}
403
404fn metadata(aweme_id: &str, comment: &Value, reply: Option<&Value>) -> Value {
405 let mut result = Map::from_iter([
406 (
407 "source".to_owned(),
408 json!(if reply.is_some() {
409 "douyin_comment_reply"
410 } else {
411 "douyin_comment"
412 }),
413 ),
414 ("aweme_id".to_owned(), json!(aweme_id)),
415 (
416 "comment_id".to_owned(),
417 json!(first_string(comment, &["id"])),
418 ),
419 ("comment_digg_count".to_owned(), json!(digg(comment))),
420 (
421 "comment_create_time".to_owned(),
422 comment.get("create_time").cloned().unwrap_or(Value::Null),
423 ),
424 (
425 "comment_user".to_owned(),
426 user_metadata(comment.get("user")),
427 ),
428 (
429 "quality_score".to_owned(),
430 json!(digg(comment) + reply.map(digg).unwrap_or(0)),
431 ),
432 ]);
433 if let Some(reply) = reply {
434 result.extend([
435 ("reply_id".to_owned(), json!(first_string(reply, &["id"]))),
436 ("reply_digg_count".to_owned(), json!(digg(reply))),
437 (
438 "reply_create_time".to_owned(),
439 reply.get("create_time").cloned().unwrap_or(Value::Null),
440 ),
441 ("reply_user".to_owned(), user_metadata(reply.get("user"))),
442 ]);
443 }
444 Value::Object(result)
445}
446
447fn user_metadata(user: Option<&Value>) -> Value {
448 let object = user.and_then(Value::as_object);
449 json!({"uid":object_string(object,"uid"),"sec_uid":object_string(object,"sec_uid"),"nickname":object_string(object,"nickname"),"unique_id":object_string(object,"unique_id")})
450}
451
452fn first_string(value: &Value, keys: &[&str]) -> String {
453 keys.iter()
454 .find_map(|key| {
455 value
456 .get(key)
457 .and_then(Value::as_str)
458 .filter(|value| !value.is_empty())
459 })
460 .unwrap_or("")
461 .to_owned()
462}
463
464fn object_string(object: Option<&Map<String, Value>>, key: &str) -> String {
465 object
466 .and_then(|value| value.get(key))
467 .and_then(Value::as_str)
468 .unwrap_or("")
469 .to_owned()
470}
471
472fn digg(value: &Value) -> i64 {
473 value
474 .get("digg_count")
475 .and_then(|value| value.as_i64().or_else(|| value.as_str()?.parse().ok()))
476 .unwrap_or(0)
477}
478
479fn truthy(value: Option<&Value>) -> bool {
480 value.is_some_and(|value| {
481 value
482 .as_bool()
483 .unwrap_or_else(|| value.as_i64().unwrap_or(0) != 0)
484 })
485}
486
487fn reached_limit(length: usize, limit: usize) -> bool {
488 limit > 0 && length >= limit
489}
490
491fn pause(has_more: bool, seconds: f64) {
492 if has_more && seconds > 0.0 {
493 thread::sleep(Duration::from_secs_f64(seconds));
494 }
495}
496
497fn contains_verify_check(value: &Value) -> bool {
498 match value {
499 Value::Object(values) => values
500 .iter()
501 .any(|(key, value)| key == "verify_check" || contains_verify_check(value)),
502 Value::Array(values) => values.iter().any(contains_verify_check),
503 Value::String(value) => value == "verify_check",
504 _ => false,
505 }
506}
507
508fn write_output(text: &str, path: Option<&Path>) -> Result<(), String> {
509 if let Some(path) = path {
510 if let Some(parent) = path
511 .parent()
512 .filter(|parent| !parent.as_os_str().is_empty())
513 {
514 fs::create_dir_all(parent).map_err(|error| error.to_string())?;
515 }
516 fs::write(path, format!("{text}\n")).map_err(|error| error.to_string())
517 } else {
518 println!("{text}");
519 Ok(())
520 }
521}
522
523#[cfg(test)]
524mod tests {
525 use super::{
526 CommentArgs, OutputFormat, extract_aweme_id, format_chatml, normalize_comment, sign,
527 };
528 use serde_json::json;
529
530 #[test]
531 fn extracts_raw_and_url_aweme_ids() {
532 assert_eq!(
533 extract_aweme_id("7380000000000000000").unwrap(),
534 "7380000000000000000"
535 );
536 assert_eq!(
537 extract_aweme_id("https://www.douyin.com/video/7380000000000000000?x=1").unwrap(),
538 "7380000000000000000"
539 );
540 assert_eq!(
541 extract_aweme_id("https://www.douyin.com/note/7380000000000000000").unwrap(),
542 "7380000000000000000"
543 );
544 }
545
546 #[test]
547 fn normalizes_comment_fields() {
548 let value = normalize_comment(&json!({
549 "cid":"1","text":"你好","create_time":1710000000,"digg_count":3,"reply_comment_total":2,"ip_label":"上海",
550 "user":{"uid":"u1","sec_uid":"sec","nickname":"用户","unique_id":"unique"}
551 }));
552 assert_eq!(value["id"], "1");
553 assert_eq!(value["user"]["nickname"], "用户");
554 assert_eq!(value["digg_count"], 3);
555 }
556
557 #[test]
558 fn bundled_signer_returns_a_bogus_value() {
559 let value = sign(
560 "sign_datail",
561 "aweme_id=7380000000000000000&device_platform=webapp&aid=6383",
562 super::DEFAULT_USER_AGENT,
563 )
564 .unwrap();
565 assert!(value.ends_with('='));
566 assert!(value.len() > 20);
567 }
568
569 #[test]
570 fn chatml_pairs_comments_and_replies() {
571 let args = CommentArgs {
572 target: String::new(),
573 limit: 100,
574 count: 20,
575 with_replies: true,
576 reply_limit: 20,
577 sleep_seconds: 0.0,
578 output: None,
579 output_format: OutputFormat::ChatmlJsonl,
580 comment_role: "user".to_owned(),
581 reply_role: "assistant".to_owned(),
582 min_comment_digg: 0,
583 min_reply_digg: 0,
584 include_single_comments: false,
585 cookie: None,
586 };
587 let records = format_chatml(
588 &json!({
589 "aweme_id":"7380000000000000000",
590 "comments":[{"id":"c1","text":"这车能买吗?","digg_count":8,"user":{},"replies":[
591 {"id":"r1","text":"先查维保和事故。","digg_count":12,"user":{}}
592 ]}]
593 }),
594 &args,
595 );
596 assert_eq!(records[0]["messages"][0]["role"], "user");
597 assert_eq!(records[0]["messages"][1]["content"], "先查维保和事故。");
598 assert_eq!(records[0]["metadata"]["quality_score"], 20);
599 }
600}