1use std::sync::OnceLock;
5
6use regex::Regex;
7use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
10#[serde(rename_all = "camelCase")]
11pub enum NoiseCategory {
12 Date,
13 RelativeTime,
14 Price,
15 Count,
16 Number,
17}
18
19const MONTHS: &str = "jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec";
20const UNITS: &str = "seconds?|secs?|minutes?|mins?|hours?|hrs?|days?|weeks?|months?|years?|yrs?";
21const COUNT_CN: &str = "次阅读|次浏览|次播放|次下载|条评论|条回复|个赞|人关注|人在看|阅读|浏览|评论|点赞|收藏|播放|转发|分享|下载|回复|关注|赞";
22const COUNT_EN: &str = "views?|comments?|likes?|reads?|shares?|replies|reply|retweets?|stars?|downloads?|followers?|subscribers?|votes?|points?|visits?|viewers?|reviews?|ratings?";
23
24struct Patterns {
25 any: Regex,
26 kinds: [(NoiseCategory, Regex); 4],
27}
28
29fn patterns() -> &'static Patterns {
30 static RE: OnceLock<Patterns> = OnceLock::new();
31 RE.get_or_init(|| {
32 let date = format!(
33 r"(?i)(?:[0-9]{{4}}[-/.][0-9]{{1,2}}[-/.][0-9]{{1,2}}(?:[T ][0-9]{{1,2}}:[0-9]{{2}}(?::[0-9]{{2}}(?:\.[0-9]+)?)?(?:Z|[+-][0-9]{{2}}:?[0-9]{{2}})?)?|[0-9]{{4}}年[0-9]{{1,2}}月(?:[0-9]{{1,2}}[日号])?|[0-9]{{1,2}}月[0-9]{{1,2}}[日号]|[0-9]{{1,2}}:[0-9]{{2}}(?::[0-9]{{2}})?|\b(?:{m})[a-z]*\.? [0-9]{{1,2}}(?:st|nd|rd|th)?(?:, [0-9]{{4}})?\b|\b[0-9]{{1,2}}(?:st|nd|rd|th)? (?:{m})[a-z]*\.?(?:,? [0-9]{{4}})?\b)",
34 m = MONTHS
35 );
36 let rel = format!(
37 r"(?:(?i:\b(?:[0-9]+|an?|one)\s?(?:{u})\s+ago\b|\bin\s+(?:[0-9]+|an?)\s?(?:{u})\b|\bjust now\b|\byesterday\b)|[0-9]+\s?(?:个)?(?:秒钟?|分钟|小时|天|周|星期|个月|年)[前后]|刚刚|昨天|前天)",
38 u = UNITS
39 );
40 let price = r"(?:(?:US\$|[$€£¥¥₹₩])\s?[0-9][0-9,]*(?:\.[0-9]+)?(?:[kKmM万亿]\b)?|(?:USD|EUR|GBP|CNY|RMB|JPY)\s?[0-9][0-9,]*(?:\.[0-9]+)?|[0-9][0-9,]*(?:\.[0-9]+)?\s?(?:USD|EUR|GBP|CNY|RMB|JPY|元|块钱|美元|欧元|日元|港元))";
41 let count = format!(
42 r"(?:[0-9][0-9,]*(?:\.[0-9]+)?[kKmM万]?\s?(?:{cn}|(?i:(?:{en})\b))|(?:{cn}|(?i:(?:{en})\b))[::]?\s?[0-9][0-9,]*(?:\.[0-9]+)?)",
43 cn = COUNT_CN,
44 en = COUNT_EN
45 );
46 let num = r"(?:[0-9][0-9,]*(?:\.[0-9]+)?%?)";
47 let full = |p: &str| Regex::new(&format!("^(?:{p})$")).expect("noise regex");
48 Patterns {
49 any: Regex::new(&format!("{date}|{rel}|{price}|{count}|{num}")).expect("noise regex"),
50 kinds: [
51 (NoiseCategory::Date, full(&date)),
52 (NoiseCategory::RelativeTime, full(&rel)),
53 (NoiseCategory::Price, full(price)),
54 (NoiseCategory::Count, full(&count)),
55 ],
56 }
57 })
58}
59
60struct Entity<'a> {
61 kind: NoiseCategory,
62 text: &'a str,
63}
64
65fn mask(s: &str) -> (String, Vec<Entity<'_>>) {
66 let mut out = String::with_capacity(s.len());
67 let mut ents = Vec::new();
68 let mut last = 0;
69 let pats = patterns();
70 for m in pats.any.find_iter(s) {
71 let kind = match pats.kinds.iter().find(|(_, re)| re.is_match(m.as_str())) {
73 Some(&(k, _)) => k,
74 None => {
75 if s[..m.start()]
77 .chars()
78 .next_back()
79 .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
80 {
81 continue;
82 }
83 NoiseCategory::Number
84 }
85 };
86 out.push_str(&s[last..m.start()]);
87 out.push('\u{E000}');
88 out.push(match kind {
89 NoiseCategory::Date => 'd',
90 NoiseCategory::RelativeTime => 'r',
91 NoiseCategory::Price => 'p',
92 NoiseCategory::Count => 'c',
93 NoiseCategory::Number => 'n',
94 });
95 last = m.end();
96 ents.push(Entity {
97 kind,
98 text: m.as_str(),
99 });
100 }
101 out.push_str(&s[last..]);
102 (out, ents)
103}
104
105pub(crate) fn classify(
107 old: &str,
108 new: &str,
109 enabled: &[NoiseCategory],
110) -> Option<Vec<NoiseCategory>> {
111 let (mo, eo) = mask(old);
112 let (mn, en) = mask(new);
113 if mo != mn {
114 return None;
115 }
116 let mut cats: Vec<NoiseCategory> = eo
117 .iter()
118 .zip(&en)
119 .filter(|(a, b)| a.text != b.text)
120 .map(|(a, _)| a.kind)
121 .collect();
122 cats.sort();
123 cats.dedup();
124 if cats.is_empty() || cats.iter().any(|c| !enabled.contains(c)) {
125 return None;
126 }
127 Some(cats)
128}
129
130#[cfg(test)]
131mod tests {
132 use super::*;
133 use NoiseCategory::*;
134
135 const ALL: [NoiseCategory; 5] = [Date, RelativeTime, Price, Count, Number];
136
137 fn c(a: &str, b: &str) -> Option<Vec<NoiseCategory>> {
138 classify(a, b, &ALL)
139 }
140
141 #[test]
142 fn categories() {
143 assert_eq!(
144 c("Now $1,299.00 only", "Now $1,199.00 only"),
145 Some(vec![Price])
146 );
147 assert_eq!(
148 c("发布于 2024年1月5日", "发布于 2024年2月9日"),
149 Some(vec![Date])
150 );
151 assert_eq!(
152 c("Updated 3 hours ago", "Updated 5 hours ago"),
153 Some(vec![RelativeTime])
154 );
155 assert_eq!(c("1,234 次阅读", "1,301 次阅读"), Some(vec![Count]));
156 assert_eq!(c("12 条评论", "15 条评论"), Some(vec![Count]));
157 assert_eq!(
158 c("2024-01-05T10:00:00Z", "2024-01-06T10:00:00Z"),
159 Some(vec![Date])
160 );
161 assert_eq!(
162 c("version has 3 items", "version has 4 items"),
163 Some(vec![Number])
164 );
165 }
166
167 #[test]
168 fn substantive() {
169 assert_eq!(c("Price $5", "Cost $5"), None);
170 assert_eq!(c("Now $5", "Now 5 views"), None);
171 assert_eq!(c("a", "a b"), None);
172 assert_eq!(classify("Now $5", "Now $6", &[Date]), None);
173 }
174}