1use std::cmp::Ordering;
4use std::collections::{BTreeMap, BTreeSet};
5use std::fs;
6use std::io::{self, Read};
7use std::path::{Path, PathBuf};
8
9use clap::{Args, ValueEnum};
10use serde_json::{Map, Value, json};
11
12use crate::fs_utils;
13
14const SCORE_FORMULA: &str = "100 × (0.35×likes_norm + 0.20×comments_norm + 0.20×collects_norm + 0.25×shares_norm), norm=ln(1+x)/ln(1+max)";
15
16#[derive(Debug, Args)]
18pub struct StatsArgs {
19 input: String,
21 #[arg(long)]
23 author: Option<String>,
24 #[arg(long, value_enum, default_value_t = SortMetric::Score)]
26 sort: SortMetric,
27 #[arg(long, default_value_t = 10)]
29 top: usize,
30 #[arg(long, value_enum, default_value_t = OutputFormat::Json)]
32 format: OutputFormat,
33 #[arg(short, long)]
35 output: Option<PathBuf>,
36}
37
38#[derive(Clone, Copy, Debug, ValueEnum)]
39pub enum SortMetric {
40 Score,
41 Interactions,
42 Likes,
43 Comments,
44 Collects,
45 Shares,
46 Duration,
47 Latest,
48}
49
50impl SortMetric {
51 fn as_str(self) -> &'static str {
52 match self {
53 Self::Score => "score",
54 Self::Interactions => "interactions",
55 Self::Likes => "likes",
56 Self::Comments => "comments",
57 Self::Collects => "collects",
58 Self::Shares => "shares",
59 Self::Duration => "duration",
60 Self::Latest => "latest",
61 }
62 }
63}
64
65#[derive(Clone, Copy, Debug, ValueEnum)]
66pub enum OutputFormat {
67 Json,
68 Markdown,
69}
70
71#[derive(Clone, Debug)]
72struct Item {
73 id: String,
74 desc: String,
75 author_nickname: String,
76 author_uid: String,
77 likes: Option<u64>,
78 comments: Option<u64>,
79 collects: Option<u64>,
80 shares: Option<u64>,
81 duration_ms: Option<u64>,
82 publish_time: Option<u64>,
83 topics: Vec<String>,
84}
85
86impl Item {
87 fn interactions(&self) -> u64 {
88 self.likes
89 .unwrap_or(0)
90 .saturating_add(self.comments.unwrap_or(0))
91 .saturating_add(self.collects.unwrap_or(0))
92 .saturating_add(self.shares.unwrap_or(0))
93 }
94}
95
96#[derive(Clone, Debug)]
97struct ScoredItem {
98 item: Item,
99 interactions: u64,
100 score: f64,
101}
102
103#[derive(Default)]
104struct GroupAggregate {
105 count: u64,
106 likes: u64,
107 comments: u64,
108 collects: u64,
109 shares: u64,
110 interactions: u64,
111 interactions_sum: u128,
112}
113
114pub fn run(args: StatsArgs) -> Result<(), String> {
115 let input = read_input(&args.input)?;
116 let result = analyze_json(&input, args.author.as_deref(), args.sort, args.top)?;
117 let rendered = match args.format {
118 OutputFormat::Json => {
119 serde_json::to_string_pretty(&result).map_err(|error| error.to_string())?
120 }
121 OutputFormat::Markdown => render_markdown(&result),
122 };
123 write_output(&rendered, args.output.as_deref())
124}
125
126pub fn analyze_json(
128 input: &str,
129 author: Option<&str>,
130 sort: SortMetric,
131 top: usize,
132) -> Result<Value, String> {
133 let value: Value =
134 serde_json::from_str(input).map_err(|error| format!("输入不是合法 JSON: {error}"))?;
135 let items = parse_items(&value);
136 if items.is_empty() {
137 return Err("输入中没有有效作品记录(作品需要 id 或 aweme_id)".to_owned());
138 }
139 let input_count = items.len();
140 let matched: Vec<_> = items
141 .into_iter()
142 .filter(|item| author.is_none_or(|name| item.author_nickname == name))
143 .collect();
144 if matched.is_empty() {
145 return Err(match author {
146 Some(name) => format!("没有 author_nickname 精确匹配“{name}”的作品"),
147 None => "输入中没有有效作品记录".to_owned(),
148 });
149 }
150
151 let maxima = metric_maxima(&matched);
152 let mut scored: Vec<_> = matched
153 .into_iter()
154 .map(|item| {
155 let interactions = item.interactions();
156 let score = score_item(&item, maxima);
157 ScoredItem {
158 item,
159 interactions,
160 score,
161 }
162 })
163 .collect();
164 sort_items(&mut scored, sort);
165
166 Ok(json!({
167 "input_count": input_count,
168 "matched_count": scored.len(),
169 "filter": {"author": author},
170 "sort": sort.as_str(),
171 "score_formula": SCORE_FORMULA,
172 "metric_coverage": metric_coverage(&scored),
173 "summary": summary(&scored),
174 "duration_buckets": duration_buckets(&scored),
175 "authors": author_stats(&scored),
176 "topics": topic_stats(&scored),
177 "top_items": scored.iter().take(top).enumerate().map(|(index, item)| {
178 top_item_json(index + 1, item)
179 }).collect::<Vec<_>>(),
180 "limitations": "仅统计采集元数据;不分析媒体画面、声音、Hook、镜头、字幕。缺少播放量,因此 score 不是互动率。"
181 }))
182}
183
184fn read_input(input: &str) -> Result<String, String> {
185 if input == "-" {
186 let mut text = String::new();
187 io::stdin()
188 .read_to_string(&mut text)
189 .map_err(|error| error.to_string())?;
190 Ok(text)
191 } else {
192 fs::read_to_string(input).map_err(|error| format!("无法读取 {input}: {error}"))
193 }
194}
195
196fn write_output(text: &str, output: Option<&Path>) -> Result<(), String> {
197 if let Some(path) = output {
198 fs_utils::atomic_write(path, format!("{text}\n").as_bytes())
199 .map_err(|error| error.to_string())
200 } else {
201 println!("{text}");
202 Ok(())
203 }
204}
205
206fn parse_items(value: &Value) -> Vec<Item> {
207 let mut candidates = Vec::new();
208 collect_candidates(value, &mut candidates);
209 candidates.into_iter().filter_map(parse_item).collect()
210}
211
212fn collect_candidates<'a>(value: &'a Value, candidates: &mut Vec<&'a Value>) {
213 match value {
214 Value::Array(values) => candidates.extend(values),
215 Value::Object(object) => {
216 let mut found_container = false;
217 for key in ["items", "aweme_list", "data"] {
218 if let Some(values) = object.get(key).and_then(Value::as_array) {
219 candidates.extend(values);
220 found_container = true;
221 }
222 }
223 if !found_container {
224 candidates.push(value);
225 }
226 }
227 _ => {}
228 }
229}
230
231fn parse_item(value: &Value) -> Option<Item> {
232 let object = value.as_object()?;
233 let id = string_or_integer(object, &["id", "aweme_id", "awemeId"])?;
234 if id.is_empty() {
235 return None;
236 }
237 Some(Item {
238 id,
239 desc: string_value(object, &["desc"]).unwrap_or_default(),
240 author_nickname: string_value(object, &["author_nickname", "authorNickname"])
241 .unwrap_or_default(),
242 author_uid: string_value(object, &["author_uid", "authorUid"]).unwrap_or_default(),
243 likes: numeric_value(object, &["digg_count", "diggCount"]),
244 comments: numeric_value(object, &["comment_count", "commentCount"]),
245 collects: numeric_value(object, &["collect_count", "collectCount"]),
246 shares: numeric_value(object, &["share_count", "shareCount"]),
247 duration_ms: numeric_value(object, &["duration"]),
248 publish_time: numeric_value(object, &["time", "create_time", "createTime"]),
249 topics: topics(object.get("text_extra").or_else(|| object.get("textExtra"))),
250 })
251}
252
253fn string_or_integer(object: &Map<String, Value>, keys: &[&str]) -> Option<String> {
254 keys.iter().find_map(|key| {
255 let value = object.get(*key)?;
256 value
257 .as_str()
258 .map(str::trim)
259 .filter(|value| !value.is_empty())
260 .map(str::to_owned)
261 .or_else(|| value.as_u64().map(|value| value.to_string()))
262 })
263}
264
265fn string_value(object: &Map<String, Value>, keys: &[&str]) -> Option<String> {
266 keys.iter().find_map(|key| {
267 object
268 .get(*key)
269 .and_then(Value::as_str)
270 .map(str::trim)
271 .filter(|value| !value.is_empty())
272 .map(str::to_owned)
273 })
274}
275
276fn numeric_value(object: &Map<String, Value>, keys: &[&str]) -> Option<u64> {
277 keys.iter()
278 .find_map(|key| object.get(*key).and_then(non_negative_integer))
279}
280
281fn non_negative_integer(value: &Value) -> Option<u64> {
282 value
283 .as_u64()
284 .or_else(|| value.as_str()?.trim().parse::<u64>().ok())
285}
286
287fn topics(value: Option<&Value>) -> Vec<String> {
288 let mut topics = BTreeSet::new();
289 if let Some(values) = value.and_then(Value::as_array) {
290 for value in values {
291 if let Some(name) = value
292 .get("tag_name")
293 .or_else(|| value.get("tagName"))
294 .and_then(Value::as_str)
295 .map(str::trim)
296 .filter(|name| !name.is_empty())
297 {
298 topics.insert(name.trim_start_matches('#').to_owned());
299 }
300 }
301 }
302 topics.into_iter().collect()
303}
304
305#[derive(Clone, Copy)]
306struct Maxima {
307 likes: u64,
308 comments: u64,
309 collects: u64,
310 shares: u64,
311}
312
313fn metric_maxima(items: &[Item]) -> Maxima {
314 Maxima {
315 likes: items
316 .iter()
317 .filter_map(|item| item.likes)
318 .max()
319 .unwrap_or(0),
320 comments: items
321 .iter()
322 .filter_map(|item| item.comments)
323 .max()
324 .unwrap_or(0),
325 collects: items
326 .iter()
327 .filter_map(|item| item.collects)
328 .max()
329 .unwrap_or(0),
330 shares: items
331 .iter()
332 .filter_map(|item| item.shares)
333 .max()
334 .unwrap_or(0),
335 }
336}
337
338fn score_item(item: &Item, maxima: Maxima) -> f64 {
339 let weighted = 0.35 * normalized(item.likes.unwrap_or(0), maxima.likes)
340 + 0.20 * normalized(item.comments.unwrap_or(0), maxima.comments)
341 + 0.20 * normalized(item.collects.unwrap_or(0), maxima.collects)
342 + 0.25 * normalized(item.shares.unwrap_or(0), maxima.shares);
343 round_two(weighted * 100.0).clamp(0.0, 100.0)
344}
345
346fn normalized(value: u64, maximum: u64) -> f64 {
347 if maximum == 0 {
348 0.0
349 } else {
350 (value as f64).ln_1p() / (maximum as f64).ln_1p()
351 }
352}
353
354fn round_two(value: f64) -> f64 {
355 (value * 100.0).round() / 100.0
356}
357
358fn sort_items(items: &mut [ScoredItem], sort: SortMetric) {
359 items.sort_by(|left, right| {
360 primary_order(left, right, sort)
361 .then_with(|| right.interactions.cmp(&left.interactions))
362 .then_with(|| left.item.id.cmp(&right.item.id))
363 });
364}
365
366fn primary_order(left: &ScoredItem, right: &ScoredItem, sort: SortMetric) -> Ordering {
367 match sort {
368 SortMetric::Score => right.score.total_cmp(&left.score),
369 SortMetric::Interactions => right.interactions.cmp(&left.interactions),
370 SortMetric::Likes => right
371 .item
372 .likes
373 .unwrap_or(0)
374 .cmp(&left.item.likes.unwrap_or(0)),
375 SortMetric::Comments => right
376 .item
377 .comments
378 .unwrap_or(0)
379 .cmp(&left.item.comments.unwrap_or(0)),
380 SortMetric::Collects => right
381 .item
382 .collects
383 .unwrap_or(0)
384 .cmp(&left.item.collects.unwrap_or(0)),
385 SortMetric::Shares => right
386 .item
387 .shares
388 .unwrap_or(0)
389 .cmp(&left.item.shares.unwrap_or(0)),
390 SortMetric::Duration => right
391 .item
392 .duration_ms
393 .unwrap_or(0)
394 .cmp(&left.item.duration_ms.unwrap_or(0)),
395 SortMetric::Latest => right
396 .item
397 .publish_time
398 .unwrap_or(0)
399 .cmp(&left.item.publish_time.unwrap_or(0)),
400 }
401}
402
403fn metric_coverage(items: &[ScoredItem]) -> Value {
404 json!({
405 "likes": items.iter().filter(|item| item.item.likes.is_some()).count(),
406 "comments": items.iter().filter(|item| item.item.comments.is_some()).count(),
407 "collects": items.iter().filter(|item| item.item.collects.is_some()).count(),
408 "shares": items.iter().filter(|item| item.item.shares.is_some()).count(),
409 "duration_ms": items.iter().filter(|item| item.item.duration_ms.is_some()).count(),
410 "published_time": items.iter().filter(|item| item.item.publish_time.is_some()).count(),
411 })
412}
413
414fn summary(items: &[ScoredItem]) -> Value {
415 let likes: Vec<_> = items.iter().filter_map(|item| item.item.likes).collect();
416 let comments: Vec<_> = items.iter().filter_map(|item| item.item.comments).collect();
417 let collects: Vec<_> = items.iter().filter_map(|item| item.item.collects).collect();
418 let shares: Vec<_> = items.iter().filter_map(|item| item.item.shares).collect();
419 let interactions: Vec<_> = items.iter().map(|item| item.interactions).collect();
420 let durations: Vec<_> = items
421 .iter()
422 .filter_map(|item| item.item.duration_ms)
423 .collect();
424 let published: Vec<_> = items
425 .iter()
426 .filter_map(|item| item.item.publish_time)
427 .collect();
428 json!({
429 "likes": metric_summary(&likes),
430 "comments": metric_summary(&comments),
431 "collects": metric_summary(&collects),
432 "shares": metric_summary(&shares),
433 "interactions": metric_summary(&interactions),
434 "duration_ms": range_summary(&durations),
435 "published_time": {
436 "earliest": published.iter().min(),
437 "latest": published.iter().max(),
438 }
439 })
440}
441
442fn metric_summary(values: &[u64]) -> Value {
443 if values.is_empty() {
444 return json!({
445 "total": Value::Null,
446 "average": Value::Null,
447 "median": Value::Null,
448 });
449 }
450 let total = saturated_sum(values.iter().copied());
451 json!({
452 "total": total,
453 "average": average_values(values),
454 "median": median(values),
455 })
456}
457
458fn range_summary(values: &[u64]) -> Value {
459 if values.is_empty() {
460 return json!({
461 "average": Value::Null,
462 "median": Value::Null,
463 "min": Value::Null,
464 "max": Value::Null,
465 });
466 }
467 json!({
468 "average": average_values(values),
469 "median": median(values),
470 "min": values.iter().min(),
471 "max": values.iter().max(),
472 })
473}
474
475fn saturated_sum(values: impl Iterator<Item = u64>) -> u64 {
476 values.fold(0_u64, u64::saturating_add)
477}
478
479fn average_values(values: &[u64]) -> f64 {
480 let total = values
481 .iter()
482 .fold(0_u128, |sum, value| sum.saturating_add(*value as u128));
483 average_wide(total, values.len())
484}
485
486fn average_wide(total: u128, count: usize) -> f64 {
487 if count == 0 {
488 0.0
489 } else {
490 round_two(total as f64 / count as f64)
491 }
492}
493
494fn median(values: &[u64]) -> f64 {
495 if values.is_empty() {
496 return 0.0;
497 }
498 let mut sorted = values.to_vec();
499 sorted.sort_unstable();
500 let middle = sorted.len() / 2;
501 if sorted.len().is_multiple_of(2) {
502 (sorted[middle - 1] as f64 + sorted[middle] as f64) / 2.0
503 } else {
504 sorted[middle] as f64
505 }
506}
507
508fn duration_buckets(items: &[ScoredItem]) -> Value {
509 let mut short = Vec::new();
510 let mut medium = Vec::new();
511 let mut long = Vec::new();
512 for item in items {
513 match item.item.duration_ms {
514 Some(duration) if duration < 60_000 => short.push(item.interactions),
515 Some(duration) if duration < 300_000 => medium.push(item.interactions),
516 Some(_) => long.push(item.interactions),
517 None => {}
518 }
519 }
520 json!({
521 "under_60s": bucket(&short),
522 "60_to_300s": bucket(&medium),
523 "over_300s": bucket(&long),
524 })
525}
526
527fn bucket(interactions: &[u64]) -> Value {
528 json!({
529 "count": interactions.len(),
530 "average_interactions": average_values(interactions),
531 })
532}
533
534fn author_stats(items: &[ScoredItem]) -> Vec<Value> {
535 let mut groups: BTreeMap<(String, String), GroupAggregate> = BTreeMap::new();
536 for item in items {
537 if item.item.author_nickname.is_empty() {
538 continue;
539 }
540 let group = groups
541 .entry((
542 item.item.author_nickname.clone(),
543 item.item.author_uid.clone(),
544 ))
545 .or_default();
546 add_to_group(group, item);
547 }
548 let mut groups: Vec<_> = groups.into_iter().collect();
549 groups.sort_by(|left, right| {
550 right
551 .1
552 .interactions
553 .cmp(&left.1.interactions)
554 .then_with(|| right.1.count.cmp(&left.1.count))
555 .then_with(|| left.0.0.cmp(&right.0.0))
556 .then_with(|| left.0.1.cmp(&right.0.1))
557 });
558 groups
559 .into_iter()
560 .map(|((author, author_uid), group)| {
561 json!({
562 "author_nickname": author,
563 "author_uid": author_uid,
564 "count": group.count,
565 "total_likes": group.likes,
566 "total_comments": group.comments,
567 "total_collects": group.collects,
568 "total_shares": group.shares,
569 "total_interactions": group.interactions,
570 "average_interactions": average_wide(group.interactions_sum, group.count as usize),
571 })
572 })
573 .collect()
574}
575
576fn add_to_group(group: &mut GroupAggregate, item: &ScoredItem) {
577 group.count = group.count.saturating_add(1);
578 group.likes = group.likes.saturating_add(item.item.likes.unwrap_or(0));
579 group.comments = group
580 .comments
581 .saturating_add(item.item.comments.unwrap_or(0));
582 group.collects = group
583 .collects
584 .saturating_add(item.item.collects.unwrap_or(0));
585 group.shares = group.shares.saturating_add(item.item.shares.unwrap_or(0));
586 group.interactions = group.interactions.saturating_add(item.interactions);
587 group.interactions_sum = group
588 .interactions_sum
589 .saturating_add(item.interactions as u128);
590}
591
592fn topic_stats(items: &[ScoredItem]) -> Vec<Value> {
593 let mut groups: BTreeMap<String, (u64, u64)> = BTreeMap::new();
594 for item in items {
595 for topic in &item.item.topics {
596 let group = groups.entry(topic.clone()).or_default();
597 group.0 = group.0.saturating_add(1);
598 group.1 = group.1.saturating_add(item.interactions);
599 }
600 }
601 let mut groups: Vec<_> = groups.into_iter().collect();
602 groups.sort_by(|left, right| {
603 right
604 .1
605 .0
606 .cmp(&left.1.0)
607 .then_with(|| right.1.1.cmp(&left.1.1))
608 .then_with(|| left.0.cmp(&right.0))
609 });
610 groups
611 .into_iter()
612 .map(|(topic, (count, total_interactions))| {
613 json!({
614 "tag_name": topic,
615 "count": count,
616 "total_interactions": total_interactions,
617 })
618 })
619 .collect()
620}
621
622fn top_item_json(rank: usize, item: &ScoredItem) -> Value {
623 json!({
624 "rank": rank,
625 "id": item.item.id,
626 "desc": item.item.desc,
627 "author_nickname": item.item.author_nickname,
628 "author_uid": item.item.author_uid,
629 "likes": item.item.likes,
630 "comments": item.item.comments,
631 "collects": item.item.collects,
632 "shares": item.item.shares,
633 "interactions": item.interactions,
634 "score": item.score,
635 "duration_ms": item.item.duration_ms,
636 "publish_time": item.item.publish_time,
637 "share_url": format!("https://www.douyin.com/video/{}", item.item.id),
638 })
639}
640
641fn render_markdown(result: &Value) -> String {
642 let mut output = format!(
643 "# 作品表现离线统计\n\n输入作品:{};匹配作品:{};排序:`{}`。\n\n> 仅统计采集元数据,不分析媒体画面、声音、Hook、镜头、字幕。缺少播放量,因此综合分不是互动率。\n\n",
644 result["input_count"],
645 result["matched_count"],
646 result["sort"].as_str().unwrap_or("")
647 );
648 output.push_str("## 总体汇总\n\n| 指标 | 总计 | 平均 | 中位数 |\n|---|---:|---:|---:|\n");
649 for (label, key) in [
650 ("点赞", "likes"),
651 ("评论", "comments"),
652 ("收藏", "collects"),
653 ("分享", "shares"),
654 ("互动合计", "interactions"),
655 ] {
656 let metric = &result["summary"][key];
657 output.push_str(&format!(
658 "| {label} | {} | {} | {} |\n",
659 display_json(&metric["total"]),
660 display_json(&metric["average"]),
661 display_json(&metric["median"])
662 ));
663 }
664 let duration = &result["summary"]["duration_ms"];
665 output.push_str(&format!(
666 "\n时长(毫秒):平均 {},中位数 {},最小 {},最大 {}。\n\n发布时间:最早 {},最晚 {}。\n",
667 display_json(&duration["average"]),
668 display_json(&duration["median"]),
669 display_json(&duration["min"]),
670 display_json(&duration["max"]),
671 display_json(&result["summary"]["published_time"]["earliest"]),
672 display_json(&result["summary"]["published_time"]["latest"])
673 ));
674
675 output.push_str("\n## 字段覆盖\n\n| 字段 | 有效记录数 |\n|---|---:|\n");
676 for (label, key) in [
677 ("likes", "likes"),
678 ("comments", "comments"),
679 ("collects", "collects"),
680 ("shares", "shares"),
681 ("duration_ms", "duration_ms"),
682 ("published_time", "published_time"),
683 ] {
684 output.push_str(&format!(
685 "| {label} | {} |\n",
686 result["metric_coverage"][key]
687 ));
688 }
689
690 output.push_str("\n## 时长分桶\n\n| 时长 | 作品数 | 平均互动 |\n|---|---:|---:|\n");
691 for (label, key) in [
692 ("不足 60 秒", "under_60s"),
693 ("60–300 秒", "60_to_300s"),
694 ("300 秒及以上", "over_300s"),
695 ] {
696 let bucket = &result["duration_buckets"][key];
697 output.push_str(&format!(
698 "| {label} | {} | {} |\n",
699 bucket["count"], bucket["average_interactions"]
700 ));
701 }
702
703 output.push_str(
704 "\n## 作者\n\n| 作者 | UID | 作品数 | 总互动 | 平均互动 |\n|---|---|---:|---:|---:|\n",
705 );
706 append_rows(&mut output, &result["authors"], |row| {
707 format!(
708 "| {} | {} | {} | {} | {} |\n",
709 escape_markdown(row["author_nickname"].as_str().unwrap_or("")),
710 escape_markdown(row["author_uid"].as_str().unwrap_or("")),
711 row["count"],
712 row["total_interactions"],
713 row["average_interactions"]
714 )
715 });
716
717 output.push_str("\n## 话题\n\n| 话题 | 作品数 | 总互动 |\n|---|---:|---:|\n");
718 append_rows(&mut output, &result["topics"], |row| {
719 format!(
720 "| #{} | {} | {} |\n",
721 escape_markdown(row["tag_name"].as_str().unwrap_or("")),
722 row["count"],
723 row["total_interactions"]
724 )
725 });
726
727 output.push_str(
728 "\n## Top 作品\n\n| # | 作品 | 作者 | 互动 | 综合分 |\n|---:|---|---|---:|---:|\n",
729 );
730 append_rows(&mut output, &result["top_items"], |row| {
731 format!(
732 "| {} | [{}]({}) | {} | {} | {} |\n",
733 row["rank"],
734 escape_markdown(row["desc"].as_str().unwrap_or("")),
735 row["share_url"].as_str().unwrap_or(""),
736 escape_markdown(row["author_nickname"].as_str().unwrap_or("")),
737 row["interactions"],
738 row["score"]
739 )
740 });
741 output
742}
743
744fn append_rows(output: &mut String, rows: &Value, render: impl Fn(&Value) -> String) {
745 if let Some(rows) = rows.as_array() {
746 for row in rows {
747 output.push_str(&render(row));
748 }
749 }
750}
751
752fn display_json(value: &Value) -> String {
753 if value.is_null() {
754 "无".to_owned()
755 } else {
756 value.to_string()
757 }
758}
759
760fn escape_markdown(value: &str) -> String {
761 value
762 .replace('\\', "\\\\")
763 .replace('|', "\\|")
764 .replace('[', "\\[")
765 .replace(']', "\\]")
766 .replace(['\r', '\n'], " ")
767}
768
769#[cfg(test)]
770mod tests {
771 use super::{SortMetric, analyze_json, median, render_markdown};
772 use serde_json::json;
773
774 fn analyze(input: &str, author: Option<&str>, sort: SortMetric) -> serde_json::Value {
775 analyze_json(input, author, sort, 20).unwrap()
776 }
777
778 #[test]
779 fn parses_flat_schema_and_camel_case_aliases() {
780 let result = analyze(
781 r#"[
782 {"id":"1","desc":"snake","author_nickname":"甲","author_uid":"u1","digg_count":1,"comment_count":2,"collect_count":3,"share_count":4,"duration":5000,"time":10},
783 {"aweme_id":"2","desc":"camel","authorNickname":"乙","authorUid":"u2","diggCount":"5","commentCount":"6","collectCount":"7","shareCount":"8","duration":"9000","createTime":"20"},
784 {"awemeId":"3","time":5}
785 ]"#,
786 None,
787 SortMetric::Latest,
788 );
789 assert_eq!(result["input_count"], 3);
790 assert_eq!(result["top_items"][0]["id"], "2");
791 assert_eq!(result["top_items"][0]["interactions"], 26);
792 }
793
794 #[test]
795 fn author_filter_is_exact() {
796 let result = analyze(
797 r#"[{"id":"1","author_nickname":"陈震同学"},{"id":"2","author_nickname":"陈震"}]"#,
798 Some("陈震同学"),
799 SortMetric::Score,
800 );
801 assert_eq!(result["matched_count"], 1);
802 assert_eq!(result["top_items"][0]["id"], "1");
803 }
804
805 #[test]
806 fn all_sort_metrics_select_expected_primary_value() {
807 let input = r#"[
808 {"id":"a","digg_count":9,"comment_count":1,"collect_count":1,"share_count":1,"duration":10,"time":10},
809 {"id":"b","digg_count":1,"comment_count":9,"collect_count":2,"share_count":2,"duration":30,"time":30},
810 {"id":"c","digg_count":2,"comment_count":2,"collect_count":9,"share_count":9,"duration":20,"time":20}
811 ]"#;
812 let expectations = [
813 (SortMetric::Interactions, "c"),
814 (SortMetric::Likes, "a"),
815 (SortMetric::Comments, "b"),
816 (SortMetric::Collects, "c"),
817 (SortMetric::Shares, "c"),
818 (SortMetric::Duration, "b"),
819 (SortMetric::Latest, "b"),
820 ];
821 for (sort, expected) in expectations {
822 let result = analyze(input, None, sort);
823 assert_eq!(result["top_items"][0]["id"], expected);
824 }
825 let score = analyze(input, None, SortMetric::Score);
826 assert_eq!(score["top_items"][0]["id"], "c");
827 }
828
829 #[test]
830 fn stable_ties_use_interactions_then_id() {
831 let result = analyze(
832 r#"[
833 {"id":"b","digg_count":5,"comment_count":5,"duration":10},
834 {"id":"a","digg_count":5,"comment_count":5,"duration":10},
835 {"id":"c","digg_count":4,"comment_count":4,"duration":10}
836 ]"#,
837 None,
838 SortMetric::Duration,
839 );
840 assert_eq!(
841 result["top_items"]
842 .as_array()
843 .unwrap()
844 .iter()
845 .map(|item| item["id"].as_str().unwrap())
846 .collect::<Vec<_>>(),
847 vec!["a", "b", "c"]
848 );
849 }
850
851 #[test]
852 fn score_is_one_hundred_for_all_maxima_and_zero_for_all_zero() {
853 let maximum = analyze(
854 r#"[{"id":"max","digg_count":10,"comment_count":10,"collect_count":10,"share_count":10}]"#,
855 None,
856 SortMetric::Score,
857 );
858 let zero = analyze(
859 r#"[{"id":"zero","digg_count":0,"comment_count":0,"collect_count":0,"share_count":0}]"#,
860 None,
861 SortMetric::Score,
862 );
863 assert_eq!(maximum["top_items"][0]["score"], 100.0);
864 assert_eq!(zero["top_items"][0]["score"], 0.0);
865 }
866
867 #[test]
868 fn score_formula_applies_declared_weights() {
869 let result = analyze(
870 r#"[
871 {"id":"likes","digg_count":10},
872 {"id":"comments","comment_count":10},
873 {"id":"collects","collect_count":10},
874 {"id":"shares","share_count":10}
875 ]"#,
876 None,
877 SortMetric::Score,
878 );
879 assert_eq!(result["top_items"][0]["score"], 35.0);
880 assert_eq!(result["top_items"][1]["score"], 25.0);
881 assert_eq!(result["top_items"][2]["score"], 20.0);
882 assert_eq!(result["top_items"][3]["score"], 20.0);
883 }
884
885 #[test]
886 fn median_handles_even_and_odd_inputs() {
887 assert_eq!(median(&[9, 1, 5]), 5.0);
888 assert_eq!(median(&[10, 2, 6, 4]), 5.0);
889 }
890
891 #[test]
892 fn duration_bucket_boundaries_are_exact() {
893 let result = analyze(
894 r#"[
895 {"id":"short","duration":59999},
896 {"id":"medium","duration":60000},
897 {"id":"long","duration":300000}
898 ]"#,
899 None,
900 SortMetric::Duration,
901 );
902 assert_eq!(result["duration_buckets"]["under_60s"]["count"], 1);
903 assert_eq!(result["duration_buckets"]["60_to_300s"]["count"], 1);
904 assert_eq!(result["duration_buckets"]["over_300s"]["count"], 1);
905 }
906
907 #[test]
908 fn missing_strings_and_negative_numbers_are_safe() {
909 let result = analyze(
910 r#"[
911 {"id":"1","digg_count":"12","comment_count":-1,"collect_count":"bad","share_count":null},
912 {"id":"2","digg_count":3}
913 ]"#,
914 None,
915 SortMetric::Likes,
916 );
917 assert_eq!(result["metric_coverage"]["likes"], 2);
918 assert_eq!(result["metric_coverage"]["comments"], 0);
919 assert_eq!(result["top_items"][0]["interactions"], 12);
920 }
921
922 #[test]
923 fn interactions_and_totals_saturate_on_overflow() {
924 let result = analyze(
925 r#"[{"id":"1","digg_count":"18446744073709551615","comment_count":"18446744073709551615","collect_count":1,"share_count":1}]"#,
926 None,
927 SortMetric::Interactions,
928 );
929 assert_eq!(result["top_items"][0]["interactions"], json!(u64::MAX));
930 assert_eq!(result["summary"]["interactions"]["total"], json!(u64::MAX));
931 }
932
933 #[test]
934 fn averages_do_not_use_saturated_totals() {
935 let result = analyze(
936 r#"[
937 {"id":"1","author_nickname":"甲","author_uid":"u1","digg_count":"18446744073709551615","duration":1000},
938 {"id":"2","author_nickname":"甲","author_uid":"u1","digg_count":"18446744073709551615","duration":1000}
939 ]"#,
940 None,
941 SortMetric::Interactions,
942 );
943 let expected = u64::MAX as f64;
944 assert_eq!(
945 result["summary"]["likes"]["average"].as_f64().unwrap(),
946 expected
947 );
948 assert_eq!(
949 result["authors"][0]["average_interactions"]
950 .as_f64()
951 .unwrap(),
952 expected
953 );
954 assert_eq!(
955 result["duration_buckets"]["under_60s"]["average_interactions"]
956 .as_f64()
957 .unwrap(),
958 expected
959 );
960 }
961
962 #[test]
963 fn missing_metrics_are_null_but_derived_interactions_are_zero() {
964 let result = analyze(r#"[{"id":"1"}]"#, None, SortMetric::Score);
965 for field in ["total", "average", "median"] {
966 assert!(result["summary"]["likes"][field].is_null());
967 }
968 for field in ["average", "median", "min", "max"] {
969 assert!(result["summary"]["duration_ms"][field].is_null());
970 }
971 assert_eq!(result["summary"]["interactions"]["total"], 0);
972 assert_eq!(result["summary"]["interactions"]["average"], 0.0);
973 assert_eq!(result["summary"]["interactions"]["median"], 0.0);
974
975 let markdown = render_markdown(&result);
976 assert!(markdown.contains("| 点赞 | 无 | 无 | 无 |"));
977 assert!(markdown.contains("时长(毫秒):平均 无,中位数 无,最小 无,最大 无。"));
978 assert!(!markdown.contains("null"));
979 }
980
981 #[test]
982 fn authors_and_topics_have_totals_and_deterministic_order() {
983 let result = analyze(
984 r#"[
985 {"id":"1","author_nickname":"甲","digg_count":10,"text_extra":[{"tag_name":"汽车"},{"tag_name":"汽车"}]},
986 {"id":"2","author_nickname":"乙","digg_count":5,"text_extra":[{"tag_name":"旅行"}]},
987 {"id":"3","author_nickname":"甲","digg_count":1,"text_extra":[{"tag_name":"旅行"}]}
988 ]"#,
989 None,
990 SortMetric::Score,
991 );
992 assert_eq!(result["authors"][0]["author_nickname"], "甲");
993 assert_eq!(result["authors"][0]["count"], 2);
994 assert_eq!(result["topics"][0]["tag_name"], "旅行");
995 assert_eq!(result["topics"][0]["count"], 2);
996 }
997
998 #[test]
999 fn authors_are_grouped_by_nickname_and_uid() {
1000 let result = analyze(
1001 r#"[
1002 {"id":"1","author_nickname":"同名","author_uid":"u1","digg_count":2},
1003 {"id":"2","author_nickname":"同名","author_uid":"u2","digg_count":1}
1004 ]"#,
1005 None,
1006 SortMetric::Interactions,
1007 );
1008 assert_eq!(result["authors"].as_array().unwrap().len(), 2);
1009 assert_eq!(result["authors"][0]["author_uid"], "u1");
1010 assert_eq!(result["authors"][1]["author_uid"], "u2");
1011 }
1012
1013 #[test]
1014 fn nested_arrays_and_single_objects_are_supported() {
1015 for input in [
1016 r#"{"items":[{"id":"1"}]}"#,
1017 r#"{"aweme_list":[{"id":"1"}]}"#,
1018 r#"{"data":[{"id":"1"}]}"#,
1019 r#"{"id":"1"}"#,
1020 ] {
1021 let result = analyze(input, None, SortMetric::Score);
1022 assert_eq!(result["input_count"], 1);
1023 }
1024 }
1025
1026 #[test]
1027 fn invalid_empty_and_unmatched_author_inputs_return_errors() {
1028 assert!(analyze_json("{", None, SortMetric::Score, 10).is_err());
1029 assert!(analyze_json(r#"{"items":[]}"#, None, SortMetric::Score, 10).is_err());
1030 assert!(
1031 analyze_json(
1032 r#"[{"id":"1","author_nickname":"甲"}]"#,
1033 Some("乙"),
1034 SortMetric::Score,
1035 10
1036 )
1037 .is_err()
1038 );
1039 }
1040
1041 #[test]
1042 fn markdown_contains_required_sections_and_limitations() {
1043 let result = analyze(
1044 r#"[{"id":"1","desc":"作品","author_nickname":"甲","digg_count":1}]"#,
1045 None,
1046 SortMetric::Score,
1047 );
1048 let markdown = render_markdown(&result);
1049 for expected in [
1050 "总体汇总",
1051 "字段覆盖",
1052 "时长分桶",
1053 "作者",
1054 "话题",
1055 "Top 作品",
1056 "不是互动率",
1057 ] {
1058 assert!(markdown.contains(expected));
1059 }
1060 }
1061
1062 #[test]
1063 fn markdown_escapes_link_and_table_control_characters() {
1064 let result = analyze(
1065 r#"[{"id":"1","desc":"正常](https://evil.example) | 下一列","author_nickname":"甲","author_uid":"u|1","digg_count":1}]"#,
1066 None,
1067 SortMetric::Score,
1068 );
1069 let markdown = render_markdown(&result);
1070 assert!(!markdown.contains("[正常](https://evil.example)"));
1071 assert!(markdown.contains(r"正常\](https://evil.example) \| 下一列"));
1072 assert!(markdown.contains(r"| 甲 | u\|1 | 1 | 1 | 1.0 |"));
1073 }
1074}