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