md.py 34 KB

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