md.py 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002
  1. from __future__ import annotations
  2. import sys
  3. from functools import lru_cache
  4. from logging import getLogger
  5. if sys.version_info >= (3, 8):
  6. from typing import final
  7. else:
  8. try:
  9. from typing_extensions import final
  10. except ImportError:
  11. def final(cls): # type: ignore[misc,no-untyped-def]
  12. return cls
  13. from .constant import (
  14. COMMON_CJK_CHARACTERS,
  15. COMMON_SAFE_ASCII_CHARACTERS,
  16. TRACE,
  17. UNICODE_SECONDARY_RANGE_KEYWORD,
  18. _ACCENTUATED,
  19. _ARABIC,
  20. _ARABIC_ISOLATED_FORM,
  21. _CJK,
  22. _HANGUL,
  23. _HIRAGANA,
  24. _KATAKANA,
  25. _LATIN,
  26. _THAI,
  27. )
  28. from .utils import (
  29. _character_flags,
  30. is_emoticon,
  31. is_punctuation,
  32. is_separator,
  33. is_symbol,
  34. remove_accent,
  35. unicode_range,
  36. )
  37. # Combined bitmask for CJK/Hangul/Katakana/Hiragana/Thai glyph detection.
  38. _GLYPH_MASK: int = _CJK | _HANGUL | _KATAKANA | _HIRAGANA | _THAI
  39. @final
  40. class CharInfo:
  41. """Pre-computed character properties shared across all detectors."""
  42. __slots__ = (
  43. "character",
  44. "printable",
  45. "alpha",
  46. "upper",
  47. "lower",
  48. "space",
  49. "digit",
  50. "is_ascii",
  51. "case_variable",
  52. "flags",
  53. "accentuated",
  54. "latin",
  55. "is_cjk",
  56. "is_arabic",
  57. "is_glyph",
  58. "punct",
  59. "sym",
  60. "range",
  61. "sep",
  62. "emoticon",
  63. "safe",
  64. "common_cjk",
  65. )
  66. character: str
  67. printable: bool
  68. alpha: bool
  69. upper: bool
  70. lower: bool
  71. space: bool
  72. digit: bool
  73. is_ascii: bool
  74. case_variable: bool
  75. flags: int
  76. accentuated: bool
  77. latin: bool
  78. is_cjk: bool
  79. is_arabic: bool
  80. is_glyph: bool
  81. punct: bool
  82. sym: bool
  83. range: str | None
  84. sep: bool
  85. emoticon: bool
  86. safe: bool
  87. common_cjk: bool
  88. def __init__(self, character: str) -> None:
  89. """Compute all properties for *character* (built once per codepoint,
  90. every branch assigns every slot)."""
  91. self.character = character
  92. # ASCII fast-path: for characters with ord < 128, we can skip
  93. # _character_flags() entirely and derive most properties from ord.
  94. o: int = ord(character)
  95. if o < 128:
  96. self.is_ascii = True
  97. self.accentuated = False
  98. self.is_cjk = False
  99. self.is_arabic = False
  100. self.is_glyph = False
  101. # ASCII alpha: a-z (97-122) or A-Z (65-90)
  102. if 65 <= o <= 90:
  103. # Uppercase ASCII letter
  104. self.alpha = True
  105. self.upper = True
  106. self.lower = False
  107. self.space = False
  108. self.digit = False
  109. self.printable = True
  110. self.case_variable = True
  111. self.flags = _LATIN
  112. self.latin = True
  113. self.punct = False
  114. self.sym = False
  115. elif 97 <= o <= 122:
  116. # Lowercase ASCII letter
  117. self.alpha = True
  118. self.upper = False
  119. self.lower = True
  120. self.space = False
  121. self.digit = False
  122. self.printable = True
  123. self.case_variable = True
  124. self.flags = _LATIN
  125. self.latin = True
  126. self.punct = False
  127. self.sym = False
  128. elif 48 <= o <= 57:
  129. # ASCII digit 0-9
  130. self.alpha = False
  131. self.upper = False
  132. self.lower = False
  133. self.space = False
  134. self.digit = True
  135. self.printable = True
  136. self.case_variable = False
  137. self.flags = 0
  138. self.latin = False
  139. self.punct = False
  140. self.sym = False
  141. elif o == 32 or (9 <= o <= 13):
  142. # Space, tab, newline, etc.
  143. self.alpha = False
  144. self.upper = False
  145. self.lower = False
  146. self.space = True
  147. self.digit = False
  148. self.printable = o == 32
  149. self.case_variable = False
  150. self.flags = 0
  151. self.latin = False
  152. self.punct = False
  153. self.sym = False
  154. else:
  155. # Other ASCII (punctuation, symbols, control chars)
  156. self.printable = character.isprintable()
  157. self.alpha = False
  158. self.upper = False
  159. self.lower = False
  160. self.space = False
  161. self.digit = False
  162. self.case_variable = False
  163. self.flags = 0
  164. self.latin = False
  165. self.punct = is_punctuation(character) if self.printable else False
  166. self.sym = is_symbol(character) if self.printable else False
  167. else:
  168. # Non-ASCII path
  169. self.is_ascii = False
  170. self.printable = character.isprintable()
  171. self.alpha = character.isalpha()
  172. self.upper = character.isupper()
  173. self.lower = character.islower()
  174. self.space = character.isspace()
  175. self.digit = character.isdigit()
  176. self.case_variable = self.lower != self.upper
  177. # Flag-based classification (single unicodedata.name() call, lru-cached)
  178. flags: int
  179. if self.alpha:
  180. flags = _character_flags(character)
  181. else:
  182. flags = 0
  183. self.flags = flags
  184. self.accentuated = bool(flags & _ACCENTUATED)
  185. self.latin = bool(flags & _LATIN)
  186. self.is_cjk = bool(flags & _CJK)
  187. self.is_arabic = bool(flags & _ARABIC)
  188. self.is_glyph = bool(flags & _GLYPH_MASK)
  189. # Eagerly compute punct and sym (avoids property dispatch overhead
  190. # on 300K+ accesses in the hot loop).
  191. self.punct = is_punctuation(character) if self.printable else False
  192. self.sym = is_symbol(character) if self.printable else False
  193. self.range = unicode_range(character)
  194. self.sep = is_separator(character)
  195. self.emoticon = is_emoticon(character)
  196. self.safe = character in COMMON_SAFE_ASCII_CHARACTERS
  197. self.common_cjk = character in COMMON_CJK_CHARACTERS
  198. # Per-codepoint cache of CharInfo instances
  199. # At most UTF-8 size allocated.
  200. @lru_cache(maxsize=None)
  201. def _char_info(character: str) -> CharInfo:
  202. """Build (once per codepoint) and cache the CharInfo for *character*."""
  203. return CharInfo(character)
  204. # ASCII table indexed by codepoint.
  205. _ASCII_CHAR_INFO: list[CharInfo] = [
  206. CharInfo(chr(_codepoint)) for _codepoint in range(128)
  207. ]
  208. class MessDetectorPlugin:
  209. """
  210. Base abstract class used for mess detection plugins.
  211. All detectors MUST extend and implement given methods.
  212. """
  213. __slots__ = ()
  214. def feed_info(self, character: str, info: CharInfo) -> None:
  215. """
  216. The main routine to be executed upon character.
  217. Insert the logic in witch the text would be considered chaotic.
  218. """
  219. raise NotImplementedError # Defensive:
  220. def reset(self) -> None: # Defensive:
  221. """
  222. Permit to reset the plugin to the initial state.
  223. """
  224. raise NotImplementedError
  225. @property
  226. def ratio(self) -> float:
  227. """
  228. Compute the chaos ratio based on what your feed() has seen.
  229. Must NOT be lower than 0.; No restriction gt 0.
  230. """
  231. raise NotImplementedError # Defensive:
  232. @final
  233. class TooManySymbolOrPunctuationPlugin(MessDetectorPlugin):
  234. __slots__ = (
  235. "_punctuation_count",
  236. "_symbol_count",
  237. "_character_count",
  238. "_last_printable_char",
  239. "_frenzy_symbol_in_word",
  240. )
  241. def __init__(self) -> None:
  242. self._punctuation_count: int = 0
  243. self._symbol_count: int = 0
  244. self._character_count: int = 0
  245. self._last_printable_char: str | None = None
  246. self._frenzy_symbol_in_word: bool = False
  247. def feed_info(self, character: str, info: CharInfo) -> None:
  248. """Optimized feed using pre-computed character info."""
  249. self._character_count += 1
  250. if character != self._last_printable_char and not info.safe:
  251. if info.punct:
  252. self._punctuation_count += 1
  253. elif not info.digit and info.sym and not info.emoticon:
  254. self._symbol_count += 2
  255. self._last_printable_char = character
  256. def reset(self) -> None: # Abstract
  257. self._punctuation_count = 0
  258. self._character_count = 0
  259. self._symbol_count = 0
  260. @property
  261. def ratio(self) -> float:
  262. if self._character_count == 0:
  263. return 0.0
  264. ratio_of_punctuation: float = (
  265. self._punctuation_count + self._symbol_count
  266. ) / self._character_count
  267. return ratio_of_punctuation if ratio_of_punctuation >= 0.3 else 0.0
  268. @final
  269. class TooManyAccentuatedPlugin(MessDetectorPlugin):
  270. __slots__ = ("_character_count", "_accentuated_count")
  271. def __init__(self) -> None:
  272. self._character_count: int = 0
  273. self._accentuated_count: int = 0
  274. def feed_info(self, character: str, info: CharInfo) -> None:
  275. """Optimized feed using pre-computed character info."""
  276. self._character_count += 1
  277. if info.accentuated:
  278. self._accentuated_count += 1
  279. def reset(self) -> None: # Abstract
  280. self._character_count = 0
  281. self._accentuated_count = 0
  282. @property
  283. def ratio(self) -> float:
  284. if self._character_count < 8:
  285. return 0.0
  286. ratio_of_accentuation: float = self._accentuated_count / self._character_count
  287. return ratio_of_accentuation if ratio_of_accentuation >= 0.35 else 0.0
  288. @final
  289. class UnprintablePlugin(MessDetectorPlugin):
  290. __slots__ = ("_unprintable_count", "_character_count")
  291. def __init__(self) -> None:
  292. self._unprintable_count: int = 0
  293. self._character_count: int = 0
  294. def feed_info(self, character: str, info: CharInfo) -> None:
  295. """Optimized feed using pre-computed character info."""
  296. if (
  297. not info.space
  298. and not info.printable
  299. and character != "\x1a"
  300. and character != "\ufeff"
  301. ):
  302. self._unprintable_count += 1
  303. self._character_count += 1
  304. def reset(self) -> None: # Abstract
  305. self._unprintable_count = 0
  306. @property
  307. def ratio(self) -> float:
  308. if self._character_count == 0: # Defensive:
  309. return 0.0
  310. return (self._unprintable_count * 8) / self._character_count
  311. @final
  312. class SuspiciousDuplicateAccentPlugin(MessDetectorPlugin):
  313. __slots__ = (
  314. "_successive_count",
  315. "_character_count",
  316. "_last_latin_character",
  317. "_last_was_accentuated",
  318. )
  319. def __init__(self) -> None:
  320. self._successive_count: int = 0
  321. self._character_count: int = 0
  322. self._last_latin_character: str | None = None
  323. self._last_was_accentuated: bool = False
  324. def feed_info(self, character: str, info: CharInfo) -> None:
  325. """Optimized feed using pre-computed character info."""
  326. self._character_count += 1
  327. if (
  328. self._last_latin_character is not None
  329. and info.accentuated
  330. and self._last_was_accentuated
  331. ):
  332. if info.upper and self._last_latin_character.isupper():
  333. self._successive_count += 1
  334. if remove_accent(character) == remove_accent(self._last_latin_character):
  335. self._successive_count += 1
  336. self._last_latin_character = character
  337. self._last_was_accentuated = info.accentuated
  338. def reset(self) -> None: # Abstract
  339. self._successive_count = 0
  340. self._character_count = 0
  341. self._last_latin_character = None
  342. self._last_was_accentuated = False
  343. @property
  344. def ratio(self) -> float:
  345. if self._character_count == 0:
  346. return 0.0
  347. return (self._successive_count * 2) / self._character_count
  348. @final
  349. class SuspiciousRange(MessDetectorPlugin):
  350. __slots__ = (
  351. "_suspicious_successive_range_count",
  352. "_character_count",
  353. "_last_printable_seen",
  354. "_last_printable_range",
  355. )
  356. def __init__(self) -> None:
  357. self._suspicious_successive_range_count: int = 0
  358. self._character_count: int = 0
  359. self._last_printable_seen: str | None = None
  360. self._last_printable_range: str | None = None
  361. def feed_info(self, character: str, info: CharInfo) -> None:
  362. """Optimized feed using pre-computed character info."""
  363. self._character_count += 1
  364. if info.space or info.punct or info.safe:
  365. self._last_printable_seen = None
  366. self._last_printable_range = None
  367. return
  368. if self._last_printable_seen is None:
  369. self._last_printable_seen = character
  370. self._last_printable_range = info.range
  371. return
  372. unicode_range_a: str | None = self._last_printable_range
  373. unicode_range_b: str | None = info.range
  374. # Identical non-None ranges can never be suspicious.
  375. if unicode_range_a != unicode_range_b or unicode_range_a is None:
  376. if is_suspiciously_successive_range(unicode_range_a, unicode_range_b):
  377. self._suspicious_successive_range_count += 1
  378. self._last_printable_seen = character
  379. self._last_printable_range = unicode_range_b
  380. def reset(self) -> None: # Abstract
  381. self._character_count = 0
  382. self._suspicious_successive_range_count = 0
  383. self._last_printable_seen = None
  384. self._last_printable_range = None
  385. @property
  386. def ratio(self) -> float:
  387. if self._character_count <= 13:
  388. return 0.0
  389. ratio_of_suspicious_range_usage: float = (
  390. self._suspicious_successive_range_count * 2
  391. ) / self._character_count
  392. return ratio_of_suspicious_range_usage
  393. @final
  394. class SuperWeirdWordPlugin(MessDetectorPlugin):
  395. __slots__ = (
  396. "_word_count",
  397. "_bad_word_count",
  398. "_foreign_long_count",
  399. "_is_current_word_bad",
  400. "_foreign_long_watch",
  401. "_character_count",
  402. "_bad_character_count",
  403. "_buffer_length",
  404. "_buffer_last_char",
  405. "_buffer_last_char_accentuated",
  406. "_buffer_accent_count",
  407. "_buffer_glyph_count",
  408. "_buffer_upper_count",
  409. "_buffer_first_lower",
  410. "_buffer_has_non_ascii",
  411. )
  412. def __init__(self) -> None:
  413. self._word_count: int = 0
  414. self._bad_word_count: int = 0
  415. self._foreign_long_count: int = 0
  416. self._is_current_word_bad: bool = False
  417. self._foreign_long_watch: bool = False
  418. self._character_count: int = 0
  419. self._bad_character_count: int = 0
  420. self._buffer_length: int = 0
  421. self._buffer_last_char: str | None = None
  422. self._buffer_last_char_accentuated: bool = False
  423. self._buffer_accent_count: int = 0
  424. self._buffer_glyph_count: int = 0
  425. self._buffer_upper_count: int = 0
  426. self._buffer_first_lower: bool = False
  427. self._buffer_has_non_ascii: bool = False
  428. def feed_info(self, character: str, info: CharInfo) -> None:
  429. """Optimized feed using pre-computed character info."""
  430. if info.alpha:
  431. if self._buffer_length == 0:
  432. self._buffer_first_lower = info.lower
  433. self._buffer_length += 1
  434. self._buffer_last_char = character
  435. if info.upper:
  436. self._buffer_upper_count += 1
  437. if not info.is_ascii:
  438. self._buffer_has_non_ascii = True
  439. self._buffer_last_char_accentuated = info.accentuated
  440. if info.accentuated:
  441. self._buffer_accent_count += 1
  442. if (
  443. not self._foreign_long_watch
  444. and (not info.latin or info.accentuated)
  445. and not info.is_glyph
  446. ):
  447. self._foreign_long_watch = True
  448. if info.is_glyph:
  449. self._buffer_glyph_count += 1
  450. return
  451. if not self._buffer_length:
  452. return
  453. if info.space or info.punct or info.sep:
  454. self._word_count += 1
  455. buffer_length: int = self._buffer_length
  456. self._character_count += buffer_length
  457. if buffer_length >= 4:
  458. if self._buffer_accent_count / buffer_length >= 0.5:
  459. self._is_current_word_bad = True
  460. elif (
  461. self._buffer_last_char_accentuated
  462. and self._buffer_last_char.isupper() # type: ignore[union-attr]
  463. and self._buffer_upper_count != buffer_length
  464. ):
  465. self._foreign_long_count += 1
  466. self._is_current_word_bad = True
  467. elif self._buffer_glyph_count == 1:
  468. self._is_current_word_bad = True
  469. self._foreign_long_count += 1
  470. elif (
  471. self._buffer_has_non_ascii
  472. and self._buffer_first_lower
  473. and self._buffer_upper_count == buffer_length - 1
  474. ):
  475. # Inverse capitalization detector.
  476. # No natural writing produces such words.
  477. # see https://github.com/jawah/charset_normalizer/issues/731
  478. self._foreign_long_count += 1
  479. self._is_current_word_bad = True
  480. if buffer_length >= 24 and self._foreign_long_watch:
  481. probable_camel_cased: bool = (
  482. self._buffer_upper_count > 0
  483. and self._buffer_upper_count / buffer_length <= 0.3
  484. )
  485. if not probable_camel_cased:
  486. self._foreign_long_count += 1
  487. self._is_current_word_bad = True
  488. if self._is_current_word_bad:
  489. self._bad_word_count += 1
  490. self._bad_character_count += buffer_length
  491. self._is_current_word_bad = False
  492. self._foreign_long_watch = False
  493. self._buffer_length = 0
  494. self._buffer_last_char = None
  495. self._buffer_last_char_accentuated = False
  496. self._buffer_accent_count = 0
  497. self._buffer_glyph_count = 0
  498. self._buffer_upper_count = 0
  499. self._buffer_first_lower = False
  500. self._buffer_has_non_ascii = False
  501. elif (
  502. character not in {"<", ">", "-", "=", "~", "|", "_"}
  503. and not info.digit
  504. and info.sym
  505. ):
  506. self._is_current_word_bad = True
  507. self._buffer_length += 1
  508. self._buffer_last_char = character
  509. self._buffer_last_char_accentuated = False
  510. def reset(self) -> None: # Abstract
  511. self._buffer_length = 0
  512. self._buffer_last_char = None
  513. self._buffer_last_char_accentuated = False
  514. self._is_current_word_bad = False
  515. self._foreign_long_watch = False
  516. self._bad_word_count = 0
  517. self._word_count = 0
  518. self._character_count = 0
  519. self._bad_character_count = 0
  520. self._foreign_long_count = 0
  521. self._buffer_accent_count = 0
  522. self._buffer_glyph_count = 0
  523. self._buffer_upper_count = 0
  524. self._buffer_first_lower = False
  525. self._buffer_has_non_ascii = False
  526. @property
  527. def ratio(self) -> float:
  528. if self._word_count <= 10 and self._foreign_long_count == 0:
  529. return 0.0
  530. return self._bad_character_count / self._character_count
  531. @final
  532. class CjkUncommonPlugin(MessDetectorPlugin):
  533. """
  534. Detect messy CJK text that probably means nothing.
  535. """
  536. __slots__ = ("_character_count", "_uncommon_count")
  537. def __init__(self) -> None:
  538. self._character_count: int = 0
  539. self._uncommon_count: int = 0
  540. def feed_info(self, character: str, info: CharInfo) -> None:
  541. """Optimized feed using pre-computed character info."""
  542. self._character_count += 1
  543. if not info.common_cjk:
  544. self._uncommon_count += 1
  545. def reset(self) -> None: # Abstract
  546. self._character_count = 0
  547. self._uncommon_count = 0
  548. @property
  549. def ratio(self) -> float:
  550. if self._character_count < 8:
  551. return 0.0
  552. uncommon_form_usage: float = self._uncommon_count / self._character_count
  553. # we can be pretty sure it's garbage when uncommon characters are widely
  554. # used. otherwise it could just be traditional chinese for example.
  555. return uncommon_form_usage / 10 if uncommon_form_usage > 0.5 else 0.0
  556. @final
  557. class ArchaicUpperLowerPlugin(MessDetectorPlugin):
  558. __slots__ = (
  559. "_buf",
  560. "_character_count_since_last_sep",
  561. "_successive_upper_lower_count",
  562. "_successive_upper_lower_count_final",
  563. "_character_count",
  564. "_last_alpha_seen",
  565. "_last_alpha_seen_upper",
  566. "_last_alpha_seen_lower",
  567. "_current_ascii_only",
  568. )
  569. def __init__(self) -> None:
  570. self._buf: bool = False
  571. self._character_count_since_last_sep: int = 0
  572. self._successive_upper_lower_count: int = 0
  573. self._successive_upper_lower_count_final: int = 0
  574. self._character_count: int = 0
  575. self._last_alpha_seen: str | None = None
  576. self._last_alpha_seen_upper: bool = False
  577. self._last_alpha_seen_lower: bool = False
  578. self._current_ascii_only: bool = True
  579. def feed_info(self, character: str, info: CharInfo) -> None:
  580. """Optimized feed using pre-computed character info."""
  581. is_concerned: bool = info.alpha and info.case_variable
  582. chunk_sep: bool = not is_concerned
  583. if chunk_sep and self._character_count_since_last_sep > 0:
  584. if (
  585. self._character_count_since_last_sep <= 64
  586. and not info.digit
  587. and not self._current_ascii_only
  588. ):
  589. self._successive_upper_lower_count_final += (
  590. self._successive_upper_lower_count
  591. )
  592. self._successive_upper_lower_count = 0
  593. self._character_count_since_last_sep = 0
  594. self._last_alpha_seen = None
  595. self._buf = False
  596. self._character_count += 1
  597. self._current_ascii_only = True
  598. return
  599. if self._current_ascii_only and not info.is_ascii:
  600. self._current_ascii_only = False
  601. if self._last_alpha_seen is not None:
  602. if (info.upper and self._last_alpha_seen_lower) or (
  603. info.lower and self._last_alpha_seen_upper
  604. ):
  605. if self._buf:
  606. self._successive_upper_lower_count += 2
  607. self._buf = False
  608. else:
  609. self._buf = True
  610. else:
  611. self._buf = False
  612. self._character_count += 1
  613. self._character_count_since_last_sep += 1
  614. self._last_alpha_seen = character
  615. self._last_alpha_seen_upper = info.upper
  616. self._last_alpha_seen_lower = info.lower
  617. def reset(self) -> None: # Abstract
  618. self._character_count = 0
  619. self._character_count_since_last_sep = 0
  620. self._successive_upper_lower_count = 0
  621. self._successive_upper_lower_count_final = 0
  622. self._last_alpha_seen = None
  623. self._last_alpha_seen_upper = False
  624. self._last_alpha_seen_lower = False
  625. self._buf = False
  626. self._current_ascii_only = True
  627. @property
  628. def ratio(self) -> float:
  629. if self._character_count == 0: # Defensive:
  630. return 0.0
  631. return self._successive_upper_lower_count_final / self._character_count
  632. @final
  633. class ArabicIsolatedFormPlugin(MessDetectorPlugin):
  634. __slots__ = ("_character_count", "_isolated_form_count")
  635. def __init__(self) -> None:
  636. self._character_count: int = 0
  637. self._isolated_form_count: int = 0
  638. def reset(self) -> None: # Abstract
  639. self._character_count = 0
  640. self._isolated_form_count = 0
  641. def feed_info(self, character: str, info: CharInfo) -> None:
  642. """Optimized feed using pre-computed character info."""
  643. self._character_count += 1
  644. if info.flags & _ARABIC_ISOLATED_FORM:
  645. self._isolated_form_count += 1
  646. @property
  647. def ratio(self) -> float:
  648. if self._character_count < 8:
  649. return 0.0
  650. isolated_form_usage: float = self._isolated_form_count / self._character_count
  651. return isolated_form_usage
  652. @lru_cache(maxsize=1024)
  653. def is_suspiciously_successive_range(
  654. unicode_range_a: str | None, unicode_range_b: str | None
  655. ) -> bool:
  656. """
  657. Determine if two Unicode range seen next to each other can be considered as suspicious.
  658. """
  659. if unicode_range_a is None or unicode_range_b is None:
  660. return True
  661. if unicode_range_a == unicode_range_b:
  662. return False
  663. if "Latin" in unicode_range_a and "Latin" in unicode_range_b:
  664. return False
  665. if "Emoticons" in unicode_range_a or "Emoticons" in unicode_range_b:
  666. return False
  667. # Latin characters can be accompanied with a combining diacritical mark
  668. # eg. Vietnamese.
  669. if ("Latin" in unicode_range_a or "Latin" in unicode_range_b) and (
  670. "Combining" in unicode_range_a or "Combining" in unicode_range_b
  671. ):
  672. return False
  673. keywords_range_a, keywords_range_b = (
  674. unicode_range_a.split(" "),
  675. unicode_range_b.split(" "),
  676. )
  677. for el in keywords_range_a:
  678. if el in UNICODE_SECONDARY_RANGE_KEYWORD:
  679. continue
  680. if el in keywords_range_b:
  681. return False
  682. # Japanese Exception
  683. range_a_jp_chars, range_b_jp_chars = (
  684. unicode_range_a
  685. in (
  686. "Hiragana",
  687. "Katakana",
  688. ),
  689. unicode_range_b in ("Hiragana", "Katakana"),
  690. )
  691. if (range_a_jp_chars or range_b_jp_chars) and (
  692. "CJK" in unicode_range_a or "CJK" in unicode_range_b
  693. ):
  694. return False
  695. if range_a_jp_chars and range_b_jp_chars:
  696. return False
  697. if "Hangul" in unicode_range_a or "Hangul" in unicode_range_b:
  698. if "CJK" in unicode_range_a or "CJK" in unicode_range_b:
  699. return False
  700. if unicode_range_a == "Basic Latin" or unicode_range_b == "Basic Latin":
  701. return False
  702. # Chinese/Japanese use dedicated range for punctuation and/or separators.
  703. if ("CJK" in unicode_range_a or "CJK" in unicode_range_b) or (
  704. unicode_range_a in ["Katakana", "Hiragana"]
  705. and unicode_range_b in ["Katakana", "Hiragana"]
  706. ):
  707. if "Punctuation" in unicode_range_a or "Punctuation" in unicode_range_b:
  708. return False
  709. if "Forms" in unicode_range_a or "Forms" in unicode_range_b:
  710. return False
  711. if unicode_range_a == "Basic Latin" or unicode_range_b == "Basic Latin":
  712. return False
  713. return True
  714. def mess_ratio(
  715. decoded_sequence: str, maximum_threshold: float = 0.2, debug: bool = False
  716. ) -> float:
  717. """
  718. Compute a mess ratio given a decoded bytes sequence. The maximum threshold does stop the computation earlier.
  719. """
  720. seq_len: int = len(decoded_sequence)
  721. if seq_len < 511:
  722. step: int = 32
  723. elif seq_len < 1024:
  724. step = 64
  725. else:
  726. step = 128
  727. # str.isascii() is O(1) (the flag lives in the str header). Six of the
  728. # nine detectors provably keep a 0.0 ratio on ASCII-only input and are
  729. # therefore not fed at all.
  730. is_pure_ascii: bool = decoded_sequence.isascii()
  731. # Cached per-codepoint character properties (see CharInfo). ASCII
  732. # characters resolve through the immutable import-time table; anything
  733. # else goes through the lru_cache-backed slow path.
  734. ascii_info = _ASCII_CHAR_INFO
  735. char_info = _char_info
  736. mean_mess_ratio: float
  737. info: CharInfo
  738. # Create each detector as a named local variable (unrolled from the generic loop).
  739. # This eliminates per-character iteration over the detector list and
  740. # per-character eligible() virtual dispatch, while keeping every plugin class
  741. # intact and fully readable.
  742. d_sp: TooManySymbolOrPunctuationPlugin = TooManySymbolOrPunctuationPlugin()
  743. d_ta: TooManyAccentuatedPlugin = TooManyAccentuatedPlugin()
  744. d_up: UnprintablePlugin = UnprintablePlugin()
  745. d_sda: SuspiciousDuplicateAccentPlugin = SuspiciousDuplicateAccentPlugin()
  746. d_sr: SuspiciousRange = SuspiciousRange()
  747. d_sw: SuperWeirdWordPlugin = SuperWeirdWordPlugin()
  748. d_cu: CjkUncommonPlugin = CjkUncommonPlugin()
  749. d_au: ArchaicUpperLowerPlugin = ArchaicUpperLowerPlugin()
  750. d_ai: ArabicIsolatedFormPlugin = ArabicIsolatedFormPlugin()
  751. # Local references for feed_info methods called in the hot loop.
  752. d_sp_feed = d_sp.feed_info
  753. d_ta_feed = d_ta.feed_info
  754. d_up_feed = d_up.feed_info
  755. d_sda_feed = d_sda.feed_info
  756. d_sr_feed = d_sr.feed_info
  757. d_sw_feed = d_sw.feed_info
  758. d_cu_feed = d_cu.feed_info
  759. d_au_feed = d_au.feed_info
  760. d_ai_feed = d_ai.feed_info
  761. for block_start in range(0, seq_len, step):
  762. for character in decoded_sequence[block_start : block_start + step]:
  763. # Character properties computed once per distinct codepoint
  764. # (shared across all plugins and all mess_ratio calls).
  765. # ord() doubles as the ASCII table index and, unlike
  766. # str.isascii(), lowers to a mypyc primitive.
  767. codepoint: int = ord(character)
  768. if codepoint < 128:
  769. info = ascii_info[codepoint]
  770. else:
  771. info = char_info(character)
  772. # Detectors with eligible() == always True
  773. d_up_feed(character, info)
  774. d_sw_feed(character, info)
  775. if is_pure_ascii:
  776. # The six remaining detectors provably stay at 0.0 (see above).
  777. if info.printable:
  778. d_sp_feed(character, info)
  779. continue
  780. d_au_feed(character, info)
  781. # Detectors with eligible() == isprintable
  782. if info.printable:
  783. d_sp_feed(character, info)
  784. d_sr_feed(character, info)
  785. # Detectors with eligible() == isalpha
  786. if info.alpha:
  787. d_ta_feed(character, info)
  788. # SuspiciousDuplicateAccent: isalpha() and is_latin()
  789. if info.latin:
  790. d_sda_feed(character, info)
  791. # CjkUncommon: is_cjk()
  792. if info.is_cjk:
  793. d_cu_feed(character, info)
  794. # ArabicIsolatedForm: is_arabic()
  795. if info.is_arabic:
  796. d_ai_feed(character, info)
  797. mean_mess_ratio = (
  798. d_sp.ratio
  799. + d_ta.ratio
  800. + d_up.ratio
  801. + d_sda.ratio
  802. + d_sr.ratio
  803. + d_sw.ratio
  804. + d_cu.ratio
  805. + d_au.ratio
  806. + d_ai.ratio
  807. )
  808. if mean_mess_ratio >= maximum_threshold:
  809. break
  810. else:
  811. # Flush last word buffer in SuperWeirdWordPlugin via trailing newline.
  812. nl_info = ascii_info[10] # "\n"
  813. d_sw_feed("\n", nl_info)
  814. if not is_pure_ascii:
  815. d_au_feed("\n", nl_info)
  816. d_up_feed("\n", nl_info)
  817. mean_mess_ratio = (
  818. d_sp.ratio
  819. + d_ta.ratio
  820. + d_up.ratio
  821. + d_sda.ratio
  822. + d_sr.ratio
  823. + d_sw.ratio
  824. + d_cu.ratio
  825. + d_au.ratio
  826. + d_ai.ratio
  827. )
  828. if debug: # Defensive:
  829. logger = getLogger("charset_normalizer")
  830. logger.log(
  831. TRACE,
  832. "Mess-detector extended-analysis start. "
  833. f"intermediary_mean_mess_ratio_calc={step} mean_mess_ratio={mean_mess_ratio} "
  834. f"maximum_threshold={maximum_threshold}",
  835. )
  836. if seq_len > 16:
  837. logger.log(TRACE, f"Starting with: {decoded_sequence[:16]}")
  838. logger.log(TRACE, f"Ending with: {decoded_sequence[-16::]}")
  839. for dt in [d_sp, d_ta, d_up, d_sda, d_sr, d_sw, d_cu, d_au, d_ai]:
  840. logger.log(TRACE, f"{dt.__class__}: {dt.ratio}")
  841. return round(mean_mess_ratio, 3)