sensitive_rs/variant/
mod.rs1use pinyin::Pinyin;
2use std::collections::HashMap;
3
4pub struct VariantDetector {
6 pinyin_map: HashMap<String, Vec<String>>, shape_map: HashMap<char, Vec<char>>, char_to_pinyin: HashMap<char, String>, }
10
11impl std::fmt::Debug for VariantDetector {
12 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
13 f.debug_struct("VariantDetector")
14 .field("pinyin_map_size", &self.pinyin_map.len())
15 .field("shape_map_size", &self.shape_map.len())
16 .field("char_to_pinyin_size", &self.char_to_pinyin.len())
17 .finish()
18 }
19}
20
21impl Default for VariantDetector {
22 fn default() -> Self {
23 Self::new()
24 }
25}
26
27impl VariantDetector {
28 pub fn new() -> Self {
30 VariantDetector {
31 pinyin_map: HashMap::new(),
32 shape_map: Self::build_shape_map(),
33 char_to_pinyin: HashMap::new(),
34 }
35 }
36
37 pub fn add_word(&mut self, word: &str) {
39 let chars_result = Pinyin::chars(word).with_tone_style(pinyin::ToneStyle::None);
40 let han_chars: Vec<char> = word.chars().filter(|c| !c.is_ascii()).collect();
41 let pinyin_lookup: HashMap<char, String> = han_chars.into_iter().zip(chars_result.iter()).collect();
42
43 let pinyins: Vec<String> = word
44 .chars()
45 .filter_map(|c| {
46 pinyin_lookup.get(&c).map(|pinyin| {
47 self.char_to_pinyin.insert(c, pinyin.clone());
48 pinyin.clone()
49 })
50 })
51 .collect();
52
53 if !pinyins.is_empty() {
54 let pinyin_key = pinyins.join("");
55 self.pinyin_map.entry(pinyin_key).or_default().push(word.to_string());
56 }
57 }
58
59 pub fn detect<'a>(&'a self, text: &str, original_words: &[&'a str]) -> Vec<&'a str> {
61 let mut variants = Vec::new();
62
63 variants.extend(self.detect_pinyin_variants(text, original_words));
65
66 variants.extend(self.detect_shape_variants(text, original_words));
68
69 variants.sort_unstable();
70 variants.dedup();
71 variants
72 }
73
74 fn detect_pinyin_variants<'a>(&'a self, text: &str, original_words: &[&'a str]) -> Vec<&'a str> {
76 let text_pinyin = self.text_to_pinyin(text);
77
78 original_words
79 .iter()
80 .filter(|&&word| {
81 let word_pinyin: String = word
83 .chars()
84 .map(|c| {
85 self.char_to_pinyin.get(&c).cloned().unwrap_or_else(|| c.to_string())
86 })
88 .collect();
89
90 text_pinyin.contains(&word_pinyin)
91 })
92 .copied()
93 .collect()
94 }
95
96 fn text_to_pinyin(&self, text: &str) -> String {
98 let uncached: Vec<char> =
100 text.chars().filter(|c| !c.is_ascii() && !self.char_to_pinyin.contains_key(c)).collect();
101 let extra: HashMap<char, String> = if uncached.is_empty() {
102 HashMap::new()
103 } else {
104 let uncached_str: String = uncached.iter().collect();
105 uncached
106 .into_iter()
107 .zip(Pinyin::chars(&uncached_str).with_tone_style(pinyin::ToneStyle::None).iter())
108 .collect()
109 };
110
111 text.chars()
112 .map(|c| {
113 self.char_to_pinyin.get(&c).cloned().or_else(|| extra.get(&c).cloned()).unwrap_or_else(|| c.to_string())
114 })
115 .collect()
116 }
117
118 fn detect_shape_variants<'a>(&'a self, text: &str, original_words: &[&'a str]) -> Vec<&'a str> {
120 original_words.iter().filter(|&&word| self.is_shape_variant(text, word)).copied().collect()
121 }
122
123 fn is_shape_variant(&self, text: &str, word: &str) -> bool {
125 let text_chars: Vec<char> = text.chars().collect();
126 let word_chars: Vec<char> = word.chars().collect();
127
128 if text_chars.len() != word_chars.len() {
129 return false;
130 }
131
132 text_chars
133 .iter()
134 .zip(word_chars.iter())
135 .all(|(&tc, &wc)| tc == wc || self.shape_map.get(&wc).is_some_and(|variants| variants.contains(&tc)))
136 }
137
138 fn build_shape_map() -> HashMap<char, Vec<char>> {
140 let mut map = HashMap::new();
141 map.insert('赌', vec!['渧', '睹', '堵']);
143 map.insert('博', vec!['搏', '傅', '膊']);
144 map.insert('有', vec!['友', '右']);
145 map.insert('色', vec!['涩']);
146 map.insert('情', vec!['请', '清']);
147 map
148 }
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154
155 #[test]
156 fn test_pinyin_detection() {
157 let mut vd = VariantDetector::new();
158 vd.add_word("赌博");
159 let results = vd.detect("dubo", &["赌博"]);
160 assert_eq!(results, vec!["赌博"]);
161 }
162
163 #[test]
164 fn test_pinyin_no_match() {
165 let mut vd = VariantDetector::new();
166 vd.add_word("赌博");
167 let results = vd.detect("hello", &["赌博"]);
168 assert!(results.is_empty());
169 }
170
171 #[test]
172 fn test_shape_variant_detection() {
173 let mut vd = VariantDetector::new();
174 vd.add_word("赌博");
175 let results = vd.detect("睹博", &["赌博"]);
177 assert_eq!(results, vec!["赌博"]);
178 }
179
180 #[test]
181 fn test_shape_no_match_different_length() {
182 let mut vd = VariantDetector::new();
183 vd.add_word("赌博");
184 let results = vd.detect("赌", &["赌博"]);
185 assert!(results.is_empty()); }
187
188 #[test]
189 fn test_empty_input() {
190 let mut vd = VariantDetector::new();
191 vd.add_word("赌博");
192 let results = vd.detect("", &["赌博"]);
193 assert!(results.is_empty());
194 }
195
196 #[test]
197 fn test_all_ascii_input() {
198 let mut vd = VariantDetector::new();
199 vd.add_word("赌博");
200 let results = vd.detect("hello world", &["赌博"]);
201 assert!(results.is_empty());
202 }
203
204 #[test]
205 fn test_mixed_script() {
206 let mut vd = VariantDetector::new();
207 vd.add_word("测试");
208 let results = vd.detect("这是 test 内容", &["测试"]);
209 assert!(results.is_empty());
211 }
212
213 #[test]
214 fn test_multiple_words() {
215 let mut vd = VariantDetector::new();
216 vd.add_word("赌博");
217 vd.add_word("色情");
218 let results = vd.detect("dubo and seqing", &["赌博", "色情"]);
219 assert_eq!(results.len(), 2);
220 }
221
222 #[test]
223 fn test_detect_dedup() {
224 let mut vd = VariantDetector::new();
227 vd.add_word("赌博");
228 let results = vd.detect("睹博", &["赌博"]);
229 assert_eq!(results.len(), 1);
230 assert_eq!(results, vec!["赌博"]);
231 }
232
233 #[test]
234 fn test_detect_returns_borrowed_slices() {
235 let mut vd = VariantDetector::new();
238 vd.add_word("赌博");
239 let words = ["赌博"];
240 let results = vd.detect("dubo", &words);
241 assert!(results.iter().all(|r| words.contains(r)));
242 }
243}