Skip to main content

sensitive_rs/variant/
mod.rs

1use pinyin::Pinyin;
2use std::collections::HashMap;
3
4/// Variation detector
5pub struct VariantDetector {
6    pinyin_map: HashMap<String, Vec<String>>, // The mapping of pinyin to original word
7    shape_map: HashMap<char, Vec<char>>,      // SHAPED CLOSE CHARACTER MAPPING
8    char_to_pinyin: HashMap<char, String>,    // Character to pinyin mapping
9}
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    /// Create a new detector
29    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    /// Construct pinyin index when adding sensitive words
38    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    /// Detect variants in text
60    pub fn detect<'a>(&'a self, text: &str, original_words: &[&'a str]) -> Vec<&'a str> {
61        let mut variants = Vec::new();
62
63        // 1. Detect pinyin variants
64        variants.extend(self.detect_pinyin_variants(text, original_words));
65
66        // 2. Detect shape-near-word variant
67        variants.extend(self.detect_shape_variants(text, original_words));
68
69        variants.sort_unstable();
70        variants.dedup();
71        variants
72    }
73
74    /// Detect pinyin variants
75    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                // Construct the pinyin of the word
82                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                        // Safe processing: Return original characters
87                    })
88                    .collect();
89
90                text_pinyin.contains(&word_pinyin)
91            })
92            .copied()
93            .collect()
94    }
95
96    /// Convert text to pinyin
97    fn text_to_pinyin(&self, text: &str) -> String {
98        // Build pinyin for uncached characters in batch
99        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    /// Detect shape-near-word variant
119    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    /// Determine whether it is a variant of the shape and character
124    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    /// Constructing a shape-size-word mapping table
139    fn build_shape_map() -> HashMap<char, Vec<char>> {
140        let mut map = HashMap::new();
141        // Example: Add some common characters
142        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        // "睹" is a shape variant of "赌"
176        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()); // different length
186    }
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        // "test" pinyin doesn't match "测试"
210        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        // A single original word can be matched by both the pinyin and shape
225        // paths simultaneously; `detect` must sort+dedup so it appears once.
226        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        // detect returns references into the caller's `original_words` slice,
236        // not freshly allocated Strings.
237        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}