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