@sideid/id-profanity-filter 1.9.6 → 1.10.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,13 @@
1
1
  import { FilterOptions, FilterResult, ProfanityWord } from "../types";
2
2
  import { findProfanity, findProfanityWithMetadata } from "./matcher";
3
- import { censorWord, escapeRegExp, normalizeText } from "../utils/stringUtils";
3
+ import { censorWord, escapeRegExp } from "../utils/stringUtils";
4
4
  import { createWordRegex } from "../utils/regexUtils";
5
- import {
6
- DEFAULT_OPTIONS,
7
- makeRandomGrawlixString,
8
- getRandomGrawlix,
9
- } from "../config/options";
5
+ import { DEFAULT_OPTIONS, makeRandomGrawlixString } from "../config/options";
6
+
7
+ interface FindProfanityFunction {
8
+ (text: string, options?: FilterOptions): string[];
9
+ lastActualMatches?: Map<string, string[]>;
10
+ }
10
11
 
11
12
  /**
12
13
  * Menyensor kata kotor dalam teks
@@ -28,6 +29,11 @@ export function filter(
28
29
  useRandomGrawlix = false,
29
30
  keepFirstAndLast = false,
30
31
  indonesianVariation = false,
32
+ detectSplit = false,
33
+ detectSimilarity = false,
34
+ useLevenshtein = false,
35
+ maxLevenshteinDistance = 2,
36
+ similarityThreshold = 0.8,
31
37
  } = { ...DEFAULT_OPTIONS, ...options };
32
38
 
33
39
  const matches = findProfanity(text, {
@@ -36,8 +42,16 @@ export function filter(
36
42
  whitelist,
37
43
  checkSubstring,
38
44
  indonesianVariation,
45
+ detectSplit,
46
+ detectSimilarity,
47
+ useLevenshtein,
48
+ maxLevenshteinDistance,
49
+ similarityThreshold,
39
50
  });
40
51
 
52
+ const actualMatches: Map<string, string[]> =
53
+ (findProfanity as FindProfanityFunction).lastActualMatches || new Map();
54
+
41
55
  const matchDetails = findProfanityWithMetadata(text, options);
42
56
 
43
57
  if (matches.length === 0) {
@@ -66,49 +80,176 @@ export function filter(
66
80
  )),
67
81
  );
68
82
 
69
- const regex = createWordRegex(word, {
70
- wholeWord: true,
71
- caseSensitive: false,
72
- leetSpeak: false,
73
- detectSplit: false,
74
- indonesianVariation: false,
75
- });
83
+ const variants = actualMatches.get(word.toLowerCase()) || [];
84
+ variants.push(word);
85
+
86
+ const uniqueVariants = [...new Set(variants)];
76
87
 
77
- let match;
78
- const textToSearch = filteredText;
88
+ uniqueVariants.forEach((variant) => {
89
+ const regex = new RegExp(`\\b${escapeRegExp(variant)}\\b`, "gi");
79
90
 
80
- regex.lastIndex = 0;
91
+ let match;
92
+ while ((match = regex.exec(filteredText)) !== null) {
93
+ const originalWord = match[0];
81
94
 
82
- while ((match = regex.exec(textToSearch)) !== null) {
83
- const originalWord = match[0];
95
+ if (whitelist.includes(originalWord.toLowerCase())) continue;
84
96
 
85
- if (whitelist.includes(originalWord.toLowerCase())) continue;
97
+ let censoredWord;
98
+ if (useRandomGrawlix) {
99
+ censoredWord = makeRandomGrawlixString(originalWord.length);
100
+ } else {
101
+ censoredWord = censorWord(
102
+ originalWord,
103
+ replaceWith,
104
+ !fullWordCensor && keepFirstAndLast,
105
+ );
106
+ }
86
107
 
87
- let censoredWord;
88
- if (useRandomGrawlix) {
89
- censoredWord = makeRandomGrawlixString(originalWord.length);
90
- } else {
91
- censoredWord = censorWord(
92
- originalWord,
93
- replaceWith,
94
- !fullWordCensor && keepFirstAndLast,
108
+ replacements.push({
109
+ original: originalWord,
110
+ censored: censoredWord,
111
+ metadata,
112
+ });
113
+
114
+ filteredText = filteredText.replace(
115
+ new RegExp(`\\b${escapeRegExp(originalWord)}\\b`, "g"),
116
+ censoredWord,
95
117
  );
96
118
  }
119
+ });
97
120
 
98
- replacements.push({
99
- original: originalWord,
100
- censored: censoredWord,
101
- metadata,
102
- });
121
+ if (detectSplit || detectLeetSpeak) {
122
+ if (detectLeetSpeak) {
123
+ const leetRegex = createWordRegex(word, {
124
+ wholeWord: true,
125
+ caseSensitive: false,
126
+ leetSpeak: true,
127
+ detectSplit: false,
128
+ indonesianVariation: false,
129
+ });
103
130
 
104
- const replaceRegex = new RegExp(
105
- `\\b${escapeRegExp(originalWord)}\\b`,
106
- "g",
107
- );
108
- filteredText = filteredText.replace(replaceRegex, censoredWord);
131
+ let match;
132
+ while ((match = leetRegex.exec(filteredText)) !== null) {
133
+ const originalWord = match[0];
134
+
135
+ if (whitelist.includes(originalWord.toLowerCase())) continue;
136
+
137
+ let censoredWord;
138
+ if (useRandomGrawlix) {
139
+ censoredWord = makeRandomGrawlixString(originalWord.length);
140
+ } else {
141
+ censoredWord = censorWord(
142
+ originalWord,
143
+ replaceWith,
144
+ !fullWordCensor && keepFirstAndLast,
145
+ );
146
+ }
147
+
148
+ replacements.push({
149
+ original: originalWord,
150
+ censored: censoredWord,
151
+ metadata,
152
+ });
153
+
154
+ filteredText = filteredText.replace(
155
+ new RegExp(escapeRegExp(originalWord), "g"),
156
+ censoredWord,
157
+ );
158
+ }
159
+ }
160
+
161
+ if (detectSplit) {
162
+ const splitRegex = createWordRegex(word, {
163
+ wholeWord: false,
164
+ caseSensitive: false,
165
+ leetSpeak: false,
166
+ detectSplit: true,
167
+ indonesianVariation: false,
168
+ });
169
+
170
+ let match;
171
+ while ((match = splitRegex.exec(filteredText)) !== null) {
172
+ const originalWord = match[0];
173
+
174
+ if (whitelist.includes(originalWord.toLowerCase())) continue;
175
+
176
+ let censoredWord;
177
+ if (useRandomGrawlix) {
178
+ censoredWord = makeRandomGrawlixString(originalWord.length);
179
+ } else {
180
+ censoredWord = censorWord(
181
+ originalWord,
182
+ replaceWith,
183
+ !fullWordCensor && keepFirstAndLast,
184
+ );
185
+ }
186
+
187
+ replacements.push({
188
+ original: originalWord,
189
+ censored: censoredWord,
190
+ metadata,
191
+ });
192
+
193
+ filteredText = filteredText.replace(
194
+ new RegExp(escapeRegExp(originalWord), "g"),
195
+ censoredWord,
196
+ );
197
+ }
198
+ }
109
199
  }
110
200
  });
111
201
 
202
+ if (detectSimilarity && useLevenshtein) {
203
+ matches.forEach((word) => {
204
+ const metadata = matchDetails.find(
205
+ (m) =>
206
+ m.word.toLowerCase() === word.toLowerCase() ||
207
+ (m.aliases &&
208
+ m.aliases.some(
209
+ (alias) => alias.toLowerCase() === word.toLowerCase(),
210
+ )),
211
+ );
212
+
213
+ const variants = actualMatches.get(word.toLowerCase()) || [];
214
+
215
+ variants.forEach((variant) => {
216
+ const exactVariantRegex = new RegExp(
217
+ `\\b${escapeRegExp(variant)}\\b`,
218
+ "gi",
219
+ );
220
+
221
+ let match;
222
+ while ((match = exactVariantRegex.exec(filteredText)) !== null) {
223
+ const originalWord = match[0];
224
+
225
+ if (whitelist.includes(originalWord.toLowerCase())) continue;
226
+
227
+ let censoredWord;
228
+ if (useRandomGrawlix) {
229
+ censoredWord = makeRandomGrawlixString(originalWord.length);
230
+ } else {
231
+ censoredWord = censorWord(
232
+ originalWord,
233
+ replaceWith,
234
+ !fullWordCensor && keepFirstAndLast,
235
+ );
236
+ }
237
+
238
+ replacements.push({
239
+ original: originalWord,
240
+ censored: censoredWord,
241
+ metadata,
242
+ });
243
+
244
+ filteredText = filteredText.replace(
245
+ new RegExp(`\\b${escapeRegExp(originalWord)}\\b`, "g"),
246
+ censoredWord,
247
+ );
248
+ }
249
+ });
250
+ });
251
+ }
252
+
112
253
  return {
113
254
  filtered: filteredText,
114
255
  censored: replacements.length,
@@ -5,25 +5,20 @@ import {
5
5
  FilterOptions,
6
6
  } from "../types";
7
7
 
8
- import { wordObjects, getWordsByFilter } from "../constants/wordList";
9
- import {
10
- normalizeText,
11
- escapeRegExp,
12
- containsAnyWord,
13
- detectSplitWords,
14
- } from "../utils/stringUtils";
15
- import {
16
- createWordRegex,
17
- addLeetSpeakVariations,
18
- addIndonesianVariations,
19
- addSplitVariations,
20
- } from "../utils/regexUtils";
8
+ import { wordObjects } from "../constants/wordList";
9
+ import { normalizeText } from "../utils/stringUtils";
10
+ import { createWordRegex } from "../utils/regexUtils";
21
11
  import {
22
12
  findPossibleProfanityBySimiliarity,
23
- stringSimilarity,
13
+ findProfanityByLevenshteinDistance,
24
14
  } from "../utils/similarityUtils";
25
15
  import { DEFAULT_OPTIONS } from "../config/options";
26
16
 
17
+ interface FindProfanityFunction {
18
+ (text: string, options?: FilterOptions): string[];
19
+ lastActualMatches?: Map<string, string[]>;
20
+ }
21
+
27
22
  export function findProfanity(
28
23
  text: string,
29
24
  options: FilterOptions = {},
@@ -40,38 +35,55 @@ export function findProfanity(
40
35
  detectSimilarity = false,
41
36
  similarityThreshold = 0.8,
42
37
  detectSplit = false,
38
+ useLevenshtein = false,
39
+ maxLevenshteinDistance = 2,
43
40
  } = { ...DEFAULT_OPTIONS, ...options };
44
41
 
45
42
  const normalizedText = normalizeText(text);
46
43
 
47
- let wordsToCheck: string[] = wordList.length > 0 ? wordList : [];
44
+ let baseWordsToCheck: string[] = wordList.length > 0 ? wordList : [];
48
45
 
49
- if (wordsToCheck.length === 0) {
50
- if (categories || regions || severityThreshold > 0) {
51
- wordsToCheck = wordObjects
52
- .filter((word) => {
53
- const matchCategory = categories
54
- ? categories.includes(word.category)
55
- : true;
56
- const matchRegion = regions ? regions.includes(word.region) : true;
57
- const matchSeverity = word.severity >= severityThreshold;
58
- return matchCategory && matchRegion && matchSeverity;
59
- })
60
- .map((word) => word.word);
61
- } else {
62
- wordsToCheck = wordObjects.map((word) => word.word);
63
- }
46
+ if (baseWordsToCheck.length === 0) {
47
+ const filteredWords = wordObjects.filter((word) => {
48
+ const matchCategory = categories
49
+ ? categories.includes(word.category)
50
+ : true;
51
+ const matchRegion = regions ? regions.includes(word.region) : true;
52
+ const matchSeverity = word.severity >= severityThreshold;
53
+ return matchCategory && matchRegion && matchSeverity;
54
+ });
55
+
56
+ baseWordsToCheck = filteredWords.map((word) => word.word);
64
57
  }
65
58
 
66
- wordsToCheck = wordsToCheck.filter(
67
- (word) => !whitelist.includes(word.toLocaleLowerCase()),
68
- );
59
+ const aliasMap = new Map<string, string>();
60
+ wordObjects.forEach((wordObj) => {
61
+ if (wordObj.aliases && wordObj.aliases.length > 0) {
62
+ const matchCategory = categories
63
+ ? categories.includes(wordObj.category)
64
+ : true;
65
+ const matchRegion = regions ? regions.includes(wordObj.region) : true;
66
+ const matchSeverity = wordObj.severity >= severityThreshold;
67
+
68
+ if (matchCategory && matchRegion && matchSeverity) {
69
+ wordObj.aliases.forEach((alias) => {
70
+ aliasMap.set(alias.toLowerCase(), wordObj.word.toLowerCase());
71
+ });
72
+ }
73
+ }
74
+ });
75
+
76
+ const wordsToCheck = [
77
+ ...baseWordsToCheck,
78
+ ...Array.from(aliasMap.keys()),
79
+ ].filter((word) => !whitelist.includes(word.toLowerCase()));
69
80
 
70
81
  if (wordsToCheck.length === 0) {
71
82
  return [];
72
83
  }
73
84
 
74
85
  const matches = new Set<string>();
86
+ const actualMatches = new Map<string, string[]>();
75
87
 
76
88
  wordsToCheck.forEach((word) => {
77
89
  const regex = createWordRegex(word, {
@@ -84,7 +96,14 @@ export function findProfanity(
84
96
 
85
97
  let match;
86
98
  while ((match = regex.exec(normalizedText)) !== null) {
87
- matches.add(word.toLowerCase());
99
+ const originalWord =
100
+ aliasMap.get(word.toLowerCase()) || word.toLowerCase();
101
+ matches.add(originalWord);
102
+
103
+ if (!actualMatches.has(originalWord)) {
104
+ actualMatches.set(originalWord, []);
105
+ }
106
+ actualMatches.get(originalWord)?.push(match[0]);
88
107
  }
89
108
  });
90
109
 
@@ -100,7 +119,14 @@ export function findProfanity(
100
119
 
101
120
  let match;
102
121
  while ((match = leetRegex.exec(text)) !== null) {
103
- matches.add(word.toLowerCase());
122
+ const originalWord =
123
+ aliasMap.get(word.toLowerCase()) || word.toLowerCase();
124
+ matches.add(originalWord);
125
+
126
+ if (!actualMatches.has(originalWord)) {
127
+ actualMatches.set(originalWord, []);
128
+ }
129
+ actualMatches.get(originalWord)?.push(match[0]);
104
130
  }
105
131
  });
106
132
  }
@@ -117,41 +143,85 @@ export function findProfanity(
117
143
 
118
144
  let match;
119
145
  while ((match = variantRegex.exec(text)) !== null) {
120
- matches.add(word.toLowerCase());
146
+ const originalWord =
147
+ aliasMap.get(word.toLowerCase()) || word.toLowerCase();
148
+ matches.add(originalWord);
149
+
150
+ if (!actualMatches.has(originalWord)) {
151
+ actualMatches.set(originalWord, []);
152
+ }
153
+ actualMatches.get(originalWord)?.push(match[0]);
121
154
  }
122
155
  });
123
156
  }
124
157
 
125
158
  if (detectSplit) {
126
- if (detectSplitWords(text, wordsToCheck)) {
127
- wordsToCheck.forEach((word) => {
128
- const splitRegex = createWordRegex(word, {
129
- wholeWord: false,
130
- caseSensitive: false,
131
- leetSpeak: false,
132
- detectSplit: true,
133
- indonesianVariation: false,
134
- });
159
+ wordsToCheck.forEach((word) => {
160
+ const splitRegex = createWordRegex(word, {
161
+ wholeWord: false,
162
+ caseSensitive: false,
163
+ leetSpeak: false,
164
+ detectSplit: true,
165
+ indonesianVariation: false,
166
+ });
135
167
 
136
- if (splitRegex.test(text)) {
137
- matches.add(word.toLowerCase());
168
+ let match;
169
+ while ((match = splitRegex.exec(text)) !== null) {
170
+ const originalWord =
171
+ aliasMap.get(word.toLowerCase()) || word.toLowerCase();
172
+ matches.add(originalWord);
173
+
174
+ if (!actualMatches.has(originalWord)) {
175
+ actualMatches.set(originalWord, []);
138
176
  }
139
- });
140
- }
177
+ actualMatches.get(originalWord)?.push(match[0]);
178
+ }
179
+ });
141
180
  }
142
181
 
143
182
  if (detectSimilarity) {
144
- const possibleProfanity = findPossibleProfanityBySimiliarity(
145
- text,
146
- wordsToCheck,
147
- similarityThreshold,
148
- );
149
-
150
- possibleProfanity.forEach((item) => {
151
- matches.add(item.original.toLowerCase());
152
- });
183
+ if (useLevenshtein) {
184
+ const possibleProfanity = findProfanityByLevenshteinDistance(
185
+ text,
186
+ wordsToCheck,
187
+ similarityThreshold,
188
+ maxLevenshteinDistance,
189
+ );
190
+
191
+ possibleProfanity.forEach((item) => {
192
+ const originalWord =
193
+ aliasMap.get(item.original.toLowerCase()) ||
194
+ item.original.toLowerCase();
195
+ matches.add(originalWord);
196
+
197
+ if (!actualMatches.has(originalWord)) {
198
+ actualMatches.set(originalWord, []);
199
+ }
200
+ actualMatches.get(originalWord)?.push(item.word);
201
+ });
202
+ } else {
203
+ const possibleProfanity = findPossibleProfanityBySimiliarity(
204
+ text,
205
+ wordsToCheck,
206
+ similarityThreshold,
207
+ );
208
+
209
+ possibleProfanity.forEach((item) => {
210
+ matches.add(item.original.toLowerCase());
211
+
212
+ const originalWord =
213
+ aliasMap.get(item.original.toLowerCase()) ||
214
+ item.original.toLowerCase();
215
+ if (!actualMatches.has(originalWord)) {
216
+ actualMatches.set(originalWord, []);
217
+ }
218
+ actualMatches.get(originalWord)?.push(item.word);
219
+ });
220
+ }
153
221
  }
154
222
 
223
+ (findProfanity as FindProfanityFunction).lastActualMatches = actualMatches;
224
+
155
225
  return Array.from(matches);
156
226
  }
157
227
 
package/src/index.ts CHANGED
@@ -21,7 +21,6 @@ import {
21
21
  CATEGORY_PRESETS,
22
22
  REGION_PRESETS,
23
23
  getPresetOptions,
24
- makeRandomGrawlixString,
25
24
  } from "./config/options";
26
25
 
27
26
  export class IDProfanityFilter {
@@ -161,10 +160,30 @@ export class IDProfanityFilter {
161
160
  /**
162
161
  * Mengaktifkan deteksi berdasarkan kesamaan
163
162
  * @param threshold Threshold kesamaan (0-1)
163
+ * @param useLevenshtein Gunakan algoritma Levenshtein untuk deteksi
164
+ * @param maxLevenshteinDistance Jarak maksimal Levenshtein (default: 2)
164
165
  */
165
- enableSimilarityDetection(threshold: number = 0.8) {
166
+ enableSimilarityDetection(
167
+ threshold: number = 0.8,
168
+ useLevenshtein: boolean = false,
169
+ maxLevenshteinDistance: number = 2,
170
+ ) {
171
+ this.options.detectSimilarity = true;
172
+ this.options.similarityThreshold = threshold;
173
+ this.options.useLevenshtein = useLevenshtein;
174
+ this.options.maxLevenshteinDistance = maxLevenshteinDistance;
175
+ }
176
+
177
+ /**
178
+ * Mengaktifkan deteksi berbasis Levenshtein distance
179
+ * @param threshold Threshold kesamaan (0-1)
180
+ * @param maxDistance Jarak maksimal Levenshtein (default: 2)
181
+ */
182
+ enableLevenshteinDetection(threshold: number = 0.8, maxDistance: number = 2) {
166
183
  this.options.detectSimilarity = true;
184
+ this.options.useLevenshtein = true;
167
185
  this.options.similarityThreshold = threshold;
186
+ this.options.maxLevenshteinDistance = maxDistance;
168
187
  }
169
188
  }
170
189
 
@@ -50,9 +50,11 @@ export interface FilterOptions {
50
50
  useRandomGrawlix?: boolean;
51
51
  keepFirstAndLast?: boolean;
52
52
  indonesianVariation?: boolean;
53
- detectSimilarity?: boolean;
54
- similarityThreshold?: number;
53
+ detectSimilarity?: boolean; // Enable/disable Levenshtein distance matching
54
+ similarityThreshold?: number; // Threshold for Levenshtein distance similarity (0-1)
55
55
  detectSplit?: boolean;
56
+ useLevenshtein?: boolean; // New option specifically for Levenshtein algorithm
57
+ maxLevenshteinDistance?: number; // Maximum allowed Levenshtein distance
56
58
  }
57
59
 
58
60
  export interface FilterResult {
@@ -72,7 +72,6 @@ export function addLeetSpeakVariations(pattern: string): string {
72
72
  z: ["z", "2"],
73
73
  };
74
74
 
75
- // Ganti tiap karakter dengan variasinya dalam grup character class
76
75
  return pattern
77
76
  .split("")
78
77
  .map((char) => {
@@ -91,6 +91,48 @@ export function findMostSimilar(
91
91
  return mostSimilar;
92
92
  }
93
93
 
94
+ /**
95
+ * Mencari string yang paling mirip dari array menggunakan Levenshtein distance
96
+ *
97
+ * @param target String target
98
+ * @param candidates Array string kandidat
99
+ * @param threshold Minimum kesamaan yang diterima (0-1)
100
+ * @param maxDistance Jarak Levenshtein maksimal yang diterima (default: 3)
101
+ * @returns String yang paling mirip atau null jika tidak ada yang di atas threshold
102
+ */
103
+ export function findMostSimilarWithLevenshtein(
104
+ target: string,
105
+ candidates: string[],
106
+ threshold: number = 0.7,
107
+ maxDistance: number = 3,
108
+ ): string | null {
109
+ if (!candidates.length) return null;
110
+
111
+ let maxSimilarity = 0;
112
+ let minDistance = Infinity;
113
+ let mostSimilar: string | null = null;
114
+
115
+ for (const candidate of candidates) {
116
+ if (Math.abs(target.length - candidate.length) > maxDistance) continue;
117
+
118
+ const distance = levenshteinDistance(target, candidate);
119
+ const similarity = stringSimilarity(target, candidate);
120
+
121
+ if (
122
+ (similarity > maxSimilarity && similarity >= threshold) ||
123
+ (similarity >= threshold && distance < minDistance)
124
+ ) {
125
+ maxSimilarity = similarity;
126
+ minDistance = distance;
127
+ mostSimilar = candidate;
128
+
129
+ if (distance <= 1 || similarity > 0.95) break;
130
+ }
131
+ }
132
+
133
+ return mostSimilar;
134
+ }
135
+
94
136
  /**
95
137
  * Cek apakah string mungkin merupakan variasi dari kata kotor
96
138
  * menggunakan kesamaan string
@@ -170,11 +212,9 @@ export function findPossibleProfanityBySimiliarity(
170
212
  const result: Array<{ word: string; original: string; similarity: number }> =
171
213
  [];
172
214
 
173
- // Pisahkan teks menjadi kata-kata
174
215
  const words = text.toLowerCase().split(/\s+/);
175
216
 
176
217
  for (const word of words) {
177
- // Lewati kata-kata yang terlalu pendek
178
218
  if (word.length < 3) continue;
179
219
 
180
220
  for (const profanity of profanityWords) {
@@ -193,3 +233,58 @@ export function findPossibleProfanityBySimiliarity(
193
233
 
194
234
  return result;
195
235
  }
236
+
237
+ /**
238
+ * Cari kata-kata kotor yang mungkin dari teks menggunakan Levenshtein distance
239
+ *
240
+ * @param text Teks yang akan diperiksa
241
+ * @param profanityWords Daftar kata kotor
242
+ * @param threshold Batas minimum kesamaan (default: 0.8)
243
+ * @param maxDistance Jarak Levenshtein maksimal (default: 2)
244
+ * @returns Array kata yang mungkin merupakan kata kotor
245
+ */
246
+ export function findProfanityByLevenshteinDistance(
247
+ text: string,
248
+ profanityWords: string[],
249
+ threshold: number = 0.8,
250
+ maxDistance: number = 2,
251
+ ): Array<{
252
+ word: string;
253
+ original: string;
254
+ similarity: number;
255
+ distance: number;
256
+ }> {
257
+ const result: Array<{
258
+ word: string;
259
+ original: string;
260
+ similarity: number;
261
+ distance: number;
262
+ }> = [];
263
+
264
+ const words = text.toLowerCase().split(/\s+/);
265
+
266
+ for (const word of words) {
267
+ if (word.length < 3) continue;
268
+
269
+ for (const profanity of profanityWords) {
270
+ if (Math.abs(word.length - profanity.length) > maxDistance) continue;
271
+
272
+ const distance = levenshteinDistance(word, profanity);
273
+ if (distance <= maxDistance) {
274
+ const similarity = stringSimilarity(word, profanity);
275
+
276
+ if (similarity >= threshold) {
277
+ result.push({
278
+ word,
279
+ original: profanity,
280
+ similarity,
281
+ distance,
282
+ });
283
+ break;
284
+ }
285
+ }
286
+ }
287
+ }
288
+
289
+ return result;
290
+ }