1use 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#[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#[derive(Debug, Args)]
156pub struct InsightsArgs {
157 input: String,
159 #[arg(long, default_value_t = DEFAULT_TOP)]
161 top: usize,
162 #[arg(long, default_value_t = DEFAULT_MIN_COUNT)]
164 min_count: u64,
165 #[arg(long, value_enum, default_value_t = OutputFormat::Json)]
167 format: OutputFormat,
168 #[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
186pub 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
225pub 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}