@sideid/id-profanity-filter 1.11.13 → 1.13.0
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.
- package/CONTRIBUTING.md +91 -129
- package/README.md +172 -447
- package/dist/index.d.ts +11 -6
- package/dist/index.esm.js +161 -180
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +161 -180
- package/dist/index.js.map +1 -1
- package/dist/types/core/matcher.d.ts +5 -5
- package/dist/types/index.d.ts +5 -0
- package/dist/types/types/index.d.ts +1 -1
- package/dist/types/utils/ahoCorasick.d.ts +10 -0
- package/eslint.config.mjs +1 -0
- package/examples/advanced.ts +4 -1
- package/examples/custom-list.ts +3 -3
- package/package.json +9 -2
- package/rollup.config.mjs +5 -1
- package/src/constants/categories/sexual.ts +1 -1
- package/src/constants/regions/general.ts +3 -3
- package/src/constants/wordList.ts +0 -27
- package/src/core/filter.ts +58 -178
- package/src/core/matcher.ts +69 -43
- package/src/index.ts +8 -0
- package/src/types/index.ts +1 -11
- package/src/utils/ahoCorasick.ts +37 -0
- package/src/utils/regexUtils.ts +1 -2
- package/src/utils/similarityUtils.ts +0 -4
- package/test/matcher.test.ts +18 -0
- package/test/profanity-filter.test.ts +15 -0
- package/.eslintrc.js +0 -44
- package/src/constants/regions/ambon.ts +0 -0
- package/src/constants/regions/banjar.ts +0 -0
- package/src/constants/regions/bugis.ts +0 -0
- package/src/constants/regions/lampung.ts +0 -0
- package/src/constants/regions/manado.ts +0 -0
- package/src/constants/regions/ntb.ts +0 -0
- package/src/constants/regions/ntt.ts +0 -0
- package/src/constants/regions/palembang.ts +0 -0
- package/src/constants/regions/papua.ts +0 -0
package/package.json
CHANGED
|
@@ -1,14 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sideid/id-profanity-filter",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.13.0",
|
|
4
4
|
"description": "Library filter kata kotor dalam Bahasa Indonesia",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"module": "dist/index.esm.js",
|
|
7
7
|
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.esm.js",
|
|
12
|
+
"require": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
8
15
|
"scripts": {
|
|
9
16
|
"build": "rollup -c",
|
|
10
17
|
"test": "jest",
|
|
11
|
-
"lint": "eslint
|
|
18
|
+
"lint": "eslint src/",
|
|
12
19
|
"format": "prettier --write \"src/**/*.ts\""
|
|
13
20
|
},
|
|
14
21
|
"keywords": [
|
package/rollup.config.mjs
CHANGED
|
@@ -3,7 +3,11 @@ import commonjs from '@rollup/plugin-commonjs';
|
|
|
3
3
|
import typescript from '@rollup/plugin-typescript';
|
|
4
4
|
import json from '@rollup/plugin-json';
|
|
5
5
|
import dts from 'rollup-plugin-dts';
|
|
6
|
-
import
|
|
6
|
+
import { readFileSync } from 'node:fs';
|
|
7
|
+
|
|
8
|
+
const pkg = JSON.parse(
|
|
9
|
+
readFileSync(new URL('./package.json', import.meta.url), 'utf-8')
|
|
10
|
+
);
|
|
7
11
|
|
|
8
12
|
export default [
|
|
9
13
|
{
|
|
@@ -79,7 +79,7 @@ export const sexual: ProfanityWord[] = [
|
|
|
79
79
|
category: 'sexual',
|
|
80
80
|
region: 'general',
|
|
81
81
|
severity: 0.9,
|
|
82
|
-
aliases: ['ngew'
|
|
82
|
+
aliases: ['ngew'],
|
|
83
83
|
description: 'Istilah kasar untuk aktivitas seksual',
|
|
84
84
|
context: 'Kata vulgar yang merujuk pada aktivitas seksual',
|
|
85
85
|
},
|
|
@@ -6,7 +6,7 @@ export const general: ProfanityWord[] = [
|
|
|
6
6
|
category: 'profanity',
|
|
7
7
|
region: 'general',
|
|
8
8
|
severity: 0.7,
|
|
9
|
-
aliases: ['anjay', 'anjir', 'anying', 'njing', 'anj', 'anjg', 'ajg'],
|
|
9
|
+
aliases: ['anjay', 'anjir', 'anying', 'njing', 'anj', 'anjg', 'ajg', 'anjic', 'anjink'],
|
|
10
10
|
description: 'Mengacu pada hewan anjing, digunakan sebagai umpatan',
|
|
11
11
|
context: 'Umpatan umum untuk menunjukkan kemarahan atau ketidaksetujuan',
|
|
12
12
|
},
|
|
@@ -115,7 +115,7 @@ export const general: ProfanityWord[] = [
|
|
|
115
115
|
category: 'insult',
|
|
116
116
|
region: 'general',
|
|
117
117
|
severity: 0.5,
|
|
118
|
-
aliases: ['sialn'
|
|
118
|
+
aliases: ['sialn'],
|
|
119
119
|
description: 'Kata yang mengacu pada orang yang membawa sial',
|
|
120
120
|
context: 'Hinaan untuk menyebut orang yang dianggap membawa sial',
|
|
121
121
|
},
|
|
@@ -178,7 +178,7 @@ export const general: ProfanityWord[] = [
|
|
|
178
178
|
category: 'sexual',
|
|
179
179
|
region: 'general',
|
|
180
180
|
severity: 0.9,
|
|
181
|
-
aliases: ['ngew'
|
|
181
|
+
aliases: ['ngew'],
|
|
182
182
|
description: 'Istilah kasar untuk aktivitas seksual',
|
|
183
183
|
context: 'Kata vulgar yang merujuk pada aktivitas seksual',
|
|
184
184
|
},
|
|
@@ -15,16 +15,7 @@ import { batak, batakWords } from './regions/batak';
|
|
|
15
15
|
import { minang, minangWords } from './regions/minang';
|
|
16
16
|
import { bali, baliWords } from './regions/bali';
|
|
17
17
|
import { madura, maduraWords } from './regions/madura';
|
|
18
|
-
// import { bugis, bugisWords } from './regions/bugis';
|
|
19
18
|
import { aceh, acehWords } from './regions/aceh';
|
|
20
|
-
// import { ambon, ambonWords } from './regions/ambon';
|
|
21
|
-
// import { papua, papuaWords } from './regions/papua';
|
|
22
|
-
// import { manado, manadoWords } from './regions/manado';
|
|
23
|
-
// import { banjar, banjarWords } from './regions/banjar';
|
|
24
|
-
// import { palembang, palembangWords } from './regions/palembang';
|
|
25
|
-
// import { lampung, lampungWords } from './regions/lampung';
|
|
26
|
-
// import { ntt, nttWords } from './regions/ntt';
|
|
27
|
-
// import { ntb, ntbWords } from './regions/ntb';
|
|
28
19
|
|
|
29
20
|
export const wordCategories = {
|
|
30
21
|
sexual: sexualWords,
|
|
@@ -45,16 +36,7 @@ export const wordRegions = {
|
|
|
45
36
|
minang: minangWords,
|
|
46
37
|
bali: baliWords,
|
|
47
38
|
madura: maduraWords,
|
|
48
|
-
// bugis: bugisWords,
|
|
49
39
|
aceh: acehWords,
|
|
50
|
-
// ambon: ambonWords,
|
|
51
|
-
// papua: papuaWords,
|
|
52
|
-
// manado: manadoWords,
|
|
53
|
-
// banjar: banjarWords,
|
|
54
|
-
// palembang: palembangWords,
|
|
55
|
-
// lampung: lampungWords,
|
|
56
|
-
// ntt: nttWords,
|
|
57
|
-
// ntb: ntbWords,
|
|
58
40
|
};
|
|
59
41
|
|
|
60
42
|
export const wordObjects: ProfanityWord[] = [
|
|
@@ -66,16 +48,7 @@ export const wordObjects: ProfanityWord[] = [
|
|
|
66
48
|
...minang,
|
|
67
49
|
...bali,
|
|
68
50
|
...madura,
|
|
69
|
-
// ...bugis,
|
|
70
51
|
...aceh,
|
|
71
|
-
// ...ambon,
|
|
72
|
-
// ...papua,
|
|
73
|
-
// ...manado,
|
|
74
|
-
// ...banjar,
|
|
75
|
-
// ...palembang,
|
|
76
|
-
// ...lampung,
|
|
77
|
-
// ...ntt,
|
|
78
|
-
// ...ntb,
|
|
79
52
|
];
|
|
80
53
|
|
|
81
54
|
export const allWords: string[] = wordObjects.map((item) => item.word);
|
package/src/core/filter.ts
CHANGED
|
@@ -17,6 +17,7 @@ interface FindProfanityFunction {
|
|
|
17
17
|
* @returns FilterResult dengan hasil filter
|
|
18
18
|
*/
|
|
19
19
|
export function filter(text: string, options: FilterOptions = {}): FilterResult {
|
|
20
|
+
const mergedOptions = { ...DEFAULT_OPTIONS, ...options };
|
|
20
21
|
const {
|
|
21
22
|
replaceWith = '*',
|
|
22
23
|
fullWordCensor = true,
|
|
@@ -25,31 +26,10 @@ export function filter(text: string, options: FilterOptions = {}): FilterResult
|
|
|
25
26
|
checkSubstring = false,
|
|
26
27
|
useRandomGrawlix = false,
|
|
27
28
|
keepFirstAndLast = false,
|
|
28
|
-
indonesianVariation = false,
|
|
29
29
|
detectSplit = false,
|
|
30
|
-
|
|
31
|
-
useLevenshtein = false,
|
|
32
|
-
maxLevenshteinDistance = 2,
|
|
33
|
-
similarityThreshold = 0.8,
|
|
34
|
-
} = { ...DEFAULT_OPTIONS, ...options };
|
|
35
|
-
|
|
36
|
-
const matches = findProfanity(text, {
|
|
37
|
-
...options,
|
|
38
|
-
detectLeetSpeak,
|
|
39
|
-
whitelist,
|
|
40
|
-
checkSubstring,
|
|
41
|
-
indonesianVariation,
|
|
42
|
-
detectSplit,
|
|
43
|
-
detectSimilarity,
|
|
44
|
-
useLevenshtein,
|
|
45
|
-
maxLevenshteinDistance,
|
|
46
|
-
similarityThreshold,
|
|
47
|
-
});
|
|
30
|
+
} = mergedOptions;
|
|
48
31
|
|
|
49
|
-
const
|
|
50
|
-
(findProfanity as FindProfanityFunction).lastActualMatches || new Map();
|
|
51
|
-
|
|
52
|
-
const matchDetails = findProfanityWithMetadata(text, options);
|
|
32
|
+
const matches = findProfanity(text, mergedOptions);
|
|
53
33
|
|
|
54
34
|
if (matches.length === 0) {
|
|
55
35
|
return {
|
|
@@ -59,15 +39,42 @@ export function filter(text: string, options: FilterOptions = {}): FilterResult
|
|
|
59
39
|
};
|
|
60
40
|
}
|
|
61
41
|
|
|
62
|
-
|
|
42
|
+
const actualMatches: Map<string, string[]> =
|
|
43
|
+
(findProfanity as FindProfanityFunction).lastActualMatches || new Map();
|
|
44
|
+
const matchDetails = findProfanityWithMetadata(text, mergedOptions);
|
|
45
|
+
const normalizedWhitelist = whitelist.map((w) => w.toLowerCase());
|
|
63
46
|
|
|
47
|
+
let filteredText = text;
|
|
64
48
|
const replacements: Array<{
|
|
65
49
|
original: string;
|
|
66
50
|
censored: string;
|
|
67
51
|
metadata?: ProfanityWord;
|
|
68
52
|
}> = [];
|
|
69
53
|
|
|
70
|
-
|
|
54
|
+
const getCensoredWord = (originalWord: string): string => {
|
|
55
|
+
if (useRandomGrawlix) {
|
|
56
|
+
return makeRandomGrawlixString(originalWord.length);
|
|
57
|
+
}
|
|
58
|
+
return censorWord(originalWord, replaceWith, !fullWordCensor && keepFirstAndLast);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const applyReplacement = (pattern: RegExp, metadata?: ProfanityWord) => {
|
|
62
|
+
filteredText = filteredText.replace(pattern, (matchedStr) => {
|
|
63
|
+
if (normalizedWhitelist.includes(matchedStr.toLowerCase())) {
|
|
64
|
+
return matchedStr;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const censored = getCensoredWord(matchedStr);
|
|
68
|
+
replacements.push({
|
|
69
|
+
original: matchedStr,
|
|
70
|
+
censored,
|
|
71
|
+
metadata,
|
|
72
|
+
});
|
|
73
|
+
return censored;
|
|
74
|
+
});
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
for (const word of matches) {
|
|
71
78
|
const metadata = matchDetails.find(
|
|
72
79
|
(m) =>
|
|
73
80
|
m.word.toLowerCase() === word.toLowerCase() ||
|
|
@@ -75,163 +82,36 @@ export function filter(text: string, options: FilterOptions = {}): FilterResult
|
|
|
75
82
|
);
|
|
76
83
|
|
|
77
84
|
const variants = actualMatches.get(word.toLowerCase()) || [];
|
|
78
|
-
variants.
|
|
85
|
+
const allVariants = [...new Set([...variants, word])].sort((a, b) => b.length - a.length);
|
|
79
86
|
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
while ((match = regex.exec(filteredText)) !== null) {
|
|
87
|
-
const originalWord = match[0];
|
|
88
|
-
|
|
89
|
-
if (whitelist.includes(originalWord.toLowerCase())) continue;
|
|
90
|
-
|
|
91
|
-
let censoredWord;
|
|
92
|
-
if (useRandomGrawlix) {
|
|
93
|
-
censoredWord = makeRandomGrawlixString(originalWord.length);
|
|
94
|
-
} else {
|
|
95
|
-
censoredWord = censorWord(originalWord, replaceWith, !fullWordCensor && keepFirstAndLast);
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
replacements.push({
|
|
99
|
-
original: originalWord,
|
|
100
|
-
censored: censoredWord,
|
|
101
|
-
metadata,
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
filteredText = filteredText.replace(
|
|
105
|
-
new RegExp(`\\b${escapeRegExp(originalWord)}\\b`, 'g'),
|
|
106
|
-
censoredWord
|
|
107
|
-
);
|
|
108
|
-
}
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
if (detectSplit || detectLeetSpeak) {
|
|
112
|
-
if (detectLeetSpeak) {
|
|
113
|
-
const leetRegex = createWordRegex(word, {
|
|
114
|
-
wholeWord: true,
|
|
115
|
-
caseSensitive: false,
|
|
116
|
-
leetSpeak: true,
|
|
117
|
-
detectSplit: false,
|
|
118
|
-
indonesianVariation: false,
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
let match;
|
|
122
|
-
while ((match = leetRegex.exec(filteredText)) !== null) {
|
|
123
|
-
const originalWord = match[0];
|
|
124
|
-
|
|
125
|
-
if (whitelist.includes(originalWord.toLowerCase())) continue;
|
|
126
|
-
|
|
127
|
-
let censoredWord;
|
|
128
|
-
if (useRandomGrawlix) {
|
|
129
|
-
censoredWord = makeRandomGrawlixString(originalWord.length);
|
|
130
|
-
} else {
|
|
131
|
-
censoredWord = censorWord(
|
|
132
|
-
originalWord,
|
|
133
|
-
replaceWith,
|
|
134
|
-
!fullWordCensor && keepFirstAndLast
|
|
135
|
-
);
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
replacements.push({
|
|
139
|
-
original: originalWord,
|
|
140
|
-
censored: censoredWord,
|
|
141
|
-
metadata,
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
filteredText = filteredText.replace(
|
|
145
|
-
new RegExp(escapeRegExp(originalWord), 'g'),
|
|
146
|
-
censoredWord
|
|
147
|
-
);
|
|
148
|
-
}
|
|
149
|
-
}
|
|
87
|
+
for (const variant of allVariants) {
|
|
88
|
+
const boundaryPattern = checkSubstring
|
|
89
|
+
? escapeRegExp(variant)
|
|
90
|
+
: `\\b${escapeRegExp(variant)}\\b`;
|
|
91
|
+
applyReplacement(new RegExp(boundaryPattern, 'gi'), metadata);
|
|
92
|
+
}
|
|
150
93
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
let match;
|
|
161
|
-
while ((match = splitRegex.exec(filteredText)) !== null) {
|
|
162
|
-
const originalWord = match[0];
|
|
163
|
-
|
|
164
|
-
if (whitelist.includes(originalWord.toLowerCase())) continue;
|
|
165
|
-
|
|
166
|
-
let censoredWord;
|
|
167
|
-
if (useRandomGrawlix) {
|
|
168
|
-
censoredWord = makeRandomGrawlixString(originalWord.length);
|
|
169
|
-
} else {
|
|
170
|
-
censoredWord = censorWord(
|
|
171
|
-
originalWord,
|
|
172
|
-
replaceWith,
|
|
173
|
-
!fullWordCensor && keepFirstAndLast
|
|
174
|
-
);
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
replacements.push({
|
|
178
|
-
original: originalWord,
|
|
179
|
-
censored: censoredWord,
|
|
180
|
-
metadata,
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
filteredText = filteredText.replace(
|
|
184
|
-
new RegExp(escapeRegExp(originalWord), 'g'),
|
|
185
|
-
censoredWord
|
|
186
|
-
);
|
|
187
|
-
}
|
|
188
|
-
}
|
|
94
|
+
if (detectLeetSpeak) {
|
|
95
|
+
const leetRegex = createWordRegex(word, {
|
|
96
|
+
wholeWord: !checkSubstring,
|
|
97
|
+
caseSensitive: false,
|
|
98
|
+
leetSpeak: true,
|
|
99
|
+
detectSplit: false,
|
|
100
|
+
indonesianVariation: false,
|
|
101
|
+
});
|
|
102
|
+
applyReplacement(leetRegex, metadata);
|
|
189
103
|
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
);
|
|
199
|
-
|
|
200
|
-
const variants = actualMatches.get(word.toLowerCase()) || [];
|
|
201
|
-
|
|
202
|
-
variants.forEach((variant) => {
|
|
203
|
-
const exactVariantRegex = new RegExp(`\\b${escapeRegExp(variant)}\\b`, 'gi');
|
|
204
|
-
|
|
205
|
-
let match;
|
|
206
|
-
while ((match = exactVariantRegex.exec(filteredText)) !== null) {
|
|
207
|
-
const originalWord = match[0];
|
|
208
|
-
|
|
209
|
-
if (whitelist.includes(originalWord.toLowerCase())) continue;
|
|
210
|
-
|
|
211
|
-
let censoredWord;
|
|
212
|
-
if (useRandomGrawlix) {
|
|
213
|
-
censoredWord = makeRandomGrawlixString(originalWord.length);
|
|
214
|
-
} else {
|
|
215
|
-
censoredWord = censorWord(
|
|
216
|
-
originalWord,
|
|
217
|
-
replaceWith,
|
|
218
|
-
!fullWordCensor && keepFirstAndLast
|
|
219
|
-
);
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
replacements.push({
|
|
223
|
-
original: originalWord,
|
|
224
|
-
censored: censoredWord,
|
|
225
|
-
metadata,
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
filteredText = filteredText.replace(
|
|
229
|
-
new RegExp(`\\b${escapeRegExp(originalWord)}\\b`, 'g'),
|
|
230
|
-
censoredWord
|
|
231
|
-
);
|
|
232
|
-
}
|
|
104
|
+
|
|
105
|
+
if (detectSplit) {
|
|
106
|
+
const splitRegex = createWordRegex(word, {
|
|
107
|
+
wholeWord: false,
|
|
108
|
+
caseSensitive: false,
|
|
109
|
+
leetSpeak: false,
|
|
110
|
+
detectSplit: true,
|
|
111
|
+
indonesianVariation: false,
|
|
233
112
|
});
|
|
234
|
-
|
|
113
|
+
applyReplacement(splitRegex, metadata);
|
|
114
|
+
}
|
|
235
115
|
}
|
|
236
116
|
|
|
237
117
|
return {
|
package/src/core/matcher.ts
CHANGED
|
@@ -10,18 +10,23 @@ import {
|
|
|
10
10
|
import { DEFAULT_OPTIONS } from '../config/options';
|
|
11
11
|
import { AhoCorasick } from '../utils/ahoCorasick';
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
13
|
+
let defaultAhoCorasick: AhoCorasick | null = null;
|
|
14
|
+
|
|
15
|
+
function getDefaultAhoCorasick(): AhoCorasick {
|
|
16
|
+
if (!defaultAhoCorasick) {
|
|
17
|
+
const ac = new AhoCorasick();
|
|
18
|
+
for (const wordObj of wordObjects) {
|
|
19
|
+
ac.addPattern(wordObj.word);
|
|
20
|
+
if (wordObj.aliases) {
|
|
21
|
+
for (const alias of wordObj.aliases) {
|
|
22
|
+
ac.addPattern(alias);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
ac.build();
|
|
27
|
+
defaultAhoCorasick = ac;
|
|
21
28
|
}
|
|
22
|
-
|
|
23
|
-
globalAhoCorasick.build();
|
|
24
|
-
ahoCorasickInitialized = true;
|
|
29
|
+
return defaultAhoCorasick;
|
|
25
30
|
}
|
|
26
31
|
|
|
27
32
|
interface FindProfanityFunction {
|
|
@@ -54,13 +59,16 @@ export function findProfanity(text: string, options: FilterOptions = {}): string
|
|
|
54
59
|
maxLevenshteinDistance = 2,
|
|
55
60
|
} = { ...DEFAULT_OPTIONS, ...options };
|
|
56
61
|
|
|
62
|
+
const hasCustomWordList = Boolean(wordList && wordList.length > 0);
|
|
57
63
|
const normalizedWhitelist = whitelist.map((w) => w.toLowerCase());
|
|
58
|
-
|
|
59
64
|
const normalizedText = normalizeText(text);
|
|
60
65
|
|
|
61
|
-
let baseWordsToCheck: string[] =
|
|
66
|
+
let baseWordsToCheck: string[] = [];
|
|
67
|
+
const aliasMap = new Map<string, string>();
|
|
62
68
|
|
|
63
|
-
if (
|
|
69
|
+
if (hasCustomWordList) {
|
|
70
|
+
baseWordsToCheck = wordList;
|
|
71
|
+
} else {
|
|
64
72
|
const filteredWords = wordObjects.filter((word) => {
|
|
65
73
|
const matchCategory = categories ? categories.includes(word.category) : true;
|
|
66
74
|
const matchRegion = regions ? regions.includes(word.region) : true;
|
|
@@ -69,26 +77,15 @@ export function findProfanity(text: string, options: FilterOptions = {}): string
|
|
|
69
77
|
});
|
|
70
78
|
|
|
71
79
|
baseWordsToCheck = filteredWords.map((word) => word.word);
|
|
72
|
-
}
|
|
73
80
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
matchRegion &&
|
|
83
|
-
matchSeverity &&
|
|
84
|
-
wordObj.aliases &&
|
|
85
|
-
wordObj.aliases.length > 0
|
|
86
|
-
) {
|
|
87
|
-
wordObj.aliases.forEach((alias) => {
|
|
88
|
-
aliasMap.set(alias.toLowerCase(), wordObj.word.toLowerCase());
|
|
89
|
-
});
|
|
90
|
-
}
|
|
91
|
-
});
|
|
81
|
+
filteredWords.forEach((wordObj) => {
|
|
82
|
+
if (wordObj.aliases && wordObj.aliases.length > 0) {
|
|
83
|
+
wordObj.aliases.forEach((alias) => {
|
|
84
|
+
aliasMap.set(alias.toLowerCase(), wordObj.word.toLowerCase());
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
}
|
|
92
89
|
|
|
93
90
|
const wordsToCheck = [...baseWordsToCheck, ...Array.from(aliasMap.keys())].filter(
|
|
94
91
|
(word) => !normalizedWhitelist.includes(word.toLowerCase())
|
|
@@ -103,6 +100,7 @@ export function findProfanity(text: string, options: FilterOptions = {}): string
|
|
|
103
100
|
|
|
104
101
|
const passesFilters = (word: string): boolean => {
|
|
105
102
|
if (normalizedWhitelist.includes(word.toLowerCase())) return false;
|
|
103
|
+
if (hasCustomWordList) return true;
|
|
106
104
|
|
|
107
105
|
const metadata = getWordMetadata(word);
|
|
108
106
|
if (!metadata) return false;
|
|
@@ -114,10 +112,29 @@ export function findProfanity(text: string, options: FilterOptions = {}): string
|
|
|
114
112
|
return matchCategory && matchRegion && matchSeverity;
|
|
115
113
|
};
|
|
116
114
|
|
|
117
|
-
|
|
115
|
+
let ac: AhoCorasick;
|
|
116
|
+
if (hasCustomWordList) {
|
|
117
|
+
ac = new AhoCorasick();
|
|
118
|
+
for (const w of wordsToCheck) {
|
|
119
|
+
ac.addPattern(w);
|
|
120
|
+
}
|
|
121
|
+
ac.build();
|
|
122
|
+
} else {
|
|
123
|
+
ac = getDefaultAhoCorasick();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const occurrences = ac.searchWithPositions(normalizedText);
|
|
127
|
+
for (const occ of occurrences) {
|
|
128
|
+
const { pattern: match, start, end } = occ;
|
|
129
|
+
|
|
130
|
+
if (!checkSubstring) {
|
|
131
|
+
const isWordStart = start === 0 || !/[a-z0-9_]/i.test(normalizedText[start - 1]);
|
|
132
|
+
const isWordEnd = end === normalizedText.length || !/[a-z0-9_]/i.test(normalizedText[end]);
|
|
133
|
+
if (!isWordStart || !isWordEnd) {
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
118
137
|
|
|
119
|
-
const basicMatches = globalAhoCorasick.searchUnique(normalizedText);
|
|
120
|
-
for (const match of basicMatches) {
|
|
121
138
|
if (normalizedWhitelist.includes(match.toLowerCase())) continue;
|
|
122
139
|
|
|
123
140
|
const originalWord = aliasMap.get(match.toLowerCase()) || match.toLowerCase();
|
|
@@ -274,8 +291,8 @@ export function findProfanity(text: string, options: FilterOptions = {}): string
|
|
|
274
291
|
/**
|
|
275
292
|
* Mencari kata kotor lengkap dengan metadata
|
|
276
293
|
*
|
|
277
|
-
* @param text Teks yang akan
|
|
278
|
-
* @param options Opsi
|
|
294
|
+
* @param text Teks yang akan diperiksa
|
|
295
|
+
* @param options Opsi untuk pencarian kata kotor
|
|
279
296
|
* @return Array dari objek kata kotor yang ditemukan
|
|
280
297
|
*/
|
|
281
298
|
export function findProfanityWithMetadata(
|
|
@@ -295,15 +312,24 @@ export function findProfanityWithMetadata(
|
|
|
295
312
|
(obj.aliases && obj.aliases.some((alias) => alias.toLowerCase() === word.toLowerCase()))
|
|
296
313
|
);
|
|
297
314
|
|
|
298
|
-
|
|
315
|
+
if (wordObject) {
|
|
316
|
+
return wordObject;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return {
|
|
320
|
+
word,
|
|
321
|
+
category: 'profanity' as ProfanityCategory,
|
|
322
|
+
region: 'general' as Region,
|
|
323
|
+
severity: 0.5,
|
|
324
|
+
};
|
|
299
325
|
})
|
|
300
326
|
.filter((word): word is ProfanityWord => word !== undefined);
|
|
301
327
|
}
|
|
302
328
|
|
|
303
329
|
/**
|
|
304
|
-
* Mencari
|
|
330
|
+
* Mencari kategori kata kotor yang ada dalam teks
|
|
305
331
|
*
|
|
306
|
-
* @param matchDetails Hasil pencarian dari
|
|
332
|
+
* @param matchDetails Hasil pencarian dari findProfanityWithMetadata()
|
|
307
333
|
* @return Array kategori unik
|
|
308
334
|
*/
|
|
309
335
|
export function findCategories(matchDetails: ProfanityWord[]): ProfanityCategory[] {
|
|
@@ -319,7 +345,7 @@ export function findCategories(matchDetails: ProfanityWord[]): ProfanityCategory
|
|
|
319
345
|
/**
|
|
320
346
|
* Mencari region kata kotor yang ada dalam teks
|
|
321
347
|
*
|
|
322
|
-
* @param matchDetails Hasil pencarian dari
|
|
348
|
+
* @param matchDetails Hasil pencarian dari findProfanityWithMetadata()
|
|
323
349
|
* @return Array region unik
|
|
324
350
|
*/
|
|
325
351
|
export function findRegions(matchDetails: ProfanityWord[]): Region[] {
|
|
@@ -343,7 +369,7 @@ export function calculateSeverity(matchDetails: ProfanityWord[]): number {
|
|
|
343
369
|
return 0;
|
|
344
370
|
}
|
|
345
371
|
|
|
346
|
-
const countFactor = Math.min(matchDetails.length / 10, 1);
|
|
372
|
+
const countFactor = Math.min(matchDetails.length / 10, 1);
|
|
347
373
|
|
|
348
374
|
const categoryWeights: Record<ProfanityCategory, number> = {
|
|
349
375
|
sexual: 0.9,
|
package/src/index.ts
CHANGED
|
@@ -95,6 +95,14 @@ export class IDProfanityFilter {
|
|
|
95
95
|
};
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
/**
|
|
99
|
+
* Mengatur ulang opsi filter ke default
|
|
100
|
+
* @param options Opsi baru untuk override default
|
|
101
|
+
*/
|
|
102
|
+
resetOptions(options: FilterOptions = {}) {
|
|
103
|
+
this.options = { ...DEFAULT_OPTIONS, ...options };
|
|
104
|
+
}
|
|
105
|
+
|
|
98
106
|
/**
|
|
99
107
|
* Menggunakan preset yang telah ditentukan
|
|
100
108
|
* @param presetName Nama preset yang akan digunakan
|
package/src/types/index.ts
CHANGED
|
@@ -16,17 +16,7 @@ export type Region =
|
|
|
16
16
|
| 'minang'
|
|
17
17
|
| 'bali'
|
|
18
18
|
| 'madura'
|
|
19
|
-
| '
|
|
20
|
-
| 'aceh'
|
|
21
|
-
| 'ambon'
|
|
22
|
-
| 'papua'
|
|
23
|
-
| 'manado'
|
|
24
|
-
| 'banjar'
|
|
25
|
-
| 'palembang'
|
|
26
|
-
| 'lampung'
|
|
27
|
-
| 'ntt'
|
|
28
|
-
| 'mandailing'
|
|
29
|
-
| 'ntb';
|
|
19
|
+
| 'aceh';
|
|
30
20
|
|
|
31
21
|
export interface ProfanityWord {
|
|
32
22
|
word: string;
|
package/src/utils/ahoCorasick.ts
CHANGED
|
@@ -145,6 +145,43 @@ export class AhoCorasick {
|
|
|
145
145
|
return new Set(matches.keys());
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Mencari semua kemunculan pola beserta posisi indeks awal dan akhirnya
|
|
150
|
+
* @param text Teks yang akan dicari
|
|
151
|
+
* @returns Array objek berisi pattern, start, dan end
|
|
152
|
+
*/
|
|
153
|
+
searchWithPositions(text: string): Array<{ pattern: string; start: number; end: number }> {
|
|
154
|
+
if (!this.built) {
|
|
155
|
+
this.build();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const results: Array<{ pattern: string; start: number; end: number }> = [];
|
|
159
|
+
const normalizedText = text.toLowerCase();
|
|
160
|
+
let node = this.root;
|
|
161
|
+
|
|
162
|
+
for (let i = 0; i < normalizedText.length; i++) {
|
|
163
|
+
const char = normalizedText[i];
|
|
164
|
+
|
|
165
|
+
while (node !== this.root && !node.children.has(char)) {
|
|
166
|
+
node = node.fail!;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (node.children.has(char)) {
|
|
170
|
+
node = node.children.get(char)!;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
for (const match of node.output) {
|
|
174
|
+
results.push({
|
|
175
|
+
pattern: match,
|
|
176
|
+
start: i + 1 - match.length,
|
|
177
|
+
end: i + 1,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return results;
|
|
183
|
+
}
|
|
184
|
+
|
|
148
185
|
/**
|
|
149
186
|
* Mengecek apakah teks mengandung setidaknya satu pola
|
|
150
187
|
* @param text Teks yang akan dicari
|
package/src/utils/regexUtils.ts
CHANGED
|
@@ -164,8 +164,7 @@ export function addIndonesianVariations(pattern: string): string {
|
|
|
164
164
|
const variations = variationMap[lowerChar];
|
|
165
165
|
|
|
166
166
|
if (variations && variations.length > 1) {
|
|
167
|
-
|
|
168
|
-
result += `[${variations.join('')}]`;
|
|
167
|
+
result += `(?:${variations.join('|')})`;
|
|
169
168
|
} else {
|
|
170
169
|
result += char;
|
|
171
170
|
}
|