Skip to main content

douyin_cli/
insights.rs

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