Skip to main content

douyin_cli/
comments.rs

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