1use 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#[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#[derive(Debug, Args)]
155pub struct InsightsArgs {
156 input: String,
158 #[arg(long, default_value_t = DEFAULT_TOP)]
160 top: usize,
161 #[arg(long, default_value_t = DEFAULT_MIN_COUNT)]
163 min_count: u64,
164 #[arg(long, value_enum, default_value_t = OutputFormat::Json)]
166 format: OutputFormat,
167 #[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
183pub 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
222pub 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}