mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Improve and strenghten the profanity filter
This commit is contained in:
+526
-50
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
|
import unicodedata
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from threading import RLock
|
from threading import RLock
|
||||||
from typing import Iterable, List, Set, Tuple
|
from typing import Iterable, List, Set, Tuple
|
||||||
@@ -13,8 +14,8 @@ BLOCKLIST_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|||||||
|
|
||||||
_CUSTOM_RU_TERMS: Set[str] = {
|
_CUSTOM_RU_TERMS: Set[str] = {
|
||||||
"бляд", "блять", "бля", "сука", "суки", "сучка", "мразь", "ебан",
|
"бляд", "блять", "бля", "сука", "суки", "сучка", "мразь", "ебан",
|
||||||
"ебать", "ебёт", "ебет", "уёбок", "уебок", "уебище", "пизда",
|
"ебать", "ебёт", "ебет", "ебаная", "ебаная", "уёбок", "уебок", "уебище", "пизда",
|
||||||
"пиздец", "пизд", "хуй", "хуя", "хуе", "хуё", "хер", "гондон",
|
"пиздец", "пизд", "хуй", "хуя", "хуе", "хуё", "хуйня", "хер", "гондон",
|
||||||
"долбоёб", "долбоеб", "дебил", "член", "проститутка", "проститутки",
|
"долбоёб", "долбоеб", "дебил", "член", "проститутка", "проститутки",
|
||||||
"урод", "хуесос", "хуесосы", "хуесосов", "хуесоса", "пидор",
|
"урод", "хуесос", "хуесосы", "хуесосов", "хуесоса", "пидор",
|
||||||
"пидоры", "пидорас", "пидорасы", "пидорасов",
|
"пидоры", "пидорас", "пидорасы", "пидорасов",
|
||||||
@@ -28,6 +29,12 @@ _ADULT_TERMS: Set[str] = {
|
|||||||
|
|
||||||
_STATIC_TERMS: Set[str] = set(term.lower() for term in (_CUSTOM_RU_TERMS | _ADULT_TERMS))
|
_STATIC_TERMS: Set[str] = set(term.lower() for term in (_CUSTOM_RU_TERMS | _ADULT_TERMS))
|
||||||
|
|
||||||
|
# Words that should never be censored (whitelist)
|
||||||
|
_WHITELIST: Set[str] = {
|
||||||
|
"говно", # Allow this word
|
||||||
|
}
|
||||||
|
|
||||||
|
# Phrase patterns - these will be applied to normalized text (without special chars)
|
||||||
_PHRASE_PATTERNS: Tuple[re.Pattern[str], ...] = (
|
_PHRASE_PATTERNS: Tuple[re.Pattern[str], ...] = (
|
||||||
re.compile(r"\bmax\s+is\s+better\b", re.IGNORECASE | re.UNICODE),
|
re.compile(r"\bmax\s+is\s+better\b", re.IGNORECASE | re.UNICODE),
|
||||||
re.compile(r"\bмакс\s+лучше\b", re.IGNORECASE | re.UNICODE),
|
re.compile(r"\bмакс\s+лучше\b", re.IGNORECASE | re.UNICODE),
|
||||||
@@ -39,42 +46,142 @@ _PHRASE_PATTERNS: Tuple[re.Pattern[str], ...] = (
|
|||||||
re.compile(r"\bсамсунг\s+г[ао]вно\b", re.IGNORECASE | re.UNICODE),
|
re.compile(r"\bсамсунг\s+г[ао]вно\b", re.IGNORECASE | re.UNICODE),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Map for normalizing homoglyphs (similar-looking characters)
|
||||||
|
# Maps English/Latin characters to their Cyrillic equivalents and vice versa
|
||||||
|
# Also includes Greek, full-width, and other Unicode variants
|
||||||
_LEET_MAP = {
|
_LEET_MAP = {
|
||||||
|
# Numbers to letters
|
||||||
"0": "о",
|
"0": "о",
|
||||||
"o": "о",
|
|
||||||
"о": "о",
|
|
||||||
"a": "а",
|
|
||||||
"@": "а",
|
|
||||||
"4": "а",
|
|
||||||
"а": "а",
|
|
||||||
"e": "е",
|
|
||||||
"ё": "е",
|
|
||||||
"3": "е",
|
|
||||||
"c": "с",
|
|
||||||
"s": "с",
|
|
||||||
"с": "с",
|
|
||||||
"x": "х",
|
|
||||||
"х": "х",
|
|
||||||
"t": "т",
|
|
||||||
"т": "т",
|
|
||||||
"p": "п",
|
|
||||||
"п": "п",
|
|
||||||
"n": "н",
|
|
||||||
"н": "н",
|
|
||||||
"m": "м",
|
|
||||||
"м": "м",
|
|
||||||
"y": "у",
|
|
||||||
"u": "у",
|
|
||||||
"у": "у",
|
|
||||||
"g": "г",
|
|
||||||
"г": "г",
|
|
||||||
"v": "в",
|
|
||||||
"в": "в",
|
|
||||||
"f": "ф",
|
|
||||||
"ф": "ф",
|
|
||||||
"i": "и",
|
|
||||||
"1": "и",
|
"1": "и",
|
||||||
|
"3": "е",
|
||||||
|
"4": "а",
|
||||||
|
# Latin to Cyrillic (lowercase)
|
||||||
|
"a": "а",
|
||||||
|
"c": "с",
|
||||||
|
"e": "е",
|
||||||
|
"f": "ф",
|
||||||
|
"g": "г",
|
||||||
|
"i": "и",
|
||||||
|
"m": "м",
|
||||||
|
"n": "н",
|
||||||
|
"o": "о",
|
||||||
|
"p": "п",
|
||||||
|
"s": "с",
|
||||||
|
"t": "т",
|
||||||
|
"u": "у",
|
||||||
|
"v": "в",
|
||||||
|
"x": "х",
|
||||||
|
"y": "у",
|
||||||
|
"z": "з", # English 'z' to Cyrillic 'з'
|
||||||
|
# Latin to Cyrillic (uppercase)
|
||||||
|
"A": "а",
|
||||||
|
"C": "с",
|
||||||
|
"E": "е",
|
||||||
|
"F": "ф",
|
||||||
|
"G": "г",
|
||||||
|
"I": "и",
|
||||||
|
"M": "м",
|
||||||
|
"N": "н",
|
||||||
|
"O": "о",
|
||||||
|
"P": "п",
|
||||||
|
"S": "с",
|
||||||
|
"T": "т",
|
||||||
|
"U": "у",
|
||||||
|
"V": "в",
|
||||||
|
"X": "х",
|
||||||
|
"Y": "у",
|
||||||
|
"Z": "з", # English 'Z' to Cyrillic 'з'
|
||||||
|
# Greek letters that look like Cyrillic/Latin
|
||||||
|
"α": "а", # Greek alpha
|
||||||
|
"Α": "а",
|
||||||
|
"ο": "о", # Greek omicron
|
||||||
|
"Ο": "о",
|
||||||
|
"ρ": "р", # Greek rho (looks like Cyrillic р)
|
||||||
|
"Ρ": "р",
|
||||||
|
"υ": "у", # Greek upsilon
|
||||||
|
"Υ": "у",
|
||||||
|
"χ": "х", # Greek chi
|
||||||
|
"Χ": "х",
|
||||||
|
"ε": "е", # Greek epsilon
|
||||||
|
"Ε": "е",
|
||||||
|
"ι": "и", # Greek iota
|
||||||
|
"Ι": "и",
|
||||||
|
"ν": "н", # Greek nu
|
||||||
|
"Ν": "н",
|
||||||
|
"μ": "м", # Greek mu
|
||||||
|
"Μ": "м",
|
||||||
|
"π": "п", # Greek pi
|
||||||
|
"Π": "п",
|
||||||
|
"τ": "т", # Greek tau
|
||||||
|
"Τ": "т",
|
||||||
|
"γ": "г", # Greek gamma
|
||||||
|
"Γ": "г",
|
||||||
|
"σ": "с", # Greek sigma
|
||||||
|
"Σ": "с",
|
||||||
|
"φ": "ф", # Greek phi
|
||||||
|
"Φ": "ф",
|
||||||
|
# Full-width Latin characters
|
||||||
|
"a": "а",
|
||||||
|
"A": "а",
|
||||||
|
"c": "с",
|
||||||
|
"C": "с",
|
||||||
|
"e": "е",
|
||||||
|
"E": "е",
|
||||||
|
"f": "ф",
|
||||||
|
"F": "ф",
|
||||||
|
"g": "г",
|
||||||
|
"G": "г",
|
||||||
|
"i": "и",
|
||||||
|
"I": "и",
|
||||||
|
"m": "м",
|
||||||
|
"M": "м",
|
||||||
|
"n": "н",
|
||||||
|
"N": "н",
|
||||||
|
"o": "о",
|
||||||
|
"O": "о",
|
||||||
|
"p": "п",
|
||||||
|
"P": "п",
|
||||||
|
"s": "с",
|
||||||
|
"S": "с",
|
||||||
|
"t": "т",
|
||||||
|
"T": "т",
|
||||||
|
"u": "у",
|
||||||
|
"U": "у",
|
||||||
|
"v": "в",
|
||||||
|
"V": "в",
|
||||||
|
"x": "х",
|
||||||
|
"X": "х",
|
||||||
|
"y": "у",
|
||||||
|
"Y": "у",
|
||||||
|
"z": "з", # Full-width 'z' to Cyrillic 'з'
|
||||||
|
"Z": "з",
|
||||||
|
# Cyrillic to canonical Cyrillic (identity mappings)
|
||||||
|
"а": "а",
|
||||||
|
"с": "с",
|
||||||
|
"е": "е",
|
||||||
|
"ё": "е",
|
||||||
|
"ф": "ф",
|
||||||
|
"г": "г",
|
||||||
"и": "и",
|
"и": "и",
|
||||||
|
"м": "м",
|
||||||
|
"н": "н",
|
||||||
|
"о": "о",
|
||||||
|
"п": "п",
|
||||||
|
"т": "т",
|
||||||
|
"у": "у",
|
||||||
|
"ү": "у", # Cyrillic capital U (U+04AE)
|
||||||
|
"Ү": "у", # Cyrillic capital U (U+04AE)
|
||||||
|
"в": "в",
|
||||||
|
"х": "х",
|
||||||
|
"р": "р",
|
||||||
|
"з": "з", # Cyrillic 'з'
|
||||||
|
"д": "д", # Cyrillic 'д'
|
||||||
|
"б": "б", # Cyrillic 'б'
|
||||||
|
"л": "л", # Cyrillic 'л'
|
||||||
|
"я": "я", # Cyrillic 'я'
|
||||||
|
"н": "н", # Already mapped, but explicit
|
||||||
|
# Special characters
|
||||||
|
"@": "а",
|
||||||
}
|
}
|
||||||
|
|
||||||
_RAW_PHRASE_GROUPS: Tuple[Tuple[str, Tuple[str, ...]], ...] = (
|
_RAW_PHRASE_GROUPS: Tuple[Tuple[str, Tuple[str, ...]], ...] = (
|
||||||
@@ -87,14 +194,240 @@ _PHRASE_CACHE: dict[str, Tuple[Tuple[str, ...], ...]] = {}
|
|||||||
|
|
||||||
|
|
||||||
def _normalize_char(ch: str) -> str:
|
def _normalize_char(ch: str) -> str:
|
||||||
|
"""Normalize a single character, mapping homoglyphs to canonical form."""
|
||||||
|
# First try direct mapping (preserves case for non-mapped chars)
|
||||||
|
if ch in _LEET_MAP:
|
||||||
|
return _LEET_MAP[ch]
|
||||||
|
# Then try lowercase mapping
|
||||||
lower = ch.lower()
|
lower = ch.lower()
|
||||||
return _LEET_MAP.get(lower, lower)
|
if lower in _LEET_MAP:
|
||||||
|
return _LEET_MAP[lower]
|
||||||
|
# If no mapping and character is ASCII letter, return lowercase
|
||||||
|
# This preserves English words like "fromchat" as-is
|
||||||
|
if ch.isascii() and ch.isalpha():
|
||||||
|
return lower
|
||||||
|
# For other characters, return lowercase for consistency
|
||||||
|
return lower
|
||||||
|
|
||||||
|
|
||||||
def _normalize_token(token: str) -> str:
|
def _normalize_token(token: str) -> str:
|
||||||
|
"""Normalize a token by mapping all homoglyphs."""
|
||||||
return "".join(_normalize_char(ch) for ch in token)
|
return "".join(_normalize_char(ch) for ch in token)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_text_for_profanity(text: str) -> str:
|
||||||
|
"""
|
||||||
|
Normalize entire text by mapping homoglyphs to canonical forms.
|
||||||
|
This prevents bypasses like using English 'u' instead of Russian 'у'.
|
||||||
|
"""
|
||||||
|
return "".join(_normalize_char(ch) for ch in text)
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_zero_width_chars(text: str) -> str:
|
||||||
|
"""
|
||||||
|
Remove zero-width characters that could be used to bypass filters.
|
||||||
|
"""
|
||||||
|
# Zero-width space, zero-width non-joiner, zero-width joiner, etc.
|
||||||
|
zero_width_chars = [
|
||||||
|
'\u200B', # Zero-width space
|
||||||
|
'\u200C', # Zero-width non-joiner
|
||||||
|
'\u200D', # Zero-width joiner
|
||||||
|
'\uFEFF', # Zero-width no-break space
|
||||||
|
'\u2060', # Word joiner
|
||||||
|
'\u2061', # Function application
|
||||||
|
'\u2062', # Invisible times
|
||||||
|
'\u2063', # Invisible separator
|
||||||
|
'\u2064', # Invisible plus
|
||||||
|
]
|
||||||
|
result = text
|
||||||
|
for zw_char in zero_width_chars:
|
||||||
|
result = result.replace(zw_char, '')
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_alphanumeric_with_mapping(text: str, preserve_spaces: bool = False) -> tuple[str, list[int]]:
|
||||||
|
"""
|
||||||
|
Extract only alphanumeric characters from text and create a mapping
|
||||||
|
from normalized positions to original positions.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
preserve_spaces: If True, preserve spaces in the normalized text (for phrase matching)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(normalized_text, position_map) where position_map[i] is the original
|
||||||
|
position of the i-th character in normalized_text
|
||||||
|
"""
|
||||||
|
# First normalize Unicode (composed vs decomposed)
|
||||||
|
normalized_unicode = unicodedata.normalize('NFKC', text)
|
||||||
|
|
||||||
|
# For phrase matching, convert zero-width chars to spaces instead of stripping
|
||||||
|
if preserve_spaces:
|
||||||
|
zero_width_chars = ['\u200B', '\u200C', '\u200D', '\uFEFF', '\u2060', '\u2061', '\u2062', '\u2063', '\u2064']
|
||||||
|
for zw_char in zero_width_chars:
|
||||||
|
normalized_unicode = normalized_unicode.replace(zw_char, ' ')
|
||||||
|
else:
|
||||||
|
# Strip zero-width characters
|
||||||
|
normalized_unicode = _strip_zero_width_chars(normalized_unicode)
|
||||||
|
|
||||||
|
normalized = []
|
||||||
|
position_map = []
|
||||||
|
|
||||||
|
for i, ch in enumerate(normalized_unicode):
|
||||||
|
# Check if character is alphanumeric (including Cyrillic)
|
||||||
|
if ch.isalnum():
|
||||||
|
# For phrase matching, preserve ASCII letters as-is (just lowercase)
|
||||||
|
# to allow English words in patterns to match
|
||||||
|
if preserve_spaces and ch.isascii() and ch.isalpha():
|
||||||
|
normalized.append(ch.lower())
|
||||||
|
else:
|
||||||
|
# Normalize this character (homoglyphs, Cyrillic, etc.)
|
||||||
|
normalized.append(_normalize_char(ch))
|
||||||
|
position_map.append(i)
|
||||||
|
elif preserve_spaces:
|
||||||
|
# For phrase matching, treat any whitespace or non-alphanumeric as word separator
|
||||||
|
if ch.isspace() or not ch.isalnum():
|
||||||
|
# Normalize to single space to allow patterns to match
|
||||||
|
if normalized and normalized[-1] != ' ': # Don't add consecutive spaces
|
||||||
|
normalized.append(' ')
|
||||||
|
position_map.append(i)
|
||||||
|
|
||||||
|
return "".join(normalized), position_map
|
||||||
|
|
||||||
|
|
||||||
|
def _check_profanity_substrings(normalized_text: str, profane_words: Set[str]) -> list[tuple[int, int]]:
|
||||||
|
"""
|
||||||
|
Check for profane words as substrings or subsequences in normalized text.
|
||||||
|
This catches cases like "хуй" in "хууй" (with extra characters).
|
||||||
|
Returns list of (start, end) positions where profanity is found.
|
||||||
|
"""
|
||||||
|
spans = []
|
||||||
|
normalized_lower = normalized_text.lower()
|
||||||
|
|
||||||
|
for word in profane_words:
|
||||||
|
word_lower = word.lower()
|
||||||
|
|
||||||
|
# First try exact substring match
|
||||||
|
start = 0
|
||||||
|
while True:
|
||||||
|
pos = normalized_lower.find(word_lower, start)
|
||||||
|
if pos == -1:
|
||||||
|
break
|
||||||
|
spans.append((pos, pos + len(word_lower)))
|
||||||
|
start = pos + 1
|
||||||
|
|
||||||
|
# Also check if profane word appears as a subsequence (allowing extra chars)
|
||||||
|
# This catches cases like "хуй" in "хууй" or "хU★уй" -> "хууй"
|
||||||
|
word_chars = list(word_lower)
|
||||||
|
text_chars = list(normalized_lower)
|
||||||
|
|
||||||
|
# Try to find the word as a subsequence
|
||||||
|
i = 0 # position in text
|
||||||
|
j = 0 # position in word
|
||||||
|
seq_start = None
|
||||||
|
|
||||||
|
while i < len(text_chars) and j < len(word_chars):
|
||||||
|
if text_chars[i] == word_chars[j]:
|
||||||
|
if seq_start is None:
|
||||||
|
seq_start = i
|
||||||
|
j += 1
|
||||||
|
if j == len(word_chars):
|
||||||
|
# Found the word as subsequence
|
||||||
|
seq_end = i + 1
|
||||||
|
# Only add if it's not already covered by exact match
|
||||||
|
if (seq_start, seq_end) not in spans:
|
||||||
|
spans.append((seq_start, seq_end))
|
||||||
|
# Reset to find next occurrence
|
||||||
|
seq_start = None
|
||||||
|
j = 0
|
||||||
|
# Continue from after the start position
|
||||||
|
i = seq_start + 1 if seq_start is not None else i + 1
|
||||||
|
continue
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
return spans
|
||||||
|
|
||||||
|
|
||||||
|
def _find_profanity_spans_in_original(
|
||||||
|
normalized_text: str,
|
||||||
|
position_map: list[int],
|
||||||
|
original_length: int,
|
||||||
|
original_text: str
|
||||||
|
) -> list[tuple[int, int]]:
|
||||||
|
"""
|
||||||
|
Find profanity in normalized text and map the spans back to original text positions.
|
||||||
|
Uses both better_profanity library and substring matching for better detection.
|
||||||
|
|
||||||
|
Returns list of (start, end) tuples in original text coordinates.
|
||||||
|
"""
|
||||||
|
spans = []
|
||||||
|
|
||||||
|
if not normalized_text or not position_map:
|
||||||
|
return spans
|
||||||
|
|
||||||
|
# Check normalized text for profanity using better_profanity
|
||||||
|
censored = _profanity.censor(normalized_text, censor_char="\\*")
|
||||||
|
|
||||||
|
# Also check for profane words as substrings (to catch cases like "хуй" in "хууй" or "хуйня")
|
||||||
|
profane_words = _STATIC_TERMS
|
||||||
|
substring_spans = _check_profanity_substrings(normalized_text, profane_words)
|
||||||
|
|
||||||
|
# Combine spans from both methods
|
||||||
|
all_spans = set()
|
||||||
|
|
||||||
|
# From better_profanity censoring
|
||||||
|
i = 0
|
||||||
|
while i < len(censored):
|
||||||
|
if censored[i] == "*":
|
||||||
|
span_start = i
|
||||||
|
while i < len(censored) and censored[i] == "*":
|
||||||
|
i += 1
|
||||||
|
span_end = i
|
||||||
|
all_spans.add((span_start, span_end))
|
||||||
|
else:
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
# From substring matching
|
||||||
|
for start, end in substring_spans:
|
||||||
|
all_spans.add((start, end))
|
||||||
|
|
||||||
|
# Map all spans to original positions
|
||||||
|
for span_start, span_end in all_spans:
|
||||||
|
if span_start < len(position_map):
|
||||||
|
orig_start = position_map[span_start]
|
||||||
|
# Find the end position - use the last mapped position in the span
|
||||||
|
if span_end > 0 and span_end <= len(position_map):
|
||||||
|
orig_end = position_map[span_end - 1] + 1
|
||||||
|
elif span_end > len(position_map):
|
||||||
|
orig_end = original_length
|
||||||
|
else:
|
||||||
|
orig_end = orig_start + 1
|
||||||
|
|
||||||
|
# Extend span to include any non-alphanumeric characters between
|
||||||
|
# the mapped positions in the original text
|
||||||
|
# Limit extension to prevent over-censoring (max 50 chars each direction)
|
||||||
|
max_extension = 50
|
||||||
|
extension_count = 0
|
||||||
|
|
||||||
|
# Extend backwards to include any preceding non-alphanumeric
|
||||||
|
while (orig_start > 0 and
|
||||||
|
not original_text[orig_start - 1].isalnum() and
|
||||||
|
extension_count < max_extension):
|
||||||
|
orig_start -= 1
|
||||||
|
extension_count += 1
|
||||||
|
|
||||||
|
extension_count = 0
|
||||||
|
# Extend forwards to include any following non-alphanumeric
|
||||||
|
while (orig_end < original_length and
|
||||||
|
not original_text[orig_end].isalnum() and
|
||||||
|
extension_count < max_extension):
|
||||||
|
orig_end += 1
|
||||||
|
extension_count += 1
|
||||||
|
|
||||||
|
spans.append((orig_start, min(orig_end, original_length)))
|
||||||
|
|
||||||
|
return spans
|
||||||
|
|
||||||
|
|
||||||
def _tokenize_with_spans(text: str) -> List[Tuple[int, int, str]]:
|
def _tokenize_with_spans(text: str) -> List[Tuple[int, int, str]]:
|
||||||
tokens: List[Tuple[int, int, str]] = []
|
tokens: List[Tuple[int, int, str]] = []
|
||||||
start: int | None = None
|
start: int | None = None
|
||||||
@@ -241,8 +574,17 @@ def _rebuild_dictionary(force: bool = False) -> None:
|
|||||||
|
|
||||||
profanity = Profanity()
|
profanity = Profanity()
|
||||||
profanity.load_censor_words()
|
profanity.load_censor_words()
|
||||||
|
# Remove whitelisted words from the default word list
|
||||||
|
try:
|
||||||
|
for word in _WHITELIST:
|
||||||
|
profanity.remove_censor_words([word])
|
||||||
|
except AttributeError:
|
||||||
|
# If remove_censor_words doesn't exist, we'll handle it in post-processing
|
||||||
|
pass
|
||||||
combined = set(_STATIC_TERMS)
|
combined = set(_STATIC_TERMS)
|
||||||
combined.update(blocklist_list)
|
combined.update(blocklist_list)
|
||||||
|
# Remove whitelisted words from our custom terms
|
||||||
|
combined -= _WHITELIST
|
||||||
if combined:
|
if combined:
|
||||||
profanity.add_censor_words(list(combined))
|
profanity.add_censor_words(list(combined))
|
||||||
|
|
||||||
@@ -251,18 +593,59 @@ def _rebuild_dictionary(force: bool = False) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _apply_phrase_filters(text: str) -> str:
|
def _apply_phrase_filters(text: str) -> str:
|
||||||
result = text
|
"""
|
||||||
|
Apply phrase patterns to text. Patterns are applied to normalized text
|
||||||
|
(without special characters) and then mapped back to original positions.
|
||||||
|
"""
|
||||||
|
# Normalize text for phrase matching (remove special chars but preserve spaces)
|
||||||
|
normalized_text, position_map = _extract_alphanumeric_with_mapping(text, preserve_spaces=True)
|
||||||
|
normalized_lower = normalized_text.lower()
|
||||||
|
|
||||||
|
result = list(text)
|
||||||
|
censored_positions = set()
|
||||||
|
|
||||||
|
# Apply phrase patterns to normalized text
|
||||||
for pattern in _PHRASE_PATTERNS:
|
for pattern in _PHRASE_PATTERNS:
|
||||||
while True:
|
for match in pattern.finditer(normalized_lower):
|
||||||
match = pattern.search(result)
|
# Map back to original positions
|
||||||
if not match:
|
norm_start = match.start()
|
||||||
break
|
norm_end = match.end()
|
||||||
result = result[:match.start()] + ("*" * (match.end() - match.start())) + result[match.end():]
|
|
||||||
|
if norm_start < len(position_map) and norm_end <= len(position_map):
|
||||||
for start, end in sorted(_find_fuzzy_phrase_spans(text, "generic"), reverse=True):
|
orig_start = position_map[norm_start]
|
||||||
result = result[:start] + ("*" * (end - start)) + result[end:]
|
orig_end = position_map[norm_end - 1] + 1 if norm_end > 0 else orig_start + 1
|
||||||
|
|
||||||
return result
|
# Extend to include special characters
|
||||||
|
while orig_start > 0 and not text[orig_start - 1].isalnum():
|
||||||
|
orig_start -= 1
|
||||||
|
while orig_end < len(text) and not text[orig_end].isalnum():
|
||||||
|
orig_end += 1
|
||||||
|
|
||||||
|
# Mark positions for censoring
|
||||||
|
for pos in range(orig_start, min(orig_end, len(result))):
|
||||||
|
censored_positions.add(pos)
|
||||||
|
|
||||||
|
# Apply fuzzy phrase spans
|
||||||
|
for start, end in sorted(_find_fuzzy_phrase_spans(normalized_lower, "generic"), reverse=True):
|
||||||
|
if start < len(position_map) and end <= len(position_map):
|
||||||
|
orig_start = position_map[start]
|
||||||
|
orig_end = position_map[end - 1] + 1 if end > 0 else orig_start + 1
|
||||||
|
|
||||||
|
# Extend to include special characters
|
||||||
|
while orig_start > 0 and not text[orig_start - 1].isalnum():
|
||||||
|
orig_start -= 1
|
||||||
|
while orig_end < len(text) and not text[orig_end].isalnum():
|
||||||
|
orig_end += 1
|
||||||
|
|
||||||
|
for pos in range(orig_start, min(orig_end, len(result))):
|
||||||
|
censored_positions.add(pos)
|
||||||
|
|
||||||
|
# Apply censoring
|
||||||
|
for pos in censored_positions:
|
||||||
|
if pos < len(result):
|
||||||
|
result[pos] = "*"
|
||||||
|
|
||||||
|
return "".join(result)
|
||||||
|
|
||||||
|
|
||||||
def censor_text(text: str) -> str:
|
def censor_text(text: str) -> str:
|
||||||
@@ -271,7 +654,62 @@ def censor_text(text: str) -> str:
|
|||||||
|
|
||||||
_rebuild_dictionary()
|
_rebuild_dictionary()
|
||||||
preprocessed = _apply_phrase_filters(text)
|
preprocessed = _apply_phrase_filters(text)
|
||||||
return _profanity.censor(preprocessed, censor_char="\\*")
|
|
||||||
|
# Normalize text for whitelist matching (to handle special characters)
|
||||||
|
normalized_for_whitelist, whitelist_position_map = _extract_alphanumeric_with_mapping(preprocessed)
|
||||||
|
normalized_for_whitelist_lower = normalized_for_whitelist.lower()
|
||||||
|
|
||||||
|
# Identify and protect whitelisted words (using normalized text)
|
||||||
|
whitelist_spans = []
|
||||||
|
for whitelist_word in _WHITELIST:
|
||||||
|
# Normalize whitelist word too
|
||||||
|
normalized_whitelist, _ = _extract_alphanumeric_with_mapping(whitelist_word)
|
||||||
|
normalized_whitelist_lower = normalized_whitelist.lower()
|
||||||
|
|
||||||
|
# Find in normalized text
|
||||||
|
pattern = re.compile(re.escape(normalized_whitelist_lower), re.IGNORECASE)
|
||||||
|
for match in pattern.finditer(normalized_for_whitelist_lower):
|
||||||
|
# Map back to original positions
|
||||||
|
if match.start() < len(whitelist_position_map) and match.end() <= len(whitelist_position_map):
|
||||||
|
orig_start = whitelist_position_map[match.start()]
|
||||||
|
orig_end = whitelist_position_map[match.end() - 1] + 1 if match.end() > 0 else orig_start + 1
|
||||||
|
# Extend to include any special characters
|
||||||
|
while orig_start > 0 and not preprocessed[orig_start - 1].isalnum():
|
||||||
|
orig_start -= 1
|
||||||
|
while orig_end < len(preprocessed) and not preprocessed[orig_end].isalnum():
|
||||||
|
orig_end += 1
|
||||||
|
whitelist_spans.append((orig_start, min(orig_end, len(preprocessed)), preprocessed[orig_start:orig_end]))
|
||||||
|
|
||||||
|
# Extract only alphanumeric characters and normalize homoglyphs
|
||||||
|
# This removes special characters, emojis, etc. that could be used to bypass the filter
|
||||||
|
normalized_text, position_map = _extract_alphanumeric_with_mapping(preprocessed)
|
||||||
|
normalized_lower = normalized_text.lower()
|
||||||
|
|
||||||
|
# Check profanity on normalized text (without special characters)
|
||||||
|
profanity_spans = _find_profanity_spans_in_original(
|
||||||
|
normalized_lower,
|
||||||
|
position_map,
|
||||||
|
len(preprocessed),
|
||||||
|
preprocessed
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply censoring to original text
|
||||||
|
result = list(preprocessed)
|
||||||
|
for start, end in profanity_spans:
|
||||||
|
# Check if this span overlaps with a whitelisted word
|
||||||
|
is_whitelisted = False
|
||||||
|
for wl_start, wl_end, _ in whitelist_spans:
|
||||||
|
# Check if spans overlap
|
||||||
|
if not (end <= wl_start or start >= wl_end):
|
||||||
|
is_whitelisted = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not is_whitelisted:
|
||||||
|
# Censor the entire span (including any special characters within it)
|
||||||
|
for pos in range(start, min(end, len(result))):
|
||||||
|
result[pos] = "*"
|
||||||
|
|
||||||
|
return "".join(result)
|
||||||
|
|
||||||
|
|
||||||
def contains_profanity(text: str) -> bool:
|
def contains_profanity(text: str) -> bool:
|
||||||
@@ -279,12 +717,50 @@ def contains_profanity(text: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
_rebuild_dictionary()
|
_rebuild_dictionary()
|
||||||
|
|
||||||
|
# Extract only alphanumeric characters and normalize homoglyphs
|
||||||
|
# This removes special characters, emojis, etc. that could be used to bypass the filter
|
||||||
|
normalized_text, _ = _extract_alphanumeric_with_mapping(text)
|
||||||
|
normalized_lower = normalized_text.lower()
|
||||||
|
|
||||||
|
# Check phrase patterns on normalized text (to handle special characters)
|
||||||
for pattern in _PHRASE_PATTERNS:
|
for pattern in _PHRASE_PATTERNS:
|
||||||
if pattern.search(text):
|
if pattern.search(normalized_lower):
|
||||||
return True
|
return True
|
||||||
if _find_fuzzy_phrase_spans(text, "generic"):
|
if _find_fuzzy_phrase_spans(normalized_lower, "generic"):
|
||||||
return True
|
return True
|
||||||
return _profanity.contains_profanity(text)
|
|
||||||
|
# Check for profane words as substrings/subsequences (to catch cases like "хуй" in "хууй" or "хуйня")
|
||||||
|
profane_words = _STATIC_TERMS
|
||||||
|
substring_spans = _check_profanity_substrings(normalized_text, profane_words)
|
||||||
|
|
||||||
|
if substring_spans:
|
||||||
|
# Check if any found profanity is not part of a whitelisted word
|
||||||
|
for span_start, span_end in substring_spans:
|
||||||
|
is_whitelisted = False
|
||||||
|
for whitelist_word in _WHITELIST:
|
||||||
|
normalized_whitelist, _ = _extract_alphanumeric_with_mapping(whitelist_word)
|
||||||
|
normalized_whitelist_lower = normalized_whitelist.lower()
|
||||||
|
wl_pos = normalized_lower.find(normalized_whitelist_lower)
|
||||||
|
if wl_pos != -1:
|
||||||
|
# Check if profane span is within whitelisted word
|
||||||
|
if wl_pos <= span_start < wl_pos + len(normalized_whitelist_lower):
|
||||||
|
is_whitelisted = True
|
||||||
|
break
|
||||||
|
if not is_whitelisted:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Remove whitelisted words from text before checking profanity
|
||||||
|
# This allows standalone whitelisted words but still blocks them in phrases
|
||||||
|
for whitelist_word in _WHITELIST:
|
||||||
|
# Normalize whitelist word too
|
||||||
|
normalized_whitelist, _ = _extract_alphanumeric_with_mapping(whitelist_word)
|
||||||
|
normalized_whitelist_lower = normalized_whitelist.lower()
|
||||||
|
# Use word boundaries to match whole words only
|
||||||
|
pattern = re.compile(r"\b" + re.escape(normalized_whitelist_lower) + r"\b", re.IGNORECASE)
|
||||||
|
normalized_lower = pattern.sub("", normalized_lower)
|
||||||
|
|
||||||
|
return _profanity.contains_profanity(normalized_lower)
|
||||||
|
|
||||||
|
|
||||||
def contains_sensitive_phrase(text: str) -> bool:
|
def contains_sensitive_phrase(text: str) -> bool:
|
||||||
|
|||||||
Reference in New Issue
Block a user