persian-text-guard 1.4.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.
- persian_text_guard-1.4.0/LICENSE +21 -0
- persian_text_guard-1.4.0/PKG-INFO +454 -0
- persian_text_guard-1.4.0/README.md +426 -0
- persian_text_guard-1.4.0/THIRD-PARTY-NOTICES.md +46 -0
- persian_text_guard-1.4.0/hatch_build.py +127 -0
- persian_text_guard-1.4.0/pyproject.toml +155 -0
- persian_text_guard-1.4.0/src/persian_text_guard/__init__.py +54 -0
- persian_text_guard-1.4.0/src/persian_text_guard/_filter.py +374 -0
- persian_text_guard-1.4.0/src/persian_text_guard/_fold.py +260 -0
- persian_text_guard-1.4.0/src/persian_text_guard/_normalizer.py +550 -0
- persian_text_guard-1.4.0/src/persian_text_guard/_regions.py +127 -0
- persian_text_guard-1.4.0/src/persian_text_guard/_scan.py +573 -0
- persian_text_guard-1.4.0/src/persian_text_guard/_source_map.py +177 -0
- persian_text_guard-1.4.0/src/persian_text_guard/_types.py +314 -0
- persian_text_guard-1.4.0/src/persian_text_guard/_unicode.py +219 -0
- persian_text_guard-1.4.0/src/persian_text_guard/_utf16.py +77 -0
- persian_text_guard-1.4.0/src/persian_text_guard/_version.py +3 -0
- persian_text_guard-1.4.0/src/persian_text_guard/_word_list.py +245 -0
- persian_text_guard-1.4.0/src/persian_text_guard/_wordlists.py +10 -0
- persian_text_guard-1.4.0/src/persian_text_guard/py.typed +0 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Amirehsan Kohannasab
|
|
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,454 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: persian-text-guard
|
|
3
|
+
Version: 1.4.0
|
|
4
|
+
Summary: Persian text normalization and evasion-resistant profanity filtering for Python. Folds Arabic/Persian look-alike letters, zero-width and bidi characters, and all three digit ranges, and catches the ways people get around a word list: spaced or dotted letters, held keys, leetspeak, filler symbols, Cyrillic look-alikes and Finglish. Finds every match with its position and censors messages with a fixed mask. Ships an optional curated Persian/Finglish/English word list of about 1,250 categorised entries.
|
|
5
|
+
Project-URL: Homepage, https://github.com/AmirehsanK/PersianTextGuard/tree/main/python#readme
|
|
6
|
+
Project-URL: Source, https://github.com/AmirehsanK/PersianTextGuard
|
|
7
|
+
Project-URL: Issues, https://github.com/AmirehsanK/PersianTextGuard/issues
|
|
8
|
+
Author: Amirehsan Kohannasab
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
License-File: THIRD-PARTY-NOTICES.md
|
|
12
|
+
Keywords: arabic,farsi,finglish,moderation,normalization,persian,profanity,profanity-filter,python,rtl,text
|
|
13
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Natural Language :: English
|
|
16
|
+
Classifier: Natural Language :: Persian
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
23
|
+
Classifier: Programming Language :: Python :: Free Threading :: 3 - Stable
|
|
24
|
+
Classifier: Topic :: Text Processing :: Linguistic
|
|
25
|
+
Classifier: Typing :: Typed
|
|
26
|
+
Requires-Python: >=3.11
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# persian-text-guard
|
|
30
|
+
|
|
31
|
+
Persian text normalization and evasion-resistant profanity filtering for Python.
|
|
32
|
+
|
|
33
|
+
[فارسی](#فارسی) · [Full project README](https://github.com/AmirehsanK/PersianTextGuard#readme) · [.NET package](https://www.nuget.org/packages/PersianTextGuard) · [npm package](https://www.npmjs.com/package/persian-text-guard)
|
|
34
|
+
|
|
35
|
+
A word list is easy to get around. Type an Arabic `ي` instead of `ی`, slip a zero-width character
|
|
36
|
+
inside a word, write `f u c k`, `sh1t`, `کیییییر` or `k0s`, and a plain `word in text` check sees
|
|
37
|
+
nothing. `persian-text-guard` reads through those tricks, and just as importantly, it doesn't flag
|
|
38
|
+
ordinary messages: `هر کس`, `تخم مرغ`, `class`, `push it` and `Scunthorpe` all pass.
|
|
39
|
+
|
|
40
|
+
It is the Python port of [PersianTextGuard](https://github.com/AmirehsanK/PersianTextGuard), and gives
|
|
41
|
+
the same answers as the .NET and JavaScript packages for every case in the shared
|
|
42
|
+
[conformance corpus](https://github.com/AmirehsanK/PersianTextGuard/tree/main/conformance).
|
|
43
|
+
|
|
44
|
+
- Pure Python, with full type hints (`py.typed`), for CPython 3.11 and later, free-threaded 3.14
|
|
45
|
+
included.
|
|
46
|
+
- No dependencies.
|
|
47
|
+
- Safe to share across threads.
|
|
48
|
+
|
|
49
|
+
## Installation
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install persian-text-guard
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
or, in a project managed by uv:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
uv add persian-text-guard
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Quick start
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from persian_text_guard import ProfanityFilter, WordList
|
|
65
|
+
|
|
66
|
+
# Build once and share it: a filter never changes after it is built.
|
|
67
|
+
filter = ProfanityFilter(WordList.persian_default())
|
|
68
|
+
|
|
69
|
+
assert not filter.contains_profanity("سلام، سفارشم کی میرسه؟")
|
|
70
|
+
assert filter.contains_profanity("ک.ی.ر")
|
|
71
|
+
assert filter.contains_profanity("f u c k")
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### What matched, and why
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from persian_text_guard import EvasionKind, ProfanityFilter, WordCategory, WordList
|
|
78
|
+
|
|
79
|
+
filter = ProfanityFilter(WordList.persian_default())
|
|
80
|
+
match = filter.find_match("sh1iiit")
|
|
81
|
+
|
|
82
|
+
assert match is not None
|
|
83
|
+
assert match.word.text == "shit"
|
|
84
|
+
assert match.word.category == WordCategory.PROFANITY
|
|
85
|
+
assert match.evasion == (EvasionKind.REPEATED_LETTERS, EvasionKind.LOOKALIKE_CHARACTERS)
|
|
86
|
+
assert filter.find_match("hello") is None
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Every match, with its position
|
|
90
|
+
|
|
91
|
+
Positions are Python string indexes (code points), so slicing gives the matched text:
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from persian_text_guard import ProfanityFilter, WordList
|
|
95
|
+
|
|
96
|
+
filter = ProfanityFilter(WordList.persian_default())
|
|
97
|
+
message = "sh1t and f u c k"
|
|
98
|
+
|
|
99
|
+
found = [
|
|
100
|
+
(match.word.text, match.index, match.length, message[match.index : match.index + match.length])
|
|
101
|
+
for match in filter.find_matches(message)
|
|
102
|
+
]
|
|
103
|
+
assert found == [("shit", 0, 4, "sh1t"), ("fuck", 9, 7, "f u c k")]
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
This holds for emoji and every other character outside the Basic Multilingual Plane too: each counts
|
|
107
|
+
as one position, as it does everywhere else in Python.
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
from persian_text_guard import ProfanityFilter, WordList
|
|
111
|
+
|
|
112
|
+
filter = ProfanityFilter(WordList.persian_default())
|
|
113
|
+
message = "\N{GRINNING FACE} کیر"
|
|
114
|
+
|
|
115
|
+
match = filter.find_match(message)
|
|
116
|
+
assert match is not None
|
|
117
|
+
assert (match.index, match.length) == (2, 3)
|
|
118
|
+
assert message[match.index : match.index + match.length] == "کیر"
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### Censoring
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
from persian_text_guard import ProfanityFilter, WordList
|
|
125
|
+
|
|
126
|
+
filter = ProfanityFilter(WordList.persian_default())
|
|
127
|
+
|
|
128
|
+
assert filter.censor("kir and motherfucker") == "**** and ****"
|
|
129
|
+
assert filter.censor("جنده\N{ZERO WIDTH NON-JOINER}ها رو ببین") == "**** رو ببین"
|
|
130
|
+
assert filter.censor("this is kir", "#") == "this is ####"
|
|
131
|
+
assert filter.censor("hello") == "hello"
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Whole words are hidden, every mask is four characters, and the output is always clean. Clean text
|
|
135
|
+
comes back unchanged, and everything outside a hidden word is returned exactly as given. The mask can
|
|
136
|
+
be any symbol or punctuation; a letter, digit, whitespace or control character raises `ValueError`.
|
|
137
|
+
|
|
138
|
+
### Categories
|
|
139
|
+
|
|
140
|
+
Every bundled entry has a category: `profanity`, `sexual`, `insult`, `slur`, `harassment` or `mild`.
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
from persian_text_guard import ProfanityFilter, WordCategory, WordList
|
|
144
|
+
|
|
145
|
+
everyday = ProfanityFilter(WordList.persian_default()) # everything except mild
|
|
146
|
+
strict = ProfanityFilter(WordList.all()) # mild too
|
|
147
|
+
slurs_and_abuse = ProfanityFilter(WordList.bundled(WordCategory.SLUR, "harassment"))
|
|
148
|
+
|
|
149
|
+
assert not everyday.contains_profanity("این فیلم آشغال بود")
|
|
150
|
+
assert strict.contains_profanity("این فیلم آشغال بود")
|
|
151
|
+
assert slurs_and_abuse.contains_profanity("kys")
|
|
152
|
+
assert not slurs_and_abuse.contains_profanity("کیر")
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
A category, like a match mode or a normalization step, can be given as an enum member or as its name
|
|
156
|
+
(`"slur"`); a type checker rejects a name that does not exist, and so does the code at run time.
|
|
157
|
+
|
|
158
|
+
### Your own words
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
from persian_text_guard import BannedWord, ProfanityFilter, WordList, WordMatchMode
|
|
162
|
+
|
|
163
|
+
filter = ProfanityFilter(
|
|
164
|
+
[
|
|
165
|
+
BannedWord("اسپم"), # whole word (the default)
|
|
166
|
+
BannedWord("casino", WordMatchMode.ANYWHERE), # inside longer words too
|
|
167
|
+
]
|
|
168
|
+
)
|
|
169
|
+
assert filter.contains_profanity("این پیام اسپم است")
|
|
170
|
+
assert filter.contains_profanity("onlinecasino.example")
|
|
171
|
+
|
|
172
|
+
# Or combine your entries with the bundled list:
|
|
173
|
+
combined = ProfanityFilter([*WordList.persian_default(), BannedWord("casino", "anywhere", "mild")])
|
|
174
|
+
assert combined.contains_profanity("casino night")
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### Word-list files
|
|
178
|
+
|
|
179
|
+
Word lists can also be kept in a text file, one entry per line. `WordList.load` reads a path or an open
|
|
180
|
+
file as UTF-8, and `WordList.parse` reads text you already have:
|
|
181
|
+
|
|
182
|
+
```python
|
|
183
|
+
import tempfile
|
|
184
|
+
from pathlib import Path
|
|
185
|
+
|
|
186
|
+
from persian_text_guard import ProfanityFilter, WordCategory, WordList, WordMatchMode
|
|
187
|
+
|
|
188
|
+
text = "# comments start with #\n[insult]\nidiot\n~scam\n"
|
|
189
|
+
|
|
190
|
+
words = WordList.parse(text)
|
|
191
|
+
assert [(w.text, w.mode, w.category) for w in words] == [
|
|
192
|
+
("idiot", WordMatchMode.WHOLE_WORD, WordCategory.INSULT),
|
|
193
|
+
("scam", WordMatchMode.ANYWHERE, WordCategory.INSULT),
|
|
194
|
+
]
|
|
195
|
+
assert ProfanityFilter(words).contains_profanity("scammers everywhere")
|
|
196
|
+
|
|
197
|
+
with tempfile.TemporaryDirectory() as folder:
|
|
198
|
+
path = Path(folder) / "words.txt"
|
|
199
|
+
path.write_text(text, encoding="utf-8")
|
|
200
|
+
assert WordList.load(path) == words
|
|
201
|
+
with path.open("rb") as file:
|
|
202
|
+
assert WordList.load(file) == words # an open file is read, and left open
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
A heading such as `[insult]` names a category in any letter case. An unknown heading raises a
|
|
206
|
+
`WordListFormatError`, a `ValueError` with the heading's `line`. A UTF-8 byte order mark is ignored.
|
|
207
|
+
|
|
208
|
+
### Options
|
|
209
|
+
|
|
210
|
+
Every evasion the filter reads through can be turned off:
|
|
211
|
+
|
|
212
|
+
```python
|
|
213
|
+
from persian_text_guard import ProfanityFilter, ProfanityFilterOptions, WordList
|
|
214
|
+
|
|
215
|
+
literal = ProfanityFilter(WordList.persian_default(), ProfanityFilterOptions(join_spaced_letters=False))
|
|
216
|
+
|
|
217
|
+
assert not literal.contains_profanity("f u c k")
|
|
218
|
+
assert literal.contains_profanity("fuuuck")
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
### Normalization
|
|
222
|
+
|
|
223
|
+
```python
|
|
224
|
+
from persian_text_guard import NormalizationStep, normalize, to_ascii_digits, to_persian_digits, tokenize
|
|
225
|
+
|
|
226
|
+
text = "كتاب\N{ZERO WIDTH NON-JOINER}هاي ۱۲ ABC"
|
|
227
|
+
|
|
228
|
+
# "comparison" (the default): everything folded, for searching and matching.
|
|
229
|
+
assert normalize(text) == "کتابهای 12 abc"
|
|
230
|
+
# "standard": safe to store and show; keeps the zero-width non-joiner, Persian digits and case.
|
|
231
|
+
assert normalize(text, "standard") == "کتاب\N{ZERO WIDTH NON-JOINER}های ۱۲ ABC"
|
|
232
|
+
# Or any combination of steps, as members or names.
|
|
233
|
+
assert normalize("ABC ي", [NormalizationStep.LOWER_CASE, "unifyLetters"]) == "abc ی"
|
|
234
|
+
|
|
235
|
+
assert tokenize("سلام، دنیا! خوبی؟") == ["سلام", "دنیا", "خوبی"]
|
|
236
|
+
assert to_persian_digits("2 ساعت پیش") == "۲ ساعت پیش"
|
|
237
|
+
assert to_ascii_digits("۱۴۰۴/۰۵/۱۴") == "1404/05/14"
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
## Thread safety
|
|
241
|
+
|
|
242
|
+
A filter is immutable after it is built, and the bundled lists are parsed once, under a lock. Build one
|
|
243
|
+
filter and share it between threads, including on free-threaded CPython 3.14 with the GIL disabled:
|
|
244
|
+
|
|
245
|
+
```python
|
|
246
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
247
|
+
|
|
248
|
+
from persian_text_guard import ProfanityFilter, WordList
|
|
249
|
+
|
|
250
|
+
filter = ProfanityFilter(WordList.persian_default())
|
|
251
|
+
messages = ["سلام", "ک.ی.ر", "f u c k", "hello"] * 50
|
|
252
|
+
|
|
253
|
+
with ThreadPoolExecutor(max_workers=8) as pool:
|
|
254
|
+
results = list(pool.map(filter.contains_profanity, messages))
|
|
255
|
+
|
|
256
|
+
assert results == [filter.contains_profanity(message) for message in messages]
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
## Validating input
|
|
260
|
+
|
|
261
|
+
Checking, finding matches, censoring, normalizing and tokenizing accept any `str`, including empty
|
|
262
|
+
text, broken emoji, lone surrogates and very long messages, and treat `None` as an empty message. They
|
|
263
|
+
never raise for text.
|
|
264
|
+
|
|
265
|
+
Anything else, such as `bytes`, a number or a list, raises `TypeError`, so a missing check shows up at
|
|
266
|
+
once instead of an unchecked value passing as clean. Values from a request are untrusted, so check that
|
|
267
|
+
the message is a string first:
|
|
268
|
+
|
|
269
|
+
```python
|
|
270
|
+
import json
|
|
271
|
+
|
|
272
|
+
from persian_text_guard import ProfanityFilter, WordList
|
|
273
|
+
|
|
274
|
+
filter = ProfanityFilter(WordList.persian_default())
|
|
275
|
+
body = json.loads('{"message": ["kir"]}')
|
|
276
|
+
|
|
277
|
+
message = body.get("message")
|
|
278
|
+
if not isinstance(message, str):
|
|
279
|
+
outcome = "rejected: message must be text"
|
|
280
|
+
else:
|
|
281
|
+
outcome = filter.censor(message)
|
|
282
|
+
assert outcome == "rejected: message must be text"
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
| Error | Raised by | When |
|
|
286
|
+
| --- | --- | --- |
|
|
287
|
+
| `TypeError` | every text function, `censor`, `WordList.parse`, the constructors | A value that is not a `str` (or `None`, where `None` is allowed), an option that is not a `bool`, or an entry that is not a `BannedWord` |
|
|
288
|
+
| `ValueError` | `censor`, enum parameters, `normalize` | An invalid mask, or an unknown category, mode, step or preset name |
|
|
289
|
+
| `WordListFormatError` (a `ValueError`) | `WordList.parse`, `WordList.load` | An unknown section heading; `.line` is 1-based |
|
|
290
|
+
| `OSError`, `UnicodeDecodeError` | `WordList.load` | The file cannot be read, or is not UTF-8 |
|
|
291
|
+
|
|
292
|
+
## .NET, JavaScript and Python
|
|
293
|
+
|
|
294
|
+
| .NET | JavaScript/TypeScript | Python |
|
|
295
|
+
| --- | --- | --- |
|
|
296
|
+
| `new ProfanityFilter(words, options)` | `new ProfanityFilter(words, options)` | `ProfanityFilter(words, options)` |
|
|
297
|
+
| `filter.Count` | `filter.count` | `filter.count` |
|
|
298
|
+
| `filter.ContainsProfanity(text)` | `filter.containsProfanity(text)` | `filter.contains_profanity(text)` |
|
|
299
|
+
| `filter.FindMatch(text)` | `filter.findMatch(text)` | `filter.find_match(text)` (`None` when there is no match) |
|
|
300
|
+
| `filter.FindMatches(text)` | `filter.findMatches(text)` | `filter.find_matches(text)` (a tuple) |
|
|
301
|
+
| `filter.Censor(text, '#')` | `filter.censor(text, '#')` | `filter.censor(text, "#")` |
|
|
302
|
+
| `new BannedWord("x", WordMatchMode.Anywhere) { Category = WordCategory.Slur }` | `{ text: 'x', mode: 'anywhere', category: 'slur' }` | `BannedWord("x", WordMatchMode.ANYWHERE, WordCategory.SLUR)` |
|
|
303
|
+
| `ProfanityFilterOptions { SqueezeRepeatedLetters = false }` | `{ squeezeRepeatedLetters: false }` | `ProfanityFilterOptions(squeeze_repeated_letters=False)` |
|
|
304
|
+
| `match.Word`, `match.Evasion`, `match.Index`, `match.Length` | `match.word`, `.evasion`, `.index`, `.length` | `match.word`, `.evasion`, `.index`, `.length` (code points) |
|
|
305
|
+
| `EvasionKind.LookalikeCharacters \| EvasionKind.RepeatedLetters` | `['repeatedLetters', 'lookalikeCharacters']` | `(EvasionKind.REPEATED_LETTERS, EvasionKind.LOOKALIKE_CHARACTERS)` |
|
|
306
|
+
| `WordList.All`, `WordList.PersianDefault` | `WordList.all`, `WordList.persianDefault` | `WordList.all()`, `WordList.persian_default()` |
|
|
307
|
+
| `WordList.Bundled(WordCategory.Slur)` | `WordList.bundled('slur')` | `WordList.bundled(WordCategory.SLUR)` or `WordList.bundled("slur")` |
|
|
308
|
+
| `WordList.Parse(text)` | `WordList.parse(text)` | `WordList.parse(text)` |
|
|
309
|
+
| `WordList.Load(stream)` | read the file, then `WordList.parse(text)` | `WordList.load(path_or_file)` |
|
|
310
|
+
| `PersianNormalizer.Normalize(text, PersianNormalization.Standard)` | `normalize(text, 'standard')` | `normalize(text, "standard")` |
|
|
311
|
+
| `PersianNormalizer.Tokenize(text)` | `tokenize(text)` | `tokenize(text)` |
|
|
312
|
+
| `PersianNormalizer.ToPersianDigits(text)` | `toPersianDigits(text)` | `to_persian_digits(text)` |
|
|
313
|
+
| `FormatException` | `WordListFormatError` (`.line`) | `WordListFormatError` (`.line`), a `ValueError` |
|
|
314
|
+
| `ArgumentException` (mask) | `RangeError` | `ValueError` |
|
|
315
|
+
|
|
316
|
+
## Performance
|
|
317
|
+
|
|
318
|
+
Against `WordList.persian_default()` (about 1,000 entries), on CPython 3.14.6 (Intel Core i7-9700K,
|
|
319
|
+
pyperf with `--rigorous`):
|
|
320
|
+
|
|
321
|
+
| Operation | Mean | Operations/s |
|
|
322
|
+
| --- | ---: | ---: |
|
|
323
|
+
| Short clean message (5 words) | 91.4 µs | 10,937 |
|
|
324
|
+
| Long clean message (60 words) | 909 µs | 1,100 |
|
|
325
|
+
| Message with evasions | 50.4 µs | 19,850 |
|
|
326
|
+
| Normalize a long message | 35.1 µs | 28,454 |
|
|
327
|
+
| Build a filter from the bundled list | 13.3 ms | 75 |
|
|
328
|
+
| `find_matches`, clean short message | 90.4 µs | 11,059 |
|
|
329
|
+
| `find_matches`, message with three banned words | 240 µs | 4,169 |
|
|
330
|
+
| `censor`, short message with one banned word | 118 µs | 8,461 |
|
|
331
|
+
| `censor`, 60-word message with three banned words | 3.2 ms | 311 |
|
|
332
|
+
| A 132,000-character message | 210 ms | 5 |
|
|
333
|
+
|
|
334
|
+
Build the filter once and reuse it. The messages are the same as the .NET and JavaScript benchmarks';
|
|
335
|
+
run them with `uv run python bench/bench_filter.py -o .bench/results.json` in `python/`, and print the
|
|
336
|
+
table with `uv run python scripts/bench_table.py .bench/results.json`.
|
|
337
|
+
|
|
338
|
+
## Limitations
|
|
339
|
+
|
|
340
|
+
- **Unicode versions.** The filter uses the running Python's own Unicode data (`unicodedata`),
|
|
341
|
+
through a layer that reproduces .NET's rules for whitespace, case and categories. CPython 3.14 and
|
|
342
|
+
.NET 10 agree on the category of every code point. Older versions differ only on characters assigned
|
|
343
|
+
in Unicode 15 or later, and Python 3.11 also does not fold the 62 Cyrillic modifier letters
|
|
344
|
+
U+1E030–U+1E06D in compatibility normalization. The conformance corpus contains none of these
|
|
345
|
+
characters; if a difference matters to you, please open an issue.
|
|
346
|
+
- **Values that are not text.** `bytes`, numbers and other objects raise `TypeError` instead of being
|
|
347
|
+
read as an empty or converted message; see [Validating input](#validating-input).
|
|
348
|
+
- **Interpreters.** CPython 3.11 to 3.14, including free-threaded 3.14, is tested. Other interpreters,
|
|
349
|
+
such as PyPy, may work but are not tested.
|
|
350
|
+
- The evasions the filter reads through, and what it deliberately does not catch, are listed in the
|
|
351
|
+
[project README](https://github.com/AmirehsanK/PersianTextGuard#what-it-reads-through).
|
|
352
|
+
|
|
353
|
+
## Links
|
|
354
|
+
|
|
355
|
+
- [Project README](https://github.com/AmirehsanK/PersianTextGuard#readme)
|
|
356
|
+
- [Conformance corpus](https://github.com/AmirehsanK/PersianTextGuard/tree/main/conformance)
|
|
357
|
+
- [Word lists](https://github.com/AmirehsanK/PersianTextGuard/tree/main/wordlists)
|
|
358
|
+
- [.NET package on NuGet](https://www.nuget.org/packages/PersianTextGuard) and
|
|
359
|
+
[JavaScript package on npm](https://www.npmjs.com/package/persian-text-guard)
|
|
360
|
+
- [MIT licence](https://github.com/AmirehsanK/PersianTextGuard/blob/main/LICENSE)
|
|
361
|
+
|
|
362
|
+
---
|
|
363
|
+
|
|
364
|
+
<div dir="rtl">
|
|
365
|
+
|
|
366
|
+
## فارسی
|
|
367
|
+
|
|
368
|
+
</div>
|
|
369
|
+
|
|
370
|
+
<div dir="rtl">
|
|
371
|
+
|
|
372
|
+
این بسته نسخهٔ پایتونِ پروژهٔ PersianTextGuard است: یکسانسازی متن فارسی و فیلتر کلمات نامناسب که ترفندهای دور زدن فهرست کلمات را میشناسد، مثل «ک.ی.ر»، حروف فاصلهدار، حروف عربی بهجای فارسی و نویسههای نامرئی، و در عین حال پیامهای عادی مثل «هر کس» و «تخم مرغ» را رد نمیکند.
|
|
373
|
+
|
|
374
|
+
</div>
|
|
375
|
+
|
|
376
|
+
<div dir="rtl">
|
|
377
|
+
|
|
378
|
+
برای هر پیام همان پاسخی را میدهد که بستههای داتنت و جاوااسکریپت میدهند، و جایگاهها را مثل خودِ پایتون میشمارد.
|
|
379
|
+
|
|
380
|
+
</div>
|
|
381
|
+
|
|
382
|
+
<div dir="rtl">
|
|
383
|
+
|
|
384
|
+
### نصب
|
|
385
|
+
|
|
386
|
+
</div>
|
|
387
|
+
|
|
388
|
+
```bash
|
|
389
|
+
pip install persian-text-guard
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
<div dir="rtl">
|
|
393
|
+
|
|
394
|
+
### شروع سریع
|
|
395
|
+
|
|
396
|
+
</div>
|
|
397
|
+
|
|
398
|
+
<div dir="rtl">
|
|
399
|
+
|
|
400
|
+
فیلتر را یک بار بسازید و همهجا از همان استفاده کنید؛ فیلتر بعد از ساخته شدن هرگز تغییر نمیکند و استفادهٔ همزمان از چند نخ هم امن است.
|
|
401
|
+
|
|
402
|
+
</div>
|
|
403
|
+
|
|
404
|
+
```python
|
|
405
|
+
from persian_text_guard import ProfanityFilter, WordList
|
|
406
|
+
|
|
407
|
+
# فیلتر را یک بار بسازید و به اشتراک بگذارید.
|
|
408
|
+
# Build once and share it: a filter never changes after it is built.
|
|
409
|
+
filter = ProfanityFilter(WordList.persian_default())
|
|
410
|
+
|
|
411
|
+
assert not filter.contains_profanity("سلام، سفارشم کی میرسه؟")
|
|
412
|
+
assert filter.contains_profanity("ک.ی.ر")
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
<div dir="rtl">
|
|
416
|
+
|
|
417
|
+
### سانسور
|
|
418
|
+
|
|
419
|
+
</div>
|
|
420
|
+
|
|
421
|
+
<div dir="rtl">
|
|
422
|
+
|
|
423
|
+
هر کلمهٔ نامناسب، با پسوندها و فاصلههایش، با چهار نویسهٔ ثابت پوشانده میشود و بقیهٔ متن دست نمیخورد.
|
|
424
|
+
|
|
425
|
+
</div>
|
|
426
|
+
|
|
427
|
+
```python
|
|
428
|
+
from persian_text_guard import ProfanityFilter, WordList
|
|
429
|
+
|
|
430
|
+
# کلمات نامناسب با چهار نویسه پوشانده میشوند.
|
|
431
|
+
# Every banned word is hidden behind a four-character mask.
|
|
432
|
+
filter = ProfanityFilter(WordList.persian_default())
|
|
433
|
+
|
|
434
|
+
assert filter.censor("جنده\N{ZERO WIDTH NON-JOINER}ها رو ببین") == "**** رو ببین"
|
|
435
|
+
assert filter.censor("this is kir", "#") == "this is ####"
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
<div dir="rtl">
|
|
439
|
+
|
|
440
|
+
### بررسی ورودی
|
|
441
|
+
|
|
442
|
+
</div>
|
|
443
|
+
|
|
444
|
+
<div dir="rtl">
|
|
445
|
+
|
|
446
|
+
هر رشتهای پذیرفته میشود و مقدار خالیِ پایتون مثل پیام خالی است، اما هر مقدار دیگری، مثل عدد یا بایت یا فهرست، خطای نوع میدهد. پیش از فیلتر کردنِ دادههای درخواست، بررسی کنید که پیام از نوع رشته باشد.
|
|
447
|
+
|
|
448
|
+
</div>
|
|
449
|
+
|
|
450
|
+
<div dir="rtl">
|
|
451
|
+
|
|
452
|
+
مستندات کامل، فهرست ترفندهایی که شناخته میشوند و محدودیتها در [راهنمای اصلی پروژه](https://github.com/AmirehsanK/PersianTextGuard#readme) آمده است.
|
|
453
|
+
|
|
454
|
+
</div>
|