Skip to main content

douyin_cli/
stats.rs

1//! Offline metadata statistics for crawler output.
2
3use 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/// CLI arguments for offline crawler metadata statistics.
17#[derive(Debug, Args)]
18pub struct StatsArgs {
19    /// JSON 文件路径,或 - 从 stdin 读取
20    input: String,
21    /// 按 author_nickname 精确过滤
22    #[arg(long)]
23    author: Option<String>,
24    /// Top 作品排序指标
25    #[arg(long, value_enum, default_value_t = SortMetric::Score)]
26    sort: SortMetric,
27    /// Top 作品最多输出条数
28    #[arg(long, default_value_t = 10)]
29    top: usize,
30    /// 输出格式
31    #[arg(long, value_enum, default_value_t = OutputFormat::Json)]
32    format: OutputFormat,
33    /// 输出文件;不传则输出到 stdout
34    #[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
126/// Analyze crawler JSON metadata without reading or interpreting media content.
127pub 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}