safetype 1.5.0__tar.gz

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.
@@ -0,0 +1,16 @@
1
+ node_modules/
2
+ dist/
3
+ coverage/
4
+ .turbo/
5
+ *.log
6
+ .DS_Store
7
+ test-results/
8
+ playwright-report/
9
+ packages/*/e2e/dist/
10
+ packages/vue/e2e/dist/
11
+ .angular/
12
+
13
+ .venv/
14
+ python/dist/
15
+ __pycache__/
16
+ .pytest_cache/
safetype-1.5.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SafeType.js contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,122 @@
1
+ Metadata-Version: 2.5
2
+ Name: safetype
3
+ Version: 1.5.0
4
+ Summary: Rule-based content moderation (profanity, hate, harassment) for Python servers. Same engine as SafeType.js.
5
+ Project-URL: Homepage, https://github.com/karammm/safetype
6
+ Project-URL: Documentation, https://github.com/karammm/safetype/tree/main/python#readme
7
+ Project-URL: Issues, https://github.com/karammm/safetype/issues
8
+ Project-URL: Changelog, https://github.com/karammm/safetype/blob/main/CHANGELOG.md
9
+ Author: SafeType.js contributors
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: content-safety,filter,harassment,hate-speech,moderation,profanity
13
+ Classifier: Development Status :: 5 - Production/Stable
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Topic :: Text Processing :: Filters
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.9
21
+ Description-Content-Type: text/markdown
22
+
23
+ # safetype (Python)
24
+
25
+ Rule-based content moderation for Python servers: profanity, hate speech and harassment.
26
+ It's the **same engine and English dictionary** as the [SafeType.js](https://github.com/karammm/safetype)
27
+ JavaScript packages, so your backend can re-check exactly what the browser checked.
28
+ It has no dependencies and runs fully offline.
29
+
30
+ ```bash
31
+ pip install safetype
32
+ ```
33
+
34
+ ```python
35
+ from safetype import create_moderation_engine
36
+
37
+ engine = create_moderation_engine(mode="mask")
38
+ engine.moderate("What the fuck are you doing?").text
39
+ # 'What the f*ck are you doing?'
40
+ ```
41
+
42
+ ## Server-side enforcement
43
+
44
+ Client-side moderation can be bypassed (devtools, curl, modified clients). Re-check before you store:
45
+
46
+ ```python
47
+ engine = create_moderation_engine(
48
+ mode="mask",
49
+ policy={"hate": {"action": "block"}, "harassment": {"action": "block"}},
50
+ )
51
+
52
+ check = engine.validate(comment)
53
+ if not check.valid:
54
+ raise ValidationError([v.category for v in check.violations])
55
+ store(check.result.text) # profanity masked
56
+ if check.result.requires_review:
57
+ queue_for_human_review(comment) # e.g. quoted or reported speech
58
+ ```
59
+
60
+ FastAPI:
61
+
62
+ ```python
63
+ @app.post("/comments")
64
+ def create_comment(body: CommentIn):
65
+ check = engine.validate(body.text)
66
+ if not check.valid:
67
+ raise HTTPException(422, detail=[{"category": v.category, "rule": v.rule_id} for v in check.violations])
68
+ return save(check.result.text)
69
+ ```
70
+
71
+ Django form:
72
+
73
+ ```python
74
+ def clean_body(self):
75
+ check = engine.validate(self.cleaned_data["body"])
76
+ if not check.valid:
77
+ raise forms.ValidationError("Please keep it respectful.")
78
+ return check.result.text
79
+ ```
80
+
81
+ ## What it handles
82
+
83
+ - Five actions (`allow`, `mask`, `suggest`, `warn`, `block`) and per-category policies
84
+ - Profanity is masked deterministically. Hate and harassment rules are lower-confidence signals that context can weaken:
85
+ quoted or reported speech becomes `requires_review` instead of being acted on
86
+ - Unicode (accents, full-width, Cyrillic/Greek lookalikes, zero-width), leet (`sh1t`), repeated letters, spaced letters
87
+ - No Scunthorpe problem, and masking is idempotent
88
+ - Custom words, custom rules, allow lists, mask styles (`vowels`, `partial`, `full`, or a function)
89
+
90
+ ## API
91
+
92
+ ```python
93
+ engine = create_moderation_engine(
94
+ mode="mask", language="en", categories=None, policy=None, packs=None,
95
+ custom_words=None, allow_list=None, rules=None,
96
+ mask_char="*", mask_style="vowels", detect_spaced_letters=True,
97
+ )
98
+ engine.moderate(text) # ModerationResult(text, suggested_text, matches, action, allowed, requires_review, mapping)
99
+ engine.mask(text) # str
100
+ engine.validate(text) # ValidationResult(valid, result, violations)
101
+ engine.add_rule({"id": "app.x", "pattern": "(foo|bar*)", "category": "custom"})
102
+ engine.remove_rule("app.x")
103
+ engine.set_policy({"harassment": {"action": "block", "min_confidence": 0.7}})
104
+ engine.set_mode("warn")
105
+
106
+ safetype.moderate(text); safetype.mask(text) # shared default engine
107
+ ```
108
+
109
+ Offsets (`match.start` / `match.end`) are Python string indices (code points).
110
+
111
+ ## Parity with JavaScript
112
+
113
+ The test suite replays 684 cases recorded from the JavaScript engine and requires identical results.
114
+ The dictionary is exported from the JavaScript build, so both always share the same rules.
115
+
116
+ ## Limitations
117
+
118
+ Rule-based detection misses novel spellings and sarcasm, and it can flag reclaimed or quoted language.
119
+ Treat hate and harassment results as signals for review, not verdicts. Contextual (AI) classifiers
120
+ are available in the JavaScript engine and planned for Python.
121
+
122
+ MIT licensed.
@@ -0,0 +1,100 @@
1
+ # safetype (Python)
2
+
3
+ Rule-based content moderation for Python servers: profanity, hate speech and harassment.
4
+ It's the **same engine and English dictionary** as the [SafeType.js](https://github.com/karammm/safetype)
5
+ JavaScript packages, so your backend can re-check exactly what the browser checked.
6
+ It has no dependencies and runs fully offline.
7
+
8
+ ```bash
9
+ pip install safetype
10
+ ```
11
+
12
+ ```python
13
+ from safetype import create_moderation_engine
14
+
15
+ engine = create_moderation_engine(mode="mask")
16
+ engine.moderate("What the fuck are you doing?").text
17
+ # 'What the f*ck are you doing?'
18
+ ```
19
+
20
+ ## Server-side enforcement
21
+
22
+ Client-side moderation can be bypassed (devtools, curl, modified clients). Re-check before you store:
23
+
24
+ ```python
25
+ engine = create_moderation_engine(
26
+ mode="mask",
27
+ policy={"hate": {"action": "block"}, "harassment": {"action": "block"}},
28
+ )
29
+
30
+ check = engine.validate(comment)
31
+ if not check.valid:
32
+ raise ValidationError([v.category for v in check.violations])
33
+ store(check.result.text) # profanity masked
34
+ if check.result.requires_review:
35
+ queue_for_human_review(comment) # e.g. quoted or reported speech
36
+ ```
37
+
38
+ FastAPI:
39
+
40
+ ```python
41
+ @app.post("/comments")
42
+ def create_comment(body: CommentIn):
43
+ check = engine.validate(body.text)
44
+ if not check.valid:
45
+ raise HTTPException(422, detail=[{"category": v.category, "rule": v.rule_id} for v in check.violations])
46
+ return save(check.result.text)
47
+ ```
48
+
49
+ Django form:
50
+
51
+ ```python
52
+ def clean_body(self):
53
+ check = engine.validate(self.cleaned_data["body"])
54
+ if not check.valid:
55
+ raise forms.ValidationError("Please keep it respectful.")
56
+ return check.result.text
57
+ ```
58
+
59
+ ## What it handles
60
+
61
+ - Five actions (`allow`, `mask`, `suggest`, `warn`, `block`) and per-category policies
62
+ - Profanity is masked deterministically. Hate and harassment rules are lower-confidence signals that context can weaken:
63
+ quoted or reported speech becomes `requires_review` instead of being acted on
64
+ - Unicode (accents, full-width, Cyrillic/Greek lookalikes, zero-width), leet (`sh1t`), repeated letters, spaced letters
65
+ - No Scunthorpe problem, and masking is idempotent
66
+ - Custom words, custom rules, allow lists, mask styles (`vowels`, `partial`, `full`, or a function)
67
+
68
+ ## API
69
+
70
+ ```python
71
+ engine = create_moderation_engine(
72
+ mode="mask", language="en", categories=None, policy=None, packs=None,
73
+ custom_words=None, allow_list=None, rules=None,
74
+ mask_char="*", mask_style="vowels", detect_spaced_letters=True,
75
+ )
76
+ engine.moderate(text) # ModerationResult(text, suggested_text, matches, action, allowed, requires_review, mapping)
77
+ engine.mask(text) # str
78
+ engine.validate(text) # ValidationResult(valid, result, violations)
79
+ engine.add_rule({"id": "app.x", "pattern": "(foo|bar*)", "category": "custom"})
80
+ engine.remove_rule("app.x")
81
+ engine.set_policy({"harassment": {"action": "block", "min_confidence": 0.7}})
82
+ engine.set_mode("warn")
83
+
84
+ safetype.moderate(text); safetype.mask(text) # shared default engine
85
+ ```
86
+
87
+ Offsets (`match.start` / `match.end`) are Python string indices (code points).
88
+
89
+ ## Parity with JavaScript
90
+
91
+ The test suite replays 684 cases recorded from the JavaScript engine and requires identical results.
92
+ The dictionary is exported from the JavaScript build, so both always share the same rules.
93
+
94
+ ## Limitations
95
+
96
+ Rule-based detection misses novel spellings and sarcasm, and it can flag reclaimed or quoted language.
97
+ Treat hate and harassment results as signals for review, not verdicts. Contextual (AI) classifiers
98
+ are available in the JavaScript engine and planned for Python.
99
+
100
+ MIT licensed.
@@ -0,0 +1,42 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.24"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "safetype"
7
+ version = "1.5.0"
8
+ description = "Rule-based content moderation (profanity, hate, harassment) for Python servers. Same engine as SafeType.js."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.9"
13
+ authors = [{ name = "SafeType.js contributors" }]
14
+ keywords = ["moderation", "profanity", "filter", "content-safety", "hate-speech", "harassment"]
15
+ classifiers = [
16
+ "Development Status :: 5 - Production/Stable",
17
+ "Intended Audience :: Developers",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3 :: Only",
21
+ "Topic :: Text Processing :: Filters",
22
+ "Typing :: Typed",
23
+ ]
24
+ dependencies = []
25
+
26
+ [project.urls]
27
+ Homepage = "https://github.com/karammm/safetype"
28
+ Documentation = "https://github.com/karammm/safetype/tree/main/python#readme"
29
+ Issues = "https://github.com/karammm/safetype/issues"
30
+ Changelog = "https://github.com/karammm/safetype/blob/main/CHANGELOG.md"
31
+
32
+ [dependency-groups]
33
+ dev = ["pytest>=8"]
34
+
35
+ [tool.hatch.build.targets.wheel]
36
+ packages = ["src/safetype"]
37
+
38
+ [tool.hatch.build.targets.sdist]
39
+ include = ["src/safetype", "tests", "README.md", "LICENSE"]
40
+
41
+ [tool.pytest.ini_options]
42
+ testpaths = ["tests"]
@@ -0,0 +1,70 @@
1
+ """SafeType: rule-based content moderation for Python servers.
2
+
3
+ The same engine and English dictionary as the SafeType.js JavaScript packages, so the server can
4
+ re-check exactly what the browser checked.
5
+
6
+ >>> from safetype import create_moderation_engine
7
+ >>> engine = create_moderation_engine(mode="mask")
8
+ >>> engine.moderate("What the fuck are you doing?").text
9
+ 'What the f*ck are you doing?'
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Optional
15
+
16
+ from ._pattern import PatternError
17
+ from ._transform import mask_text, match_case
18
+ from .engine import ModerationEngine, create_moderation_engine, load_pack
19
+ from .types import (
20
+ ModerationAction,
21
+ ModerationCategory,
22
+ ModerationMatch,
23
+ ModerationResult,
24
+ OffsetMapping,
25
+ RuleDict,
26
+ TextEdit,
27
+ ValidationResult,
28
+ )
29
+
30
+ __version__ = "1.5.0"
31
+
32
+ _default: Optional[ModerationEngine] = None
33
+
34
+
35
+ def _engine() -> ModerationEngine:
36
+ global _default
37
+ if _default is None:
38
+ _default = create_moderation_engine()
39
+ return _default
40
+
41
+
42
+ def moderate(text: str) -> ModerationResult:
43
+ """Moderates with a shared default engine (``mode="mask"``, English)."""
44
+ return _engine().moderate(text)
45
+
46
+
47
+ def mask(text: str) -> str:
48
+ """Masks with a shared default engine."""
49
+ return _engine().mask(text)
50
+
51
+
52
+ __all__ = [
53
+ "ModerationAction",
54
+ "ModerationCategory",
55
+ "ModerationEngine",
56
+ "ModerationMatch",
57
+ "ModerationResult",
58
+ "OffsetMapping",
59
+ "PatternError",
60
+ "RuleDict",
61
+ "TextEdit",
62
+ "ValidationResult",
63
+ "__version__",
64
+ "create_moderation_engine",
65
+ "load_pack",
66
+ "mask",
67
+ "mask_text",
68
+ "match_case",
69
+ "moderate",
70
+ ]
@@ -0,0 +1,46 @@
1
+ """Synchronous context heuristics (mirrors classifier.ts). They only lower confidence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ._matcher import RawMatch
6
+ from ._tokenize import Token
7
+
8
+ CONTEXT_FACTOR = {"direct": 1.0, "quoted": 0.5, "mention": 0.4}
9
+
10
+ _MENTION_WORDS = frozenset(
11
+ "word words term slur phrase say said saying says spell spelled called calling means meaning define definition using use".split()
12
+ )
13
+
14
+ _PAIRS = (('"', '"'), ("“", "”"), ("‘", "’"), ("«", "»"), ("「", "」"))
15
+
16
+
17
+ def find_quoted_ranges(text: str) -> list[tuple[int, int]]:
18
+ ranges: list[tuple[int, int]] = []
19
+ for open_, close in _PAIRS:
20
+ start_from = 0
21
+ while start_from < len(text):
22
+ s = text.find(open_, start_from)
23
+ if s == -1:
24
+ break
25
+ e = text.find(close, s + 1)
26
+ if e == -1:
27
+ break
28
+ newline = text.find("\n", s)
29
+ if newline != -1 and newline < e:
30
+ start_from = newline + 1
31
+ continue
32
+ ranges.append((s + 1, e))
33
+ start_from = e + 1
34
+ return ranges
35
+
36
+
37
+ def detect_context(match: RawMatch, tokens: list[Token], quoted: list[tuple[int, int]]) -> str:
38
+ if match.token_index is not None:
39
+ for k in (1, 2):
40
+ i = match.token_index - k
41
+ if i >= 0 and tokens[i].value in _MENTION_WORDS:
42
+ return "mention"
43
+ for s, e in quoted:
44
+ if match.start >= s and match.end <= e:
45
+ return "quoted"
46
+ return "direct"
@@ -0,0 +1,176 @@
1
+ """Rule compilation, indexing and matching (mirrors matcher.ts)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Optional
7
+
8
+ from ._normalize import NormalizedText
9
+ from ._pattern import Alternative, Element, PatternError, Run, parse_pattern, runs_match, to_runs
10
+ from ._tokenize import Token
11
+ from .types import ALL_CATEGORIES, SEVERITY_RANK, RuleDict
12
+
13
+ DEFAULT_PRIORITY = {"custom": 40, "hate": 30, "harassment": 20, "profanity": 10}
14
+
15
+
16
+ @dataclass
17
+ class CompiledRule:
18
+ id: str
19
+ category: str
20
+ severity: str
21
+ confidence: float
22
+ priority: int
23
+ replacement: Optional[str]
24
+ label: Optional[str]
25
+ boundary: str
26
+ elements: tuple[Element, ...]
27
+
28
+
29
+ @dataclass
30
+ class RawMatch:
31
+ start: int
32
+ end: int
33
+ rule: CompiledRule
34
+ confidence: float
35
+ token_index: Optional[int] = None
36
+ context: str = "direct"
37
+ source: Optional[str] = None
38
+
39
+
40
+ def compile_rule(rule: RuleDict, boundary: str = "word") -> CompiledRule:
41
+ rule_id = rule.get("id")
42
+ if not isinstance(rule_id, str) or not rule_id:
43
+ raise TypeError("Rule requires a non-empty id")
44
+ if not isinstance(rule.get("pattern"), str):
45
+ raise TypeError(f'Rule "{rule_id}" requires a pattern')
46
+ category = rule.get("category")
47
+ if category not in ALL_CATEGORIES:
48
+ raise TypeError(f'Rule "{rule_id}" has unknown category "{category}"')
49
+ severity = rule.get("severity", "medium")
50
+ if severity not in SEVERITY_RANK:
51
+ raise TypeError(f'Rule "{rule_id}" has unknown severity "{severity}"')
52
+ confidence = rule.get("confidence", 1)
53
+ if not (0 <= confidence <= 1):
54
+ raise ValueError(f'Rule "{rule_id}" confidence must be within [0, 1]')
55
+ try:
56
+ elements = parse_pattern(rule["pattern"])
57
+ except PatternError as error:
58
+ raise PatternError(f'Rule "{rule_id}": {error}') from None
59
+ if boundary == "substring" and len(elements) != 1:
60
+ raise PatternError(f'Rule "{rule_id}": substring packs support single-token patterns only')
61
+ return CompiledRule(
62
+ id=rule_id,
63
+ category=category,
64
+ severity=severity,
65
+ confidence=confidence,
66
+ priority=rule.get("priority", DEFAULT_PRIORITY[category]),
67
+ replacement=rule.get("replacement"),
68
+ label=rule.get("label"),
69
+ boundary=boundary,
70
+ elements=elements,
71
+ )
72
+
73
+
74
+ @dataclass
75
+ class _Entry:
76
+ rule: CompiledRule
77
+ alt: Alternative
78
+
79
+
80
+ @dataclass
81
+ class RuleIndex:
82
+ exact: dict[str, list[_Entry]] = field(default_factory=dict)
83
+ prefix: dict[str, list[_Entry]] = field(default_factory=dict)
84
+ substring: list[CompiledRule] = field(default_factory=list)
85
+
86
+ @classmethod
87
+ def build(cls, rules) -> "RuleIndex":
88
+ index = cls()
89
+ for rule in rules:
90
+ if rule.boundary == "substring":
91
+ index.substring.append(rule)
92
+ continue
93
+ for alt in rule.elements[0].alternatives:
94
+ target = index.prefix if alt.prefix else index.exact
95
+ key = alt.key[0] if alt.prefix else alt.key
96
+ target.setdefault(key, []).append(_Entry(rule, alt))
97
+ return index
98
+
99
+ def candidates(self, runs: tuple[Run, ...], key: str) -> list[_Entry]:
100
+ out = [e for e in self.exact.get(key, []) if runs_match(runs, e.alt)]
101
+ out += [e for e in self.prefix.get(key[:1], []) if runs_match(runs, e.alt)]
102
+ return out
103
+
104
+
105
+ @dataclass
106
+ class _TokenInfo:
107
+ runs: tuple[Run, ...]
108
+ key: str
109
+ blocked: bool
110
+
111
+
112
+ def _token_info(token: Token, allow_list: set[str]) -> _TokenInfo:
113
+ runs = to_runs(token.value)
114
+ return _TokenInfo(runs, "".join(c for c, _ in runs), token.masked or token.value in allow_list)
115
+
116
+
117
+ def _element_matches(element: Element, info: _TokenInfo) -> bool:
118
+ return not info.blocked and any(runs_match(info.runs, alt) for alt in element.alternatives)
119
+
120
+
121
+ def _match_rest(elements, e: int, infos, t: int) -> int:
122
+ """Index of the last token consumed, preferring the longest match, or -1."""
123
+ if e == len(elements):
124
+ return t - 1
125
+ element = elements[e]
126
+ best = -1
127
+ if t < len(infos) and _element_matches(element, infos[t]):
128
+ best = _match_rest(elements, e + 1, infos, t + 1)
129
+ if element.optional:
130
+ best = max(best, _match_rest(elements, e + 1, infos, t))
131
+ return best
132
+
133
+
134
+ def find_matches(index: RuleIndex, normalized: NormalizedText, tokens: list[Token], spaced: list[Token], allow_list: set[str]) -> list[RawMatch]:
135
+ infos = [_token_info(t, allow_list) for t in tokens]
136
+ out: list[RawMatch] = []
137
+ seen: set[tuple[str, int, int]] = set()
138
+
139
+ def push(match: RawMatch) -> None:
140
+ key = (match.rule.id, match.start, match.end)
141
+ if key not in seen:
142
+ seen.add(key)
143
+ out.append(match)
144
+
145
+ for i, token in enumerate(tokens):
146
+ info = infos[i]
147
+ if info.blocked:
148
+ continue
149
+ seen_rules: set[str] = set()
150
+ for entry in index.candidates(info.runs, info.key):
151
+ rule = entry.rule
152
+ if rule.id in seen_rules:
153
+ continue
154
+ seen_rules.add(rule.id)
155
+ last = _match_rest(rule.elements, 1, infos, i + 1)
156
+ if last < i:
157
+ continue
158
+ push(RawMatch(token.start, tokens[last].end, rule, rule.confidence, token_index=i))
159
+
160
+ for token in spaced:
161
+ info = _token_info(token, allow_list)
162
+ if info.blocked:
163
+ continue
164
+ for entry in index.candidates(info.runs, info.key):
165
+ if len(entry.rule.elements) == 1:
166
+ push(RawMatch(token.start, token.end, entry.rule, entry.rule.confidence, token_index=token.first_index))
167
+
168
+ text = normalized.text
169
+ for rule in index.substring:
170
+ for alt in rule.elements[0].alternatives:
171
+ at = text.find(alt.value)
172
+ while at != -1:
173
+ n_end = at + len(alt.value)
174
+ push(RawMatch(normalized.starts[at], normalized.ends[n_end - 1], rule, rule.confidence))
175
+ at = text.find(alt.value, at + 1)
176
+ return out
@@ -0,0 +1,65 @@
1
+ """Unicode-aware normalization with an offset map back to the original text (mirrors normalizer.ts)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import unicodedata
6
+ from dataclasses import dataclass
7
+ from functools import lru_cache
8
+
9
+ _ZERO_WIDTH = frozenset("​‌‍⁠­᠎")
10
+
11
+ # Cyrillic and Greek letters that look like Latin letters (lowercase forms).
12
+ _CONFUSABLES = {
13
+ "а": "a", "в": "b", "е": "e", "ё": "e", "к": "k", "м": "m", "н": "h", "о": "o", "р": "p", "с": "c", "т": "t",
14
+ "у": "y", "х": "x", "ѕ": "s", "і": "i", "ї": "i", "ј": "j", "һ": "h", "ԁ": "d", "ԛ": "q", "ԝ": "w", "ү": "y",
15
+ "α": "a", "β": "b", "ε": "e", "η": "n", "ι": "i", "κ": "k", "ν": "v", "ο": "o", "ρ": "p", "τ": "t", "υ": "u",
16
+ "χ": "x", "ω": "w",
17
+ }
18
+
19
+ # Symbol/digit substitutions, applied only inside tokens that already contain a letter.
20
+ LEET = {"@": "a", "4": "a", "$": "s", "5": "s", "0": "o", "1": "i", "!": "i", "|": "i", "3": "e", "€": "e", "7": "t"}
21
+
22
+
23
+ @lru_cache(maxsize=10_000)
24
+ def normalize_char(ch: str) -> str:
25
+ if ch in _ZERO_WIDTH:
26
+ return ""
27
+ decomposed = unicodedata.normalize("NFKD", ch.lower())
28
+ base = "".join(c for c in decomposed if not unicodedata.category(c).startswith("M")).lower()
29
+ return "".join(_CONFUSABLES.get(c, c) for c in base)
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class NormalizedText:
34
+ text: str
35
+ starts: list[int]
36
+ """Original start offset for each character of ``text``."""
37
+ ends: list[int]
38
+ """Original end offset (exclusive) for each character of ``text``."""
39
+
40
+
41
+ def normalize(value: str) -> NormalizedText:
42
+ out: list[str] = []
43
+ starts: list[int] = []
44
+ ends: list[int] = []
45
+ for i, ch in enumerate(value):
46
+ n = normalize_char(ch)
47
+ for c in n:
48
+ out.append(c)
49
+ starts.append(i)
50
+ ends.append(i + 1)
51
+ return NormalizedText("".join(out), starts, ends)
52
+
53
+
54
+ def is_letter(ch: str) -> bool:
55
+ return unicodedata.category(ch).startswith("L")
56
+
57
+
58
+ def apply_leet(value: str) -> str:
59
+ if not any(is_letter(c) for c in value):
60
+ return value
61
+ return "".join(LEET.get(c, c) for c in value)
62
+
63
+
64
+ def normalize_term(value: str) -> str:
65
+ return apply_leet(normalize(value).text)