Skip to main content

douyin_cli/
comments.rs

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    /// 作品 ID、视频 URL 或图文 URL
24    target: String,
25    /// 最多抓取一级评论数,0 表示不限制
26    #[arg(short, long, default_value_t = 100)]
27    limit: usize,
28    /// 每页请求数量
29    #[arg(long, default_value_t = 20)]
30    count: usize,
31    /// 同时抓取评论楼中楼回复
32    #[arg(long)]
33    with_replies: bool,
34    /// 每条评论最多抓取回复数,0 表示不限制
35    #[arg(long, default_value_t = 20)]
36    reply_limit: usize,
37    /// 分页请求间隔秒数
38    #[arg(long = "sleep", default_value_t = 0.8)]
39    sleep_seconds: f64,
40    /// 输出文件;不传则输出到 stdout
41    #[arg(short, long)]
42    output: Option<PathBuf>,
43    /// 输出格式
44    #[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    /// 本次运行使用的 Cookie;默认读取保存的 Cookie
57    #[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(&params)
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}