Skip to main content

douyin_cli/
insights.rs

1//! Deterministic, offline text-frequency heuristics for content insights.
2
3use std::collections::HashMap;
4use std::fs;
5use std::io::{self, Read};
6use std::path::{Path, PathBuf};
7
8use clap::{Args, ValueEnum};
9use serde_json::{Map, Value, json};
10
11use crate::fs_utils;
12
13const DEFAULT_TOP: usize = 20;
14const DEFAULT_MIN_COUNT: u64 = 2;
15const STOP_WORDS: &[&str] = &[
16    "一个",
17    "一些",
18    "不是",
19    "不要",
20    "不过",
21    "为了",
22    "为什么",
23    "什么",
24    "他们",
25    "你们",
26    "但是",
27    "可以",
28    "可能",
29    "因为",
30    "所以",
31    "这个",
32    "那个",
33    "这些",
34    "那些",
35    "真的",
36    "已经",
37    "还是",
38    "就是",
39    "然后",
40    "如果",
41    "我们",
42    "我的",
43    "你的",
44    "他的",
45    "她的",
46    "它的",
47    "怎么",
48    "怎样",
49    "哪里",
50    "有没有",
51    "能不能",
52    "需要",
53    "想要",
54    "推荐",
55    "多少",
56    "多少钱",
57    "链接",
58    "教程",
59    "求",
60    "的",
61    "了",
62    "呢",
63    "吗",
64    "啊",
65    "呀",
66    "吧",
67    "也",
68    "都",
69    "和",
70    "与",
71    "或",
72    "在",
73    "是",
74    "有",
75    "就",
76    "很",
77    "太",
78    "还",
79    "又",
80    "被",
81    "把",
82    "给",
83    "到",
84    "从",
85    "对",
86    "让",
87    "要",
88];
89const ENGLISH_STOP_WORDS: &[&str] = &[
90    "a", "an", "and", "are", "as", "at", "be", "but", "by", "for", "from", "has", "have", "he",
91    "her", "his", "i", "in", "is", "it", "me", "my", "not", "of", "on", "or", "our", "she", "so",
92    "that", "the", "their", "them", "they", "this", "to", "was", "we", "were", "what", "when",
93    "where", "which", "who", "why", "with", "you", "your",
94];
95const DEMAND_SIGNALS: &[&str] = &[
96    "想要",
97    "求",
98    "哪里",
99    "怎么",
100    "有没有",
101    "能不能",
102    "需要",
103    "推荐",
104    "多少钱",
105    "链接",
106    "教程",
107    "怎么买",
108    "在哪买",
109    "同款",
110    "价格",
111    "求助",
112    "如何",
113    "问题",
114    "希望",
115    "建议",
116    "支持",
117];
118const MEME_MARKERS: &[&str] = &[
119    "绝绝子",
120    "笑死",
121    "谁懂啊",
122    "太真实",
123    "这谁顶得住",
124    "我哭死",
125    "绷不住",
126    "破防了",
127    "yyds",
128    "YYDS",
129    "666",
130];
131
132/// One input text and its optional interaction-derived weight.
133#[derive(Clone, Debug, PartialEq, Eq)]
134pub struct TextRecord {
135    pub text: String,
136    pub weight: Option<u64>,
137}
138
139impl TextRecord {
140    pub fn new(text: impl Into<String>, weight: Option<u64>) -> Self {
141        Self {
142            text: text.into(),
143            weight,
144        }
145    }
146}
147
148#[derive(Clone, Copy, Debug, ValueEnum)]
149pub enum OutputFormat {
150    Json,
151    Markdown,
152}
153
154/// Discover hot words, memes, and demand-like source lines from local text.
155#[derive(Debug, Args)]
156pub struct InsightsArgs {
157    /// 输入文件路径,或 - 从 stdin 读取
158    input: String,
159    /// 每类结果最多输出条数
160    #[arg(long, default_value_t = DEFAULT_TOP)]
161    top: usize,
162    /// 结果至少出现次数
163    #[arg(long, default_value_t = DEFAULT_MIN_COUNT)]
164    min_count: u64,
165    /// 输出格式
166    #[arg(long, value_enum, default_value_t = OutputFormat::Json)]
167    format: OutputFormat,
168    /// 输出文件;不传则输出到 stdout
169    #[arg(short, long)]
170    output: Option<PathBuf>,
171}
172
173pub fn run(args: InsightsArgs) -> Result<(), String> {
174    let input = read_input(&args.input)?;
175    let records = parse_records(&input);
176    let result = analyze(&records, args.top, args.min_count);
177    let rendered = match args.format {
178        OutputFormat::Json => {
179            serde_json::to_string_pretty(&result).map_err(|error| error.to_string())?
180        }
181        OutputFormat::Markdown => render_markdown(&result),
182    };
183    write_output(&rendered, args.output.as_deref())
184}
185
186/// Analyze records without network access or semantic-model inference.
187pub fn analyze(records: &[TextRecord], top: usize, min_count: u64) -> Value {
188    let mut words: HashMap<String, Aggregate> = HashMap::new();
189    let mut memes: HashMap<String, Aggregate> = HashMap::new();
190    let mut demands: HashMap<String, DemandAggregate> = HashMap::new();
191
192    for record in records {
193        let text = normalize_space(&record.text);
194        if text.is_empty() {
195            continue;
196        }
197        let weight = record.weight.unwrap_or(1).max(1);
198        for word in word_tokens(&text) {
199            words.entry(word).or_default().add(weight);
200        }
201        for meme in meme_candidates(&text) {
202            memes.entry(meme).or_default().add(weight);
203        }
204        let signals: Vec<_> = DEMAND_SIGNALS
205            .iter()
206            .filter(|signal| text.contains(**signal))
207            .map(|signal| (*signal).to_owned())
208            .collect();
209        if !signals.is_empty() {
210            demands
211                .entry(text)
212                .or_default()
213                .add(weight, signals.into_iter());
214        }
215    }
216
217    json!({
218        "input_count": records.len(),
219        "hot_words": ranked_aggregates(words, top, min_count),
220        "hot_memes": ranked_aggregates(memes, top, min_count),
221        "demands": ranked_demands(demands, top, min_count),
222    })
223}
224
225/// Parse JSON, JSONL, or non-empty plain-text lines into weighted records.
226pub fn parse_records(input: &str) -> Vec<TextRecord> {
227    if let Ok(value) = serde_json::from_str::<Value>(input)
228        && matches!(
229            &value,
230            Value::Object(_) | Value::Array(_) | Value::String(_)
231        )
232    {
233        let mut records = Vec::new();
234        extract_value(&value, 1, &mut records);
235        if !records.is_empty() {
236            return records;
237        }
238    }
239
240    let lines: Vec<_> = input
241        .lines()
242        .filter(|line| !line.trim().is_empty())
243        .collect();
244    if !lines.is_empty() {
245        let parsed: Result<Vec<Value>, _> = lines
246            .iter()
247            .map(|line| serde_json::from_str::<Value>(line))
248            .collect();
249        if let Ok(values) = parsed {
250            let mut records = Vec::new();
251            for value in values {
252                let mut extracted = Vec::new();
253                extract_value(&value, 1, &mut extracted);
254                if extracted.is_empty() {
255                    records.clear();
256                    break;
257                }
258                records.extend(extracted);
259            }
260            if !records.is_empty() {
261                return records;
262            }
263        }
264    }
265
266    lines
267        .into_iter()
268        .map(|line| TextRecord::new(line.trim(), None))
269        .collect()
270}
271
272fn read_input(input: &str) -> Result<String, String> {
273    if input == "-" {
274        let mut text = String::new();
275        io::stdin()
276            .read_to_string(&mut text)
277            .map_err(|error| error.to_string())?;
278        Ok(text)
279    } else {
280        fs::read_to_string(input).map_err(|error| format!("无法读取 {input}: {error}"))
281    }
282}
283
284fn write_output(text: &str, output: Option<&Path>) -> Result<(), String> {
285    if let Some(path) = output {
286        fs_utils::atomic_write(path, format!("{text}\n").as_bytes())
287            .map_err(|error| error.to_string())
288    } else {
289        println!("{text}");
290        Ok(())
291    }
292}
293
294fn extract_value(value: &Value, inherited_weight: u64, records: &mut Vec<TextRecord>) {
295    match value {
296        Value::String(text) => push_record(records, text, inherited_weight),
297        Value::Array(values) => {
298            for value in values {
299                extract_value(value, inherited_weight, records);
300            }
301        }
302        Value::Object(object) => {
303            let metadata_weight = object
304                .get("metadata")
305                .and_then(Value::as_object)
306                .and_then(object_weight);
307            let weight = object_weight(object)
308                .or(metadata_weight)
309                .unwrap_or(inherited_weight)
310                .max(1);
311            if let Some(messages) = object.get("messages").and_then(Value::as_array) {
312                for message in messages {
313                    if let Some(content) = message.get("content").and_then(Value::as_str) {
314                        push_record(records, content, weight);
315                    }
316                }
317            } else {
318                for key in ["text", "desc", "content"] {
319                    if let Some(text) = object.get(key).and_then(Value::as_str) {
320                        push_record(records, text, weight);
321                    }
322                }
323                if let Some(tag) = object.get("tag") {
324                    extract_tag(tag, weight, records);
325                }
326                if let Some(text_extra) = object.get("text_extra") {
327                    extract_text_extra(text_extra, weight, records);
328                }
329            }
330            for (key, nested) in object {
331                if matches!(
332                    key.as_str(),
333                    "messages"
334                        | "text"
335                        | "desc"
336                        | "content"
337                        | "tag"
338                        | "text_extra"
339                        | "weight"
340                        | "digg_count"
341                        | "quality_score"
342                        | "score"
343                ) {
344                    continue;
345                }
346                if nested.is_array() || nested.is_object() {
347                    extract_value(nested, weight, records);
348                }
349            }
350        }
351        _ => {}
352    }
353}
354
355fn extract_text_extra(value: &Value, weight: u64, records: &mut Vec<TextRecord>) {
356    match value {
357        Value::Array(values) => {
358            for value in values {
359                extract_text_extra(value, weight, records);
360            }
361        }
362        Value::Object(object) => {
363            if let Some(tag_name) = object.get("tag_name").and_then(Value::as_str) {
364                let tag_name = tag_name.trim();
365                if !tag_name.is_empty() {
366                    let topic = if tag_name.starts_with('#') {
367                        tag_name.to_owned()
368                    } else {
369                        format!("#{tag_name}")
370                    };
371                    push_record(records, &topic, weight);
372                }
373            }
374        }
375        _ => {}
376    }
377}
378
379fn extract_tag(value: &Value, weight: u64, records: &mut Vec<TextRecord>) {
380    match value {
381        Value::String(text) => push_record(records, text, weight),
382        Value::Array(values) => {
383            for value in values {
384                extract_tag(value, weight, records);
385            }
386        }
387        Value::Object(object) => {
388            for key in ["text", "name", "title", "tag_name"] {
389                if let Some(text) = object.get(key).and_then(Value::as_str) {
390                    push_record(records, text, weight);
391                }
392            }
393        }
394        _ => {}
395    }
396}
397
398fn push_record(records: &mut Vec<TextRecord>, text: &str, weight: u64) {
399    let text = text.trim();
400    if !text.is_empty() {
401        records.push(TextRecord::new(text, Some(weight)));
402    }
403}
404
405fn object_weight(object: &Map<String, Value>) -> Option<u64> {
406    ["weight", "quality_score", "digg_count", "score"]
407        .into_iter()
408        .find_map(|key| numeric_value(object.get(key)?))
409        .map(|value| value.saturating_add(1))
410}
411
412fn numeric_value(value: &Value) -> Option<u64> {
413    value
414        .as_u64()
415        .or_else(|| value.as_i64().and_then(|value| value.try_into().ok()))
416        .or_else(|| value.as_str().and_then(|value| value.parse().ok()))
417}
418
419#[derive(Default)]
420struct Aggregate {
421    count: u64,
422    score: u64,
423}
424
425impl Aggregate {
426    fn add(&mut self, weight: u64) {
427        self.count = self.count.saturating_add(1);
428        self.score = self.score.saturating_add(weight);
429    }
430}
431
432#[derive(Default)]
433struct DemandAggregate {
434    count: u64,
435    score: u64,
436    signals: Vec<String>,
437}
438
439impl DemandAggregate {
440    fn add(&mut self, weight: u64, signals: impl Iterator<Item = String>) {
441        self.count = self.count.saturating_add(1);
442        self.score = self.score.saturating_add(weight);
443        for signal in signals {
444            if !self.signals.contains(&signal) {
445                self.signals.push(signal);
446            }
447        }
448    }
449}
450
451fn ranked_aggregates(values: HashMap<String, Aggregate>, top: usize, min_count: u64) -> Vec<Value> {
452    let mut values: Vec<_> = values
453        .into_iter()
454        .filter(|(_, value)| value.count >= min_count)
455        .collect();
456    values.sort_by(|left, right| {
457        right
458            .1
459            .score
460            .cmp(&left.1.score)
461            .then_with(|| right.1.count.cmp(&left.1.count))
462            .then_with(|| left.0.cmp(&right.0))
463    });
464    values
465        .into_iter()
466        .take(top)
467        .map(|(text, value)| json!({"text":text, "count":value.count, "score":value.score}))
468        .collect()
469}
470
471fn ranked_demands(
472    values: HashMap<String, DemandAggregate>,
473    top: usize,
474    min_count: u64,
475) -> Vec<Value> {
476    let mut values: Vec<_> = values
477        .into_iter()
478        .filter(|(_, value)| value.count >= min_count)
479        .collect();
480    values.sort_by(|left, right| {
481        right
482            .1
483            .score
484            .cmp(&left.1.score)
485            .then_with(|| right.1.count.cmp(&left.1.count))
486            .then_with(|| left.0.cmp(&right.0))
487    });
488    values
489        .into_iter()
490        .take(top)
491        .map(|(text, value)| {
492            json!({"text":text, "count":value.count, "score":value.score, "signals":value.signals})
493        })
494        .collect()
495}
496
497fn word_tokens(text: &str) -> Vec<String> {
498    let mut tokens = Vec::new();
499    let chars: Vec<_> = text.chars().collect();
500    let mut index = 0;
501    while index < chars.len() {
502        if chars[index] == '#' {
503            let start = index;
504            index += 1;
505            while index < chars.len() && is_word_char(chars[index]) {
506                index += 1;
507            }
508            if index > start + 1 {
509                tokens.push(chars[start..index].iter().collect::<String>());
510            }
511        } else if chars[index].is_ascii_alphanumeric() {
512            let start = index;
513            index += 1;
514            while index < chars.len()
515                && (chars[index].is_ascii_alphanumeric() || matches!(chars[index], '_' | '-' | '.'))
516            {
517                index += 1;
518            }
519            let token = chars[start..index]
520                .iter()
521                .collect::<String>()
522                .to_ascii_lowercase();
523            if !ENGLISH_STOP_WORDS.contains(&token.as_str()) {
524                tokens.push(token);
525            }
526        } else if is_han(chars[index]) {
527            let start = index;
528            index += 1;
529            while index < chars.len() && is_han(chars[index]) {
530                index += 1;
531            }
532            let segment = chars[start..index].iter().collect::<String>();
533            for chunk in split_han_chunks(&segment) {
534                let length = chunk.chars().count();
535                if (2..=8).contains(&length) && !STOP_WORDS.contains(&chunk.as_str()) {
536                    tokens.push(chunk.clone());
537                }
538                if length > 4 {
539                    let chunk_chars: Vec<_> = chunk.chars().collect();
540                    for size in 2..=4 {
541                        for window in chunk_chars.windows(size) {
542                            let token = window.iter().collect::<String>();
543                            if !STOP_WORDS.contains(&token.as_str()) {
544                                tokens.push(token);
545                            }
546                        }
547                    }
548                }
549            }
550        } else {
551            index += 1;
552        }
553    }
554    tokens
555}
556
557fn split_han_chunks(segment: &str) -> Vec<String> {
558    let mut chunks = Vec::new();
559    let mut current = String::new();
560    let mut offset = 0;
561    while offset < segment.len() {
562        let rest = &segment[offset..];
563        let stop = STOP_WORDS
564            .iter()
565            .filter(|word| rest.starts_with(**word))
566            .max_by_key(|word| word.len());
567        if let Some(stop) = stop {
568            if !current.is_empty() {
569                chunks.push(std::mem::take(&mut current));
570            }
571            offset += stop.len();
572        } else if let Some(character) = rest.chars().next() {
573            current.push(character);
574            offset += character.len_utf8();
575        }
576    }
577    if !current.is_empty() {
578        chunks.push(current);
579    }
580    chunks
581}
582
583fn meme_candidates(text: &str) -> Vec<String> {
584    let mut candidates = Vec::new();
585    for clause in text.split(|character: char| {
586        character.is_whitespace()
587            || matches!(
588                character,
589                ',' | '。' | '!' | '?' | ',' | '.' | '!' | '?' | ';' | ';' | ':' | ':'
590            )
591    }) {
592        let clause = clause.trim();
593        let length = clause.chars().count();
594        if (2..=18).contains(&length)
595            && (MEME_MARKERS.iter().any(|marker| clause.contains(marker))
596                || clause.chars().any(is_emoji)
597                || length <= 10)
598        {
599            candidates.push(clause.to_owned());
600        }
601        let emoji: String = clause
602            .chars()
603            .filter(|character| is_emoji(*character))
604            .collect();
605        if !emoji.is_empty() {
606            candidates.push(emoji);
607        }
608    }
609    candidates.sort();
610    candidates.dedup();
611    candidates
612}
613
614fn normalize_space(text: &str) -> String {
615    text.split_whitespace().collect::<Vec<_>>().join(" ")
616}
617
618fn is_word_char(character: char) -> bool {
619    is_han(character) || character.is_ascii_alphanumeric() || character == '_'
620}
621
622fn is_han(character: char) -> bool {
623    matches!(character, '\u{3400}'..='\u{4dbf}' | '\u{4e00}'..='\u{9fff}' | '\u{f900}'..='\u{faff}')
624}
625
626fn is_emoji(character: char) -> bool {
627    matches!(
628        character,
629        '\u{1f000}'..='\u{1faff}' | '\u{2600}'..='\u{26ff}' | '\u{2700}'..='\u{27bf}'
630    )
631}
632
633fn render_markdown(result: &Value) -> String {
634    let mut output = format!("# 内容洞察\n\n输入记录:{}\n", result["input_count"]);
635    for (title, key) in [
636        ("热词", "hot_words"),
637        ("热梗", "hot_memes"),
638        ("需求发现", "demands"),
639    ] {
640        output.push_str(&format!("\n## {title}\n\n"));
641        let Some(items) = result[key].as_array() else {
642            continue;
643        };
644        if items.is_empty() {
645            output.push_str("- 无\n");
646            continue;
647        }
648        for item in items {
649            let signals = item["signals"]
650                .as_array()
651                .map(|values| {
652                    values
653                        .iter()
654                        .filter_map(Value::as_str)
655                        .collect::<Vec<_>>()
656                        .join("、")
657                })
658                .filter(|value| !value.is_empty())
659                .map(|value| format!(";信号:{value}"))
660                .unwrap_or_default();
661            output.push_str(&format!(
662                "- {}(次数:{},分数:{}{signals})\n",
663                item["text"].as_str().unwrap_or(""),
664                item["count"],
665                item["score"]
666            ));
667        }
668    }
669    output
670}
671
672#[cfg(test)]
673mod tests {
674    use super::{TextRecord, analyze, parse_records};
675    use serde_json::json;
676
677    #[test]
678    fn hot_words_exclude_stop_words_and_keep_topic() {
679        let result = analyze(
680            &[
681                TextRecord::new("这个露营灯真的好用 #露营装备", None),
682                TextRecord::new("露营灯推荐 #露营装备", None),
683            ],
684            20,
685            2,
686        );
687        assert!(
688            result["hot_words"]
689                .as_array()
690                .unwrap()
691                .iter()
692                .any(|value| value["text"] == "露营灯" && value["count"] == 2)
693        );
694        assert!(
695            result["hot_words"]
696                .as_array()
697                .unwrap()
698                .iter()
699                .any(|value| value["text"] == "#露营装备" && value["count"] == 2)
700        );
701        assert!(
702            !result["hot_words"]
703                .as_array()
704                .unwrap()
705                .iter()
706                .any(|value| value["text"] == "这个" || value["text"] == "真的")
707        );
708    }
709
710    #[test]
711    fn repeated_meme_requires_minimum_count() {
712        let result = analyze(
713            &[
714                TextRecord::new("绝绝子 😂", None),
715                TextRecord::new("绝绝子 😂", None),
716                TextRecord::new("只出现一次", None),
717            ],
718            20,
719            2,
720        );
721        assert!(
722            result["hot_memes"]
723                .as_array()
724                .unwrap()
725                .iter()
726                .any(|value| value["text"] == "绝绝子" && value["count"] == 2)
727        );
728    }
729
730    #[test]
731    fn demands_include_signals_and_sort_by_interaction_weight() {
732        let result = analyze(
733            &[
734                TextRecord::new("求购买链接", Some(3)),
735                TextRecord::new("求购买链接", Some(3)),
736                TextRecord::new("怎么安装教程", Some(10)),
737                TextRecord::new("怎么安装教程", Some(10)),
738            ],
739            20,
740            2,
741        );
742        assert_eq!(result["demands"][0]["text"], "怎么安装教程");
743        assert_eq!(result["demands"][0]["signals"], json!(["怎么", "教程"]));
744    }
745
746    #[test]
747    fn raw_comments_extract_replies_and_interaction_weights() {
748        let records = parse_records(
749            r#"{"comments":[{"text":"主评论","digg_count":8,"replies":[{"text":"回复","digg_count":3}]}]}"#,
750        );
751        assert_eq!(
752            records,
753            vec![
754                TextRecord::new("主评论", Some(9)),
755                TextRecord::new("回复", Some(4))
756            ]
757        );
758    }
759
760    #[test]
761    fn jsonl_extracts_chatml_content() {
762        let records = parse_records(
763            "{\"messages\":[{\"role\":\"user\",\"content\":\"第一条\"}]}\n{\"messages\":[{\"role\":\"assistant\",\"content\":\"第二条\"}]}",
764        );
765        assert_eq!(records.len(), 2);
766    }
767
768    #[test]
769    fn crawler_text_extra_extracts_repeated_topic() {
770        let records = parse_records(
771            r##"[
772                {"desc":"第一次露营","text_extra":[{"tag_name":"露营装备"}]},
773                {"desc":"帐篷体验","text_extra":[{"tag_name":"#露营装备"}]}
774            ]"##,
775        );
776        let result = analyze(&records, 20, 2);
777        assert!(
778            result["hot_words"]
779                .as_array()
780                .unwrap()
781                .iter()
782                .any(|value| value["text"] == "#露营装备" && value["count"] == 2)
783        );
784    }
785
786    #[test]
787    fn chatml_metadata_quality_score_orders_demands() {
788        let records = parse_records(
789            r#"[
790                {"messages":[{"role":"user","content":"求低分链接"}],"metadata":{"quality_score":1}},
791                {"messages":[{"role":"user","content":"求低分链接"}],"metadata":{"quality_score":1}},
792                {"messages":[{"role":"user","content":"求高分链接"}],"metadata":{"quality_score":20}},
793                {"messages":[{"role":"user","content":"求高分链接"}],"metadata":{"quality_score":20}}
794            ]"#,
795        );
796        let result = analyze(&records, 20, 2);
797        assert_eq!(result["demands"][0]["text"], "求高分链接");
798    }
799
800    #[test]
801    fn numeric_jsonl_falls_back_to_plain_text() {
802        let records = parse_records("666\n666\n");
803        let result = analyze(&records, 20, 2);
804        assert_eq!(result["input_count"], 2);
805        assert!(
806            result["hot_memes"]
807                .as_array()
808                .unwrap()
809                .iter()
810                .any(|value| value["text"] == "666" && value["count"] == 2)
811        );
812    }
813
814    #[test]
815    fn single_numeric_json_falls_back_to_plain_text() {
816        let records = parse_records("666");
817        let result = analyze(&records, 20, 1);
818        assert_eq!(result["input_count"], 1);
819        assert!(
820            result["hot_memes"]
821                .as_array()
822                .unwrap()
823                .iter()
824                .any(|value| value["text"] == "666" && value["count"] == 1)
825        );
826    }
827
828    #[test]
829    fn plain_text_extracts_each_non_empty_line() {
830        let records = parse_records("第一条\n\n第二条\n");
831        assert_eq!(
832            records,
833            vec![
834                TextRecord::new("第一条", None),
835                TextRecord::new("第二条", None)
836            ]
837        );
838    }
839}