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