@sideid/id-profanity-filter 1.12.0 → 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/dist/index.js CHANGED
@@ -8,7 +8,7 @@ const general = [
8
8
  category: 'profanity',
9
9
  region: 'general',
10
10
  severity: 0.7,
11
- aliases: ['anjay', 'anjir', 'anying', 'njing', 'anj', 'anjg', 'ajg'],
11
+ aliases: ['anjay', 'anjir', 'anying', 'njing', 'anj', 'anjg', 'ajg', 'anjic', 'anjink'],
12
12
  description: 'Mengacu pada hewan anjing, digunakan sebagai umpatan',
13
13
  context: 'Umpatan umum untuk menunjukkan kemarahan atau ketidaksetujuan',
14
14
  },
@@ -116,7 +116,7 @@ const general = [
116
116
  category: 'insult',
117
117
  region: 'general',
118
118
  severity: 0.5,
119
- aliases: ['sialn', 'sl'],
119
+ aliases: ['sialn'],
120
120
  description: 'Kata yang mengacu pada orang yang membawa sial',
121
121
  context: 'Hinaan untuk menyebut orang yang dianggap membawa sial',
122
122
  },
@@ -179,7 +179,7 @@ const general = [
179
179
  category: 'sexual',
180
180
  region: 'general',
181
181
  severity: 0.9,
182
- aliases: ['ngew', 'we'],
182
+ aliases: ['ngew'],
183
183
  description: 'Istilah kasar untuk aktivitas seksual',
184
184
  context: 'Kata vulgar yang merujuk pada aktivitas seksual',
185
185
  },
@@ -1611,7 +1611,7 @@ const sexual = [
1611
1611
  category: 'sexual',
1612
1612
  region: 'general',
1613
1613
  severity: 0.9,
1614
- aliases: ['ngew', 'we'],
1614
+ aliases: ['ngew'],
1615
1615
  description: 'Istilah kasar untuk aktivitas seksual',
1616
1616
  context: 'Kata vulgar yang merujuk pada aktivitas seksual',
1617
1617
  },
@@ -2143,16 +2143,7 @@ const wordObjects = [
2143
2143
  ...minang,
2144
2144
  ...bali,
2145
2145
  ...madura,
2146
- // ...bugis,
2147
2146
  ...aceh,
2148
- // ...ambon,
2149
- // ...papua,
2150
- // ...manado,
2151
- // ...banjar,
2152
- // ...palembang,
2153
- // ...lampung,
2154
- // ...ntt,
2155
- // ...ntb,
2156
2147
  ];
2157
2148
  wordObjects.map((item) => item.word);
2158
2149
  /**
@@ -2468,8 +2459,7 @@ function addIndonesianVariations(pattern) {
2468
2459
  const lowerChar = char.toLowerCase();
2469
2460
  const variations = variationMap[lowerChar];
2470
2461
  if (variations && variations.length > 1) {
2471
- // Create a character class with all variations
2472
- result += `[${variations.join('')}]`;
2462
+ result += `(?:${variations.join('|')})`;
2473
2463
  }
2474
2464
  else {
2475
2465
  result += char;
@@ -3105,6 +3095,36 @@ class AhoCorasick {
3105
3095
  const matches = this.search(text);
3106
3096
  return new Set(matches.keys());
3107
3097
  }
3098
+ /**
3099
+ * Mencari semua kemunculan pola beserta posisi indeks awal dan akhirnya
3100
+ * @param text Teks yang akan dicari
3101
+ * @returns Array objek berisi pattern, start, dan end
3102
+ */
3103
+ searchWithPositions(text) {
3104
+ if (!this.built) {
3105
+ this.build();
3106
+ }
3107
+ const results = [];
3108
+ const normalizedText = text.toLowerCase();
3109
+ let node = this.root;
3110
+ for (let i = 0; i < normalizedText.length; i++) {
3111
+ const char = normalizedText[i];
3112
+ while (node !== this.root && !node.children.has(char)) {
3113
+ node = node.fail;
3114
+ }
3115
+ if (node.children.has(char)) {
3116
+ node = node.children.get(char);
3117
+ }
3118
+ for (const match of node.output) {
3119
+ results.push({
3120
+ pattern: match,
3121
+ start: i + 1 - match.length,
3122
+ end: i + 1,
3123
+ });
3124
+ }
3125
+ }
3126
+ return results;
3127
+ }
3108
3128
  /**
3109
3129
  * Mengecek apakah teks mengandung setidaknya satu pola
3110
3130
  * @param text Teks yang akan dicari
@@ -3132,16 +3152,22 @@ class AhoCorasick {
3132
3152
  }
3133
3153
  }
3134
3154
 
3135
- const globalAhoCorasick = new AhoCorasick();
3136
- let ahoCorasickInitialized = false;
3137
- function initializeAhoCorasick(words) {
3138
- if (ahoCorasickInitialized)
3139
- return;
3140
- for (const word of words) {
3141
- globalAhoCorasick.addPattern(word);
3155
+ let defaultAhoCorasick = null;
3156
+ function getDefaultAhoCorasick() {
3157
+ if (!defaultAhoCorasick) {
3158
+ const ac = new AhoCorasick();
3159
+ for (const wordObj of wordObjects) {
3160
+ ac.addPattern(wordObj.word);
3161
+ if (wordObj.aliases) {
3162
+ for (const alias of wordObj.aliases) {
3163
+ ac.addPattern(alias);
3164
+ }
3165
+ }
3166
+ }
3167
+ ac.build();
3168
+ defaultAhoCorasick = ac;
3142
3169
  }
3143
- globalAhoCorasick.build();
3144
- ahoCorasickInitialized = true;
3170
+ return defaultAhoCorasick;
3145
3171
  }
3146
3172
  function getWordMetadata(word) {
3147
3173
  return wordObjects.find((obj) => obj.word.toLowerCase() === word.toLowerCase() ||
@@ -3149,10 +3175,15 @@ function getWordMetadata(word) {
3149
3175
  }
3150
3176
  function findProfanity(text, options = {}) {
3151
3177
  const { wordList = [], detectLeetSpeak = true, checkSubstring = false, whitelist = [], categories, regions, severityThreshold = 0, indonesianVariation = false, detectSimilarity = false, similarityThreshold = 0.8, detectSplit = false, useLevenshtein = false, maxLevenshteinDistance = 2, } = { ...DEFAULT_OPTIONS, ...options };
3178
+ const hasCustomWordList = Boolean(wordList && wordList.length > 0);
3152
3179
  const normalizedWhitelist = whitelist.map((w) => w.toLowerCase());
3153
3180
  const normalizedText = normalizeText(text);
3154
- let baseWordsToCheck = wordList.length > 0 ? wordList : [];
3155
- if (baseWordsToCheck.length === 0) {
3181
+ let baseWordsToCheck = [];
3182
+ const aliasMap = new Map();
3183
+ if (hasCustomWordList) {
3184
+ baseWordsToCheck = wordList;
3185
+ }
3186
+ else {
3156
3187
  const filteredWords = wordObjects.filter((word) => {
3157
3188
  const matchCategory = categories ? categories.includes(word.category) : true;
3158
3189
  const matchRegion = regions ? regions.includes(word.region) : true;
@@ -3160,22 +3191,14 @@ function findProfanity(text, options = {}) {
3160
3191
  return matchCategory && matchRegion && matchSeverity;
3161
3192
  });
3162
3193
  baseWordsToCheck = filteredWords.map((word) => word.word);
3194
+ filteredWords.forEach((wordObj) => {
3195
+ if (wordObj.aliases && wordObj.aliases.length > 0) {
3196
+ wordObj.aliases.forEach((alias) => {
3197
+ aliasMap.set(alias.toLowerCase(), wordObj.word.toLowerCase());
3198
+ });
3199
+ }
3200
+ });
3163
3201
  }
3164
- const aliasMap = new Map();
3165
- wordObjects.forEach((wordObj) => {
3166
- const matchCategory = categories ? categories.includes(wordObj.category) : true;
3167
- const matchRegion = regions ? regions.includes(wordObj.region) : true;
3168
- const matchSeverity = wordObj.severity >= severityThreshold;
3169
- if (matchCategory &&
3170
- matchRegion &&
3171
- matchSeverity &&
3172
- wordObj.aliases &&
3173
- wordObj.aliases.length > 0) {
3174
- wordObj.aliases.forEach((alias) => {
3175
- aliasMap.set(alias.toLowerCase(), wordObj.word.toLowerCase());
3176
- });
3177
- }
3178
- });
3179
3202
  const wordsToCheck = [...baseWordsToCheck, ...Array.from(aliasMap.keys())].filter((word) => !normalizedWhitelist.includes(word.toLowerCase()));
3180
3203
  if (wordsToCheck.length === 0) {
3181
3204
  return [];
@@ -3185,6 +3208,8 @@ function findProfanity(text, options = {}) {
3185
3208
  const passesFilters = (word) => {
3186
3209
  if (normalizedWhitelist.includes(word.toLowerCase()))
3187
3210
  return false;
3211
+ if (hasCustomWordList)
3212
+ return true;
3188
3213
  const metadata = getWordMetadata(word);
3189
3214
  if (!metadata)
3190
3215
  return false;
@@ -3193,9 +3218,27 @@ function findProfanity(text, options = {}) {
3193
3218
  const matchSeverity = metadata.severity >= severityThreshold;
3194
3219
  return matchCategory && matchRegion && matchSeverity;
3195
3220
  };
3196
- initializeAhoCorasick(wordsToCheck);
3197
- const basicMatches = globalAhoCorasick.searchUnique(normalizedText);
3198
- for (const match of basicMatches) {
3221
+ let ac;
3222
+ if (hasCustomWordList) {
3223
+ ac = new AhoCorasick();
3224
+ for (const w of wordsToCheck) {
3225
+ ac.addPattern(w);
3226
+ }
3227
+ ac.build();
3228
+ }
3229
+ else {
3230
+ ac = getDefaultAhoCorasick();
3231
+ }
3232
+ const occurrences = ac.searchWithPositions(normalizedText);
3233
+ for (const occ of occurrences) {
3234
+ const { pattern: match, start, end } = occ;
3235
+ if (!checkSubstring) {
3236
+ const isWordStart = start === 0 || !/[a-z0-9_]/i.test(normalizedText[start - 1]);
3237
+ const isWordEnd = end === normalizedText.length || !/[a-z0-9_]/i.test(normalizedText[end]);
3238
+ if (!isWordStart || !isWordEnd) {
3239
+ continue;
3240
+ }
3241
+ }
3199
3242
  if (normalizedWhitelist.includes(match.toLowerCase()))
3200
3243
  continue;
3201
3244
  const originalWord = aliasMap.get(match.toLowerCase()) || match.toLowerCase();
@@ -3320,8 +3363,8 @@ function findProfanity(text, options = {}) {
3320
3363
  /**
3321
3364
  * Mencari kata kotor lengkap dengan metadata
3322
3365
  *
3323
- * @param text Teks yang akan diperika
3324
- * @param options Opsi utnuk pencarian kata kotor
3366
+ * @param text Teks yang akan diperiksa
3367
+ * @param options Opsi untuk pencarian kata kotor
3325
3368
  * @return Array dari objek kata kotor yang ditemukan
3326
3369
  */
3327
3370
  function findProfanityWithMetadata(text, options = {}) {
@@ -3333,14 +3376,22 @@ function findProfanityWithMetadata(text, options = {}) {
3333
3376
  .map((word) => {
3334
3377
  const wordObject = wordObjects.find((obj) => obj.word.toLowerCase() === word.toLowerCase() ||
3335
3378
  (obj.aliases && obj.aliases.some((alias) => alias.toLowerCase() === word.toLowerCase())));
3336
- return wordObject;
3379
+ if (wordObject) {
3380
+ return wordObject;
3381
+ }
3382
+ return {
3383
+ word,
3384
+ category: 'profanity',
3385
+ region: 'general',
3386
+ severity: 0.5,
3387
+ };
3337
3388
  })
3338
3389
  .filter((word) => word !== undefined);
3339
3390
  }
3340
3391
  /**
3341
- * Mencari kategory kata kotor yang ada dalam teks
3392
+ * Mencari kategori kata kotor yang ada dalam teks
3342
3393
  *
3343
- * @param matchDetails Hasil pencarian dari fingProfanityWithMetadata()
3394
+ * @param matchDetails Hasil pencarian dari findProfanityWithMetadata()
3344
3395
  * @return Array kategori unik
3345
3396
  */
3346
3397
  function findCategories(matchDetails) {
@@ -3353,7 +3404,7 @@ function findCategories(matchDetails) {
3353
3404
  /**
3354
3405
  * Mencari region kata kotor yang ada dalam teks
3355
3406
  *
3356
- * @param matchDetails Hasil pencarian dari fingProfanityWithMetadata()
3407
+ * @param matchDetails Hasil pencarian dari findProfanityWithMetadata()
3357
3408
  * @return Array region unik
3358
3409
  */
3359
3410
  function findRegions(matchDetails) {
@@ -3373,7 +3424,7 @@ function calculateSeverity(matchDetails) {
3373
3424
  if (matchDetails.length === 0) {
3374
3425
  return 0;
3375
3426
  }
3376
- const countFactor = Math.min(matchDetails.length / 10, 1); // Maksimal 10 kata
3427
+ const countFactor = Math.min(matchDetails.length / 10, 1);
3377
3428
  const categoryWeights = {
3378
3429
  sexual: 0.9,
3379
3430
  blasphemy: 0.9,
@@ -3401,21 +3452,9 @@ function calculateSeverity(matchDetails) {
3401
3452
  * @returns FilterResult dengan hasil filter
3402
3453
  */
3403
3454
  function filter(text, options = {}) {
3404
- const { replaceWith = '*', fullWordCensor = true, detectLeetSpeak = true, whitelist = [], checkSubstring = false, useRandomGrawlix = false, keepFirstAndLast = false, indonesianVariation = false, detectSplit = false, detectSimilarity = false, useLevenshtein = false, maxLevenshteinDistance = 2, similarityThreshold = 0.8, } = { ...DEFAULT_OPTIONS, ...options };
3405
- const matches = findProfanity(text, {
3406
- ...options,
3407
- detectLeetSpeak,
3408
- whitelist,
3409
- checkSubstring,
3410
- indonesianVariation,
3411
- detectSplit,
3412
- detectSimilarity,
3413
- useLevenshtein,
3414
- maxLevenshteinDistance,
3415
- similarityThreshold,
3416
- });
3417
- const actualMatches = findProfanity.lastActualMatches || new Map();
3418
- const matchDetails = findProfanityWithMetadata(text, options);
3455
+ const mergedOptions = { ...DEFAULT_OPTIONS, ...options };
3456
+ const { replaceWith = '*', fullWordCensor = true, detectLeetSpeak = true, whitelist = [], checkSubstring = false, useRandomGrawlix = false, keepFirstAndLast = false, detectSplit = false, } = mergedOptions;
3457
+ const matches = findProfanity(text, mergedOptions);
3419
3458
  if (matches.length === 0) {
3420
3459
  return {
3421
3460
  filtered: text,
@@ -3423,123 +3462,62 @@ function filter(text, options = {}) {
3423
3462
  replacements: [],
3424
3463
  };
3425
3464
  }
3465
+ const actualMatches = findProfanity.lastActualMatches || new Map();
3466
+ const matchDetails = findProfanityWithMetadata(text, mergedOptions);
3467
+ const normalizedWhitelist = whitelist.map((w) => w.toLowerCase());
3426
3468
  let filteredText = text;
3427
3469
  const replacements = [];
3428
- matches.forEach((word) => {
3470
+ const getCensoredWord = (originalWord) => {
3471
+ if (useRandomGrawlix) {
3472
+ return makeRandomGrawlixString(originalWord.length);
3473
+ }
3474
+ return censorWord(originalWord, replaceWith, !fullWordCensor && keepFirstAndLast);
3475
+ };
3476
+ const applyReplacement = (pattern, metadata) => {
3477
+ filteredText = filteredText.replace(pattern, (matchedStr) => {
3478
+ if (normalizedWhitelist.includes(matchedStr.toLowerCase())) {
3479
+ return matchedStr;
3480
+ }
3481
+ const censored = getCensoredWord(matchedStr);
3482
+ replacements.push({
3483
+ original: matchedStr,
3484
+ censored,
3485
+ metadata,
3486
+ });
3487
+ return censored;
3488
+ });
3489
+ };
3490
+ for (const word of matches) {
3429
3491
  const metadata = matchDetails.find((m) => m.word.toLowerCase() === word.toLowerCase() ||
3430
3492
  (m.aliases && m.aliases.some((alias) => alias.toLowerCase() === word.toLowerCase())));
3431
3493
  const variants = actualMatches.get(word.toLowerCase()) || [];
3432
- variants.push(word);
3433
- const uniqueVariants = [...new Set(variants)];
3434
- uniqueVariants.forEach((variant) => {
3435
- const regex = new RegExp(`\\b${escapeRegExp(variant)}\\b`, 'gi');
3436
- let match;
3437
- while ((match = regex.exec(filteredText)) !== null) {
3438
- const originalWord = match[0];
3439
- if (whitelist.includes(originalWord.toLowerCase()))
3440
- continue;
3441
- let censoredWord;
3442
- if (useRandomGrawlix) {
3443
- censoredWord = makeRandomGrawlixString(originalWord.length);
3444
- }
3445
- else {
3446
- censoredWord = censorWord(originalWord, replaceWith, !fullWordCensor && keepFirstAndLast);
3447
- }
3448
- replacements.push({
3449
- original: originalWord,
3450
- censored: censoredWord,
3451
- metadata,
3452
- });
3453
- filteredText = filteredText.replace(new RegExp(`\\b${escapeRegExp(originalWord)}\\b`, 'g'), censoredWord);
3454
- }
3455
- });
3456
- if (detectSplit || detectLeetSpeak) {
3457
- if (detectLeetSpeak) {
3458
- const leetRegex = createWordRegex(word, {
3459
- wholeWord: true,
3460
- caseSensitive: false,
3461
- leetSpeak: true,
3462
- detectSplit: false,
3463
- indonesianVariation: false,
3464
- });
3465
- let match;
3466
- while ((match = leetRegex.exec(filteredText)) !== null) {
3467
- const originalWord = match[0];
3468
- if (whitelist.includes(originalWord.toLowerCase()))
3469
- continue;
3470
- let censoredWord;
3471
- if (useRandomGrawlix) {
3472
- censoredWord = makeRandomGrawlixString(originalWord.length);
3473
- }
3474
- else {
3475
- censoredWord = censorWord(originalWord, replaceWith, !fullWordCensor && keepFirstAndLast);
3476
- }
3477
- replacements.push({
3478
- original: originalWord,
3479
- censored: censoredWord,
3480
- metadata,
3481
- });
3482
- filteredText = filteredText.replace(new RegExp(escapeRegExp(originalWord), 'g'), censoredWord);
3483
- }
3484
- }
3485
- if (detectSplit) {
3486
- const splitRegex = createWordRegex(word, {
3487
- wholeWord: false,
3488
- caseSensitive: false,
3489
- leetSpeak: false,
3490
- detectSplit: true,
3491
- indonesianVariation: false,
3492
- });
3493
- let match;
3494
- while ((match = splitRegex.exec(filteredText)) !== null) {
3495
- const originalWord = match[0];
3496
- if (whitelist.includes(originalWord.toLowerCase()))
3497
- continue;
3498
- let censoredWord;
3499
- if (useRandomGrawlix) {
3500
- censoredWord = makeRandomGrawlixString(originalWord.length);
3501
- }
3502
- else {
3503
- censoredWord = censorWord(originalWord, replaceWith, !fullWordCensor && keepFirstAndLast);
3504
- }
3505
- replacements.push({
3506
- original: originalWord,
3507
- censored: censoredWord,
3508
- metadata,
3509
- });
3510
- filteredText = filteredText.replace(new RegExp(escapeRegExp(originalWord), 'g'), censoredWord);
3511
- }
3512
- }
3494
+ const allVariants = [...new Set([...variants, word])].sort((a, b) => b.length - a.length);
3495
+ for (const variant of allVariants) {
3496
+ const boundaryPattern = checkSubstring
3497
+ ? escapeRegExp(variant)
3498
+ : `\\b${escapeRegExp(variant)}\\b`;
3499
+ applyReplacement(new RegExp(boundaryPattern, 'gi'), metadata);
3513
3500
  }
3514
- });
3515
- if (detectSimilarity && useLevenshtein) {
3516
- matches.forEach((word) => {
3517
- const metadata = matchDetails.find((m) => m.word.toLowerCase() === word.toLowerCase() ||
3518
- (m.aliases && m.aliases.some((alias) => alias.toLowerCase() === word.toLowerCase())));
3519
- const variants = actualMatches.get(word.toLowerCase()) || [];
3520
- variants.forEach((variant) => {
3521
- const exactVariantRegex = new RegExp(`\\b${escapeRegExp(variant)}\\b`, 'gi');
3522
- let match;
3523
- while ((match = exactVariantRegex.exec(filteredText)) !== null) {
3524
- const originalWord = match[0];
3525
- if (whitelist.includes(originalWord.toLowerCase()))
3526
- continue;
3527
- let censoredWord;
3528
- if (useRandomGrawlix) {
3529
- censoredWord = makeRandomGrawlixString(originalWord.length);
3530
- }
3531
- else {
3532
- censoredWord = censorWord(originalWord, replaceWith, !fullWordCensor && keepFirstAndLast);
3533
- }
3534
- replacements.push({
3535
- original: originalWord,
3536
- censored: censoredWord,
3537
- metadata,
3538
- });
3539
- filteredText = filteredText.replace(new RegExp(`\\b${escapeRegExp(originalWord)}\\b`, 'g'), censoredWord);
3540
- }
3501
+ if (detectLeetSpeak) {
3502
+ const leetRegex = createWordRegex(word, {
3503
+ wholeWord: !checkSubstring,
3504
+ caseSensitive: false,
3505
+ leetSpeak: true,
3506
+ detectSplit: false,
3507
+ indonesianVariation: false,
3541
3508
  });
3542
- });
3509
+ applyReplacement(leetRegex, metadata);
3510
+ }
3511
+ if (detectSplit) {
3512
+ const splitRegex = createWordRegex(word, {
3513
+ wholeWord: false,
3514
+ caseSensitive: false,
3515
+ leetSpeak: false,
3516
+ detectSplit: true,
3517
+ indonesianVariation: false,
3518
+ });
3519
+ applyReplacement(splitRegex, metadata);
3520
+ }
3543
3521
  }
3544
3522
  return {
3545
3523
  filtered: filteredText,
@@ -3781,6 +3759,13 @@ class IDProfanityFilter {
3781
3759
  ...options,
3782
3760
  };
3783
3761
  }
3762
+ /**
3763
+ * Mengatur ulang opsi filter ke default
3764
+ * @param options Opsi baru untuk override default
3765
+ */
3766
+ resetOptions(options = {}) {
3767
+ this.options = { ...DEFAULT_OPTIONS, ...options };
3768
+ }
3784
3769
  /**
3785
3770
  * Menggunakan preset yang telah ditentukan
3786
3771
  * @param presetName Nama preset yang akan digunakan