Spaces:
Sleeping
Sleeping
| ; | |
| const fs = require("fs"); | |
| const path = require("path"); | |
| const DATA_DIRECTORY = path.join(__dirname, "profanity-data"); | |
| const WORD_PATTERN = /[\p{L}\p{N}\p{M}]+/gu; | |
| const CONTINUOUS_SCRIPT_PATTERN = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Thai}\p{Script=Khmer}\p{Script=Myanmar}]/u; | |
| const REGEX_CHUNK_SIZE = 400; | |
| const SOURCE_LANGUAGE_COUNT = 75; | |
| const SOURCE_ENTRY_COUNT = 57191; | |
| const LANGUAGE_ALIASES = Object.freeze({ | |
| "zh-cn": "zh", | |
| "zh-hans": "zh", | |
| "zh-hant": "zh", | |
| "zh-hk": "zh", | |
| "zh-tw": "zh", | |
| km: "kh", | |
| nb: "no", | |
| nn: "no", | |
| tl: "fil", | |
| yi: "yid" | |
| }); | |
| const dictionaries = new Map(); | |
| let totalEntries = 0; | |
| function normalizeToken(value) { | |
| return String(value || "") | |
| .normalize("NFKD") | |
| .toLocaleLowerCase() | |
| .replace(/\p{M}+/gu, ""); | |
| } | |
| function tokenize(value) { | |
| const text = String(value || ""); | |
| const tokens = []; | |
| WORD_PATTERN.lastIndex = 0; | |
| let match; | |
| while ((match = WORD_PATTERN.exec(text)) !== null) { | |
| const normalized = normalizeToken(match[0]); | |
| if (!normalized) continue; | |
| tokens.push({ | |
| value: normalized, | |
| start: match.index, | |
| end: match.index + match[0].length | |
| }); | |
| } | |
| return tokens; | |
| } | |
| function escapeRegex(value) { | |
| return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); | |
| } | |
| function buildRegexChunks(terms) { | |
| const unique = [...new Set(terms)] | |
| .filter((term) => Array.from(term).length >= 2) | |
| .sort((left, right) => right.length - left.length); | |
| const chunks = []; | |
| for (let index = 0; index < unique.length; index += REGEX_CHUNK_SIZE) { | |
| const alternatives = unique | |
| .slice(index, index + REGEX_CHUNK_SIZE) | |
| .map(escapeRegex) | |
| .join("|"); | |
| if (alternatives) chunks.push(new RegExp(alternatives, "giu")); | |
| } | |
| return chunks; | |
| } | |
| function buildWildcardRegex(entry) { | |
| const normalized = normalizeToken(entry); | |
| if (!normalized.includes("*")) return null; | |
| const source = normalized | |
| .split("*") | |
| .map(escapeRegex) | |
| .join("[\\p{L}\\p{N}]*"); | |
| return source ? new RegExp(`^${source}$`, "u") : null; | |
| } | |
| function loadDictionary(filePath, language) { | |
| const lines = fs.readFileSync(filePath, "utf8") | |
| .split(/\r?\n/) | |
| .map((entry) => entry.trim()) | |
| .filter(Boolean); | |
| const termsByTokenCount = new Map(); | |
| const continuousTerms = []; | |
| const wildcardPatterns = []; | |
| for (const entry of lines) { | |
| if (entry.includes("*")) { | |
| const wildcard = buildWildcardRegex(entry); | |
| if (wildcard) wildcardPatterns.push(wildcard); | |
| continue; | |
| } | |
| const entryTokens = tokenize(entry).map((token) => token.value); | |
| if (entryTokens.length === 0) continue; | |
| const tokenCount = entryTokens.length; | |
| if (!termsByTokenCount.has(tokenCount)) { | |
| termsByTokenCount.set(tokenCount, new Set()); | |
| } | |
| termsByTokenCount.get(tokenCount).add(entryTokens.join(" ")); | |
| if (CONTINUOUS_SCRIPT_PATTERN.test(entry)) { | |
| continuousTerms.push(entry.normalize("NFC")); | |
| } | |
| } | |
| return { | |
| language, | |
| entries: lines.length, | |
| termsByTokenCount, | |
| tokenCounts: [...termsByTokenCount.keys()].sort((a, b) => a - b), | |
| wildcardPatterns, | |
| continuousPatterns: buildRegexChunks(continuousTerms) | |
| }; | |
| } | |
| function loadDictionaries() { | |
| try { | |
| const files = fs.readdirSync(DATA_DIRECTORY, { withFileTypes: true }) | |
| .filter((entry) => entry.isFile() && /^[a-z]{2,3}\.txt$/i.test(entry.name)); | |
| for (const file of files) { | |
| const language = path.basename(file.name, ".txt").toLowerCase(); | |
| // The source English list includes common words such as "class". The | |
| // server's curated English roots are safer for live game chat. | |
| if (language === "en") continue; | |
| const dictionary = loadDictionary(path.join(DATA_DIRECTORY, file.name), language); | |
| dictionaries.set(language, dictionary); | |
| totalEntries += dictionary.entries; | |
| } | |
| } catch (error) { | |
| console.error(`[moderation] Multilingual dictionaries could not be loaded: ${error.message}`); | |
| } | |
| } | |
| function cleanLanguage(value) { | |
| const raw = String(value || "") | |
| .trim() | |
| .toLowerCase() | |
| .replace(/_/g, "-"); | |
| if (!raw) return ""; | |
| if (LANGUAGE_ALIASES[raw]) return LANGUAGE_ALIASES[raw]; | |
| const base = raw.split("-")[0]; | |
| return LANGUAGE_ALIASES[base] || base; | |
| } | |
| function addIfSupported(target, language) { | |
| if (dictionaries.has(language) && !target.includes(language)) target.push(language); | |
| } | |
| function selectedLanguages(text, requestedLanguage) { | |
| const selected = []; | |
| addIfSupported(selected, cleanLanguage(requestedLanguage)); | |
| if (/\p{Script=Hiragana}|\p{Script=Katakana}/u.test(text)) addIfSupported(selected, "ja"); | |
| if (/\p{Script=Han}/u.test(text)) addIfSupported(selected, "zh"); | |
| if (/\p{Script=Hangul}/u.test(text)) addIfSupported(selected, "ko"); | |
| if (/\p{Script=Arabic}/u.test(text)) { | |
| addIfSupported(selected, "ar"); | |
| addIfSupported(selected, "fa"); | |
| } | |
| if (/\p{Script=Cyrillic}/u.test(text) && selected.length === 0) { | |
| addIfSupported(selected, "ru"); | |
| addIfSupported(selected, "uk"); | |
| } | |
| if (/\p{Script=Devanagari}/u.test(text)) { | |
| addIfSupported(selected, "hi"); | |
| addIfSupported(selected, "mr"); | |
| } | |
| if (/\p{Script=Tamil}/u.test(text)) addIfSupported(selected, "ta"); | |
| if (/\p{Script=Telugu}/u.test(text)) addIfSupported(selected, "te"); | |
| if (/\p{Script=Thai}/u.test(text)) addIfSupported(selected, "th"); | |
| if (/\p{Script=Armenian}/u.test(text)) addIfSupported(selected, "hy"); | |
| if (/\p{Script=Ethiopic}/u.test(text)) addIfSupported(selected, "am"); | |
| if (/\p{Script=Myanmar}/u.test(text)) addIfSupported(selected, "my"); | |
| if (/\p{Script=Khmer}/u.test(text)) addIfSupported(selected, "kh"); | |
| if (selected.length === 0) addIfSupported(selected, "en"); | |
| return selected; | |
| } | |
| function collectDictionaryRanges(text, tokens, dictionary, ranges) { | |
| for (const tokenCount of dictionary.tokenCounts) { | |
| if (tokenCount > tokens.length) break; | |
| const terms = dictionary.termsByTokenCount.get(tokenCount); | |
| for (let index = 0; index <= tokens.length - tokenCount; index++) { | |
| const phrase = tokens | |
| .slice(index, index + tokenCount) | |
| .map((token) => token.value) | |
| .join(" "); | |
| if (!terms.has(phrase)) continue; | |
| ranges.push({ | |
| start: tokens[index].start, | |
| end: tokens[index + tokenCount - 1].end | |
| }); | |
| } | |
| } | |
| if (dictionary.wildcardPatterns.length > 0) { | |
| for (const token of tokens) { | |
| if (dictionary.wildcardPatterns.some((pattern) => pattern.test(token.value))) { | |
| ranges.push({ start: token.start, end: token.end }); | |
| } | |
| } | |
| } | |
| for (const pattern of dictionary.continuousPatterns) { | |
| pattern.lastIndex = 0; | |
| let match; | |
| while ((match = pattern.exec(text)) !== null) { | |
| ranges.push({ start: match.index, end: match.index + match[0].length }); | |
| if (match[0].length === 0) pattern.lastIndex++; | |
| } | |
| } | |
| } | |
| function applyRanges(text, ranges) { | |
| if (ranges.length === 0) return text; | |
| const ordered = ranges | |
| .filter((range) => range.start >= 0 && range.end > range.start && range.end <= text.length) | |
| .sort((left, right) => left.start - right.start || right.end - left.end); | |
| const merged = []; | |
| for (const range of ordered) { | |
| const previous = merged[merged.length - 1]; | |
| if (previous && range.start <= previous.end) { | |
| previous.end = Math.max(previous.end, range.end); | |
| } else { | |
| merged.push({ ...range }); | |
| } | |
| } | |
| let output = ""; | |
| let cursor = 0; | |
| for (const range of merged) { | |
| output += text.slice(cursor, range.start); | |
| output += "*".repeat(Array.from(text.slice(range.start, range.end)).length); | |
| cursor = range.end; | |
| } | |
| return output + text.slice(cursor); | |
| } | |
| function filterMultilingualProfanity(text, requestedLanguage) { | |
| const source = String(text || ""); | |
| if (!source || dictionaries.size === 0) return source; | |
| const tokens = tokenize(source); | |
| const ranges = []; | |
| for (const language of selectedLanguages(source, requestedLanguage)) { | |
| collectDictionaryRanges(source, tokens, dictionaries.get(language), ranges); | |
| } | |
| return applyRanges(source, ranges); | |
| } | |
| function multilingualModerationStatus() { | |
| return { | |
| source: "Wiqaya 0.2.4", | |
| license: "MIT", | |
| languages: dictionaries.size + 1, | |
| dictionaryLanguages: dictionaries.size, | |
| dictionaryEntries: totalEntries, | |
| sourceLanguages: SOURCE_LANGUAGE_COUNT, | |
| sourceEntries: SOURCE_ENTRY_COUNT, | |
| curatedEnglishList: true, | |
| localFallbackLayers: 4 | |
| }; | |
| } | |
| loadDictionaries(); | |
| module.exports = { | |
| cleanModerationLanguage: cleanLanguage, | |
| filterMultilingualProfanity, | |
| multilingualModerationStatus | |
| }; | |