"""Regex PII scrub for Polish web/official text. Replaces emails, phones, PESEL/NIP/REGON/KRS, land-register (KW) numbers, electronic contact addresses and account numbers in place so sentence structure survives. Names of public officials are left untouched — that is intentional, not a gap. Phones map to [Telefon]; everything else to [PII]. Bare PESEL requires checksum and date validation; explicit identifier labels also redact damaged numbers (including common OCR O/I/l substitutions). NIP/REGON/KRS and identity documents require explicit nearby labels. KW numbers need a nearby KW/księga wieczysta label, or the full XX0X/00000000/0 form with a valid check digit. Passport variants include one-letter and diplomatic IDs. Foreign IBANs use country lengths and MOD-97. ePUAP paths require a label; e-Doreczenia uses its AE:PL form. Wrapped email domains, local parts hyphenated across one line break and small extraction gaps around @/hyphens are supported. A title-case alphabetic prefix of 5–11 letters is dropped from the redaction when the remainder is already a complete lowercase-local email and the same passage has at least two such glues. Explicit phone extensions and terminal suffix ranges are included; room numbers are not. Labelled numeric PINs (including URL pin= values) map to [PII] before phone detection. Contact/helpline headings cover consecutive descriptive phone-list entries; unrelated lines end the list. Bounded staff/address-directory evidence also covers formatted phone fields. Labelled full-number ranges retain shared prefixes. Phones require a nearby contact cue, a Polish +48/0048 prefix, or explicit international country/trunk notation such as +CC (0). With a cue, the EUR-Lex "(32-2) 299 11 11" country-area form counts as international. Strong labels also admit one-digit country codes and wider hyphenated area codes. Short service numbers need strong labels; 116xxx numbers also accept nearby telephone prose. Unlabelled domestic numbers are left for audit because table cells have the same shapes. Flattened tables can glue labels to both neighbours ("Mödlingtel.: … 38112faks:"); such glued labels count as labels and end the preceding number. Call after HTML-to-text, before the parquet is written. """ from __future__ import annotations import datetime as dt import re PHONE_TAG = "[Telefon]" PII_TAG = "[PII]" COUNTS = ("email", "phone", "pesel", "nip", "regon", "account", "document", "krs", "electronic_address", "pin", "land_register") # Mobile + geographic area codes (2-digit national prefix after trunk 0 / +48). _PL_PREFIX = { "12", "13", "14", "15", "16", "17", "18", "22", "23", "24", "25", "26", "29", "32", "33", "34", "39", "41", "42", "43", "44", "45", "46", "47", "48", "50", "51", "52", "53", "54", "55", "56", "57", "58", "59", "60", "61", "62", "63", "65", "66", "67", "68", "69", "70", "71", "72", "73", "74", "75", "76", "77", "78", "79", "80", "81", "82", "83", "84", "85", "86", "87", "88", "89", "91", "94", "95", } _EMAIL_DOMAIN = r"[^\W_](?:[^\W_]|-[ \t]{0,3}(?=[^\W_]))*" # PDF extraction can hyphenate a local part across one line break: "jan-\nna.k@example.com". # The fragment is capped at the 64-character local-part limit so long tokens stay linear. _EMAIL_START = r"\b(?:[\w.%+&-]{0,63}[^\W_]-[ \t]*\r?\n[ \t]*)?[\w.%+&-]+[ \t]{0,3}@" _EMAIL_RE = re.compile(_EMAIL_START + r"[ \t]{0,3}(?:" + _EMAIL_DOMAIN + r"\.)+[^\W\d_]{2,}\b") _EMAIL_WRAP_RE = re.compile(_EMAIL_START + r"[ \t]{0,3}(?:" + _EMAIL_DOMAIN + r"\.)+[ \t]*\r?\n[ \t]*(?:" + _EMAIL_DOMAIN + r"\.)*[^\W\d_]{2,}\b") # Damaged contact fields can retain only the local part and @. _EMAIL_FRAGMENT_RE = re.compile(_EMAIL_START + r"(?=[ \t]*\r?$)", re.M) _EMAIL_LABEL_RE = re.compile(r"\be[ -]?mail[ \t]*:[ \t]*\Z", re.I) _EDELIVERY_RE = re.compile(r"\bAE:PL-\d{5}-\d{5}-[A-Z0-9]{5}-\d{2}(?![\w-])", re.I) _EPUAP_RE = re.compile(r"(?\bPIN[ \t]*[:=][ \t]*)" r"(?P[\u202a-\u202e\u2066-\u2069]*\d(?:[ \t]?\d){3,15}#?)" r"(?!\w|[ \t]*\d)", re.I) # Horizontal Unicode spaces from HTML/PDF extraction; preserve paragraph breaks. _SPACES = str.maketrans({c: " " for c in "\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000"}) # Country lengths checked against Apache Commons Validator's IBAN registry table: # https://commons.apache.org/proper/commons-validator/xref/org/apache/commons/validator/routines/IBANValidator.html # ponytail: country/length/MOD-97 only; add national BBAN rules if false positives appear. _IBAN_LENGTHS = { country: length for length, countries in { 15: "NO", 16: "BE", 18: "DK FI AX FK FO GL NL SD", 19: "MK SI", 20: "AT BA EE KZ LT LU MN XK", 21: "CH HR LI LV", 22: "BG BH CR DE GB IM JE GG GE IE ME RS VA", 23: "AE GI IL IQ OM SO TL", 24: "AD CZ ES MD PK RO SA SE SK TN VG", 25: "LY PT ST", 26: "IS TR", 27: "BI DJ FR GF GP MQ RE PF TF YT NC BL MF PM WF GR IT MC MR SM", 28: "AL AZ BY CY DO GT HN HU LB NI PL SV", 29: "BR EG PS QA UA", 30: "JO KW MU YE", 31: "MT SC", 32: "LC", 33: "RU", }.items() for country in countries.split() } _IBAN_RE = re.compile( r"\b(?:" + "|".join( country + r"[ \t-]*[0-9]{2}(?:[ \t-]*[A-Z0-9]){" + str(length - 4) + "}" for country, length in _IBAN_LENGTHS.items() ) + r")\b", re.I, ) # Optional PL, then 26 digits with short space/tab/hyphen gaps (invoice style). _ACCOUNT_RE = re.compile(r"\b(?:PL[ \t-]*)?(?:\d[ \t-]*){25}\d\b", re.I) _REGON14_RE = re.compile(r"\b\d{14}\b") _PESEL_RE = re.compile(r"\b\d{11}\b") _NIP_DASH_RE = re.compile(r"\b(?:PL[ \t]*)?\d{3}(?:[- \t]\d{3}[- \t]\d{2}[- \t]\d{2}|[- \t]\d{2}[- \t]\d{2}[- \t]\d{3})\b", re.I) _NIP_RE = re.compile(r"\b(?:PL[ \t]*)?\d{10}\b", re.I) _DOCUMENT_RE = re.compile( r"\b(?P