Dane o stałej strukturze, takie jak e-mail, telefon, PESEL i NIP, wykrywasz w Pythonie wyrażeniami regularnymi z modułu re, a PESEL i NIP dodatkowo sprawdzasz sumą kontrolną, bo sam regex zgłasza każdy ciąg o pasującej długości. Każde trafienie zapisz razem z jego pozycją w tekście (początek i koniec), żeby można je było potem bezpiecznie podmienić. Imion i nazwisk wyrażenia regularne nie wykryją.Zanim ukryjesz w tekście dane wrażliwe, musisz je znaleźć. To pozornie proste: napisz wyrażenie regularne na PESEL i gotowe. W praktyce pierwsza wersja łapie numery zamówień, kody paczek i identyfikatory, a przy tym przeoczy numery zapisane z myślnikami. W tej części zbudujemy detektor, który robi to rzetelniej, i przygotujemy jego wynik tak, żeby kolejne części cyklu mogły go użyć.
Ten tekst jest częścią cyklu o pseudonimizacji danych w Pythonie. Same wyrażenia regularne i sumy kontrolne wystarczą tu bez żadnych dodatkowych bibliotek, a gotowy detektor wpiszemy później jako węzeł grafu w LangGraph. Jeśli nie wiesz jeszcze, czym jest graf w programowaniu, możesz zacząć od tamtego tekstu. Tutaj używamy tylko biblioteki standardowej Pythona, czyli modułów re i dataclasses.
Dlaczego samo wyrażenie regularne nie wystarcza do wykrywania PESEL?
PESEL to 11 cyfr. Wyrażenie \d{11} znajdzie każdy ciąg 11 cyfr: prawdziwy PESEL, ale też numer zamówienia, numer telefonu z kodem kraju zapisany bez spacji czy identyfikator z systemu księgowego. Regex potrafi sprawdzić kształt tekstu, ale nie to, czy ciąg jest numerem, który ma sens.
Dlatego dla PESEL i NIP robimy dwa kroki:
| Krok | Narzędzie | Co sprawdza |
|---|---|---|
| 1. Kształt | Wyrażenie regularne | Czy ciąg ma właściwą liczbę cyfr i separatorów |
| 2. Sens | Suma kontrolna | Czy ostatnia cyfra zgadza się z pozostałymi |
Dla e-maila i telefonu zostajemy przy kroku pierwszym, bo nie mają sumy kontrolnej.
Jak napisać wyrażenia regularne dla e-maila, telefonu, PESEL i NIP?
Wzorce kompilujemy raz, na poziomie modułu, jako stałe. Zapis r"..." to surowy ciąg znaków: Python nie interpretuje w nim ukośników, więc \d trafia do re dokładnie takie, jakie napisałeś:
import re
EMAIL = re.compile(r"[\w.+-]+@[\w-]+(?:\.[\w-]+)+")
PHONE = re.compile(r"(?<![\w-])(?:\+48[ -]?)?\d{3}[ -]?\d{3}[ -]?\d{3}(?![\w-])")
PESEL = re.compile(r"(?<!\d)\d{11}(?!\d)")
NIP = re.compile(r"(?<![\d-])\d{3}-?\d{3}-?\d{2}-?\d{2}(?![\d-])")
Co tu się dzieje:
\ww Pythonie 3 dopasowuje litery Unicode, więcżółć@example.plzostanie znalezione bez dodatkowych ustawień. Na pytanie o polskie znaki w regexach odpowiedź jest więc prosta: dla zwykłych ciągówstrdziałają od razu.\d{3}[ -]?\d{3}[ -]?\d{3}to telefon: trzy grupy po trzy cyfry, rozdzielone opcjonalną spacją lub myślnikiem, z opcjonalnym prefiksem+48.(?<!\d)i(?!\d)to lookbehind i lookahead (asercje wyprzedzające). Mówią: „przed tym nie może stać cyfra” i „po tym nie może stać cyfra”. Bez nich\d{11}znalazłoby 11 cyfr w środku dłuższego numeru.- W telefonie i NIP dodaliśmy do asercji także myślnik, żeby numer NIP
987-654-32-10nie został pocięty na kawałki i uznany za telefon.
Jak sprawdzić sumę kontrolną PESEL i NIP?
Ostatnia cyfra PESEL to cyfra kontrolna. Liczymy ją, mnożąc pierwsze dziesięć cyfr przez wagi 1, 3, 7, 9, 1, 3, 7, 9, 1, 3, sumując wyniki i biorąc dopełnienie do dziesięciu. NIP działa podobnie, tylko z wagami 6, 5, 7, 2, 3, 4, 5, 6, 7 i dzieleniem przez 11:
def pesel_is_valid(pesel: str) -> bool:
weights = (1, 3, 7, 9, 1, 3, 7, 9, 1, 3)
total = sum(int(d) * w for d, w in zip(pesel, weights))
return (10 - total % 10) % 10 == int(pesel[10])
def nip_is_valid(nip: str) -> bool:
digits = nip.replace("-", "")
weights = (6, 5, 7, 2, 3, 4, 5, 6, 7)
check = sum(int(d) * w for d, w in zip(digits, weights)) % 11
return check != 10 and check == int(digits[9])
W nip_is_valid jest warunek check != 10: gdy reszta z dzielenia wynosi 10, taki NIP nie może istnieć, więc odrzucamy go, zamiast porównywać z cyfrą.
Jak zebrać wszystkie trafienia razem z ich pozycjami?
W następnej części będziemy podmieniać znalezione dane na tokeny, więc samo "44051401359" nie wystarczy. Potrzebujemy wiedzieć, gdzie w tekście to jest. Zapisujemy to w małej niemutowalnej klasie:
from dataclasses import dataclass
@dataclass(frozen=True)
class Finding:
kind: str
value: str
start: int
end: int
def find_sensitive(text: str) -> list[Finding]:
findings = []
for match in EMAIL.finditer(text):
findings.append(Finding("email", match.group(), match.start(), match.end()))
for match in PESEL.finditer(text):
if pesel_is_valid(match.group()):
findings.append(Finding("pesel", match.group(), match.start(), match.end()))
for match in NIP.finditer(text):
if nip_is_valid(match.group()):
findings.append(Finding("nip", match.group(), match.start(), match.end()))
for match in PHONE.finditer(text):
findings.append(Finding("phone", match.group(), match.start(), match.end()))
return sorted(findings, key=lambda f: f.start)
finditer zamiast findall daje obiekty dopasowania, z których bierzemy start() i end(). frozen=True sprawia, że trafienia nie da się przypadkiem zmienić, a klasa jest jeszcze gotowa do użycia jako klucz w słowniku. Na końcu sortujemy po pozycji, żeby wynik czytało się od początku tekstu.
Jak to działa na prawdziwym tekście?
Przetestowałem funkcję na takim tekście: dwa poprawne numery, e-mail, telefon, numer zamówienia z 11 cyframi i PESEL z błędną cyfrą kontrolną:
Klient Jan Kowalski (PESEL 44051401359, NIP 987-654-32-10) prosi o kontakt:
jan.kowalski@example.com albo +48 600 123 456. Zamówienie nr 12345678901, błędny PESEL 44051401358.
Wynik find_sensitive(text):
Finding(kind='pesel', value='44051401359', start=27, end=38)
Finding(kind='nip', value='987-654-32-10', start=44, end=57)
Finding(kind='email', value='jan.kowalski@example.com', start=76, end=100)
Finding(kind='phone', value='+48 600 123 456', start=106, end=121)
Sam regex PESEL.findall(text) zwróciłby trzy ciągi: poprawny PESEL, numer zamówienia i PESEL z błędną cyfrą. Po dodaniu sumy kontrolnej zostaje tylko pierwszy. PESEL 44051401359 to przykładowy numer znany z dokumentacji, a NIP 987-654-32-10 ma poprawną sumę kontrolną i został dobrany tylko do przykładu.
Czego ten detektor nie wykryje i gdzie się myli?
Ważne, żebyś znał granice tego rozwiązania, zanim użyjesz go na prawdziwych dokumentach:
| Problem | Przykład | Dlaczego |
|---|---|---|
| Suma kontrolna to nie dowód | Losowy 11-cyfrowy ciąg | Co dziesiąty losowy ciąg ma poprawną cyfrę kontrolną PESEL |
| Telefon bez sumy kontrolnej | Numer faktury 600123456 | Dziewięć cyfr to też „telefon” według wzorca |
| Imiona i nazwiska | Jan Kowalski | Regex nie odróżni nazwiska od zwykłych dwóch słów |
| Dane w innym zapisie | PESEL ze spacjami po grupach | Wzorzec zakłada zapis ciągły |
Pierwszy wiersz sprawdziłem na 100 000 losowych 11-cyfrowych ciągach: 9,87% przeszło sumę kontrolną PESEL. To wynika wprost z konstrukcji, bo ostatnia cyfra jest wyznaczona przez poprzednie, więc trafia się w jednym przypadku na dziesięć. Suma kontrolna wyrzuca zatem większość przypadkowych numerów, ale nie wszystkie. Dlatego detektor to dopiero propozycja, a nie ostateczna decyzja, i w następnych częściach cyklu dodamy krok akceptacji przez operatora.
Pamiętaj też, że wykrycie i usunięcie danych w tekście nie czyni Cię zgodnym z RODO.
Szerszy kontekst prawny (AI Act, prawa autorskie, RODO i odpowiedzialność za szkody wyrządzone przez sztuczną inteligencję w pracy) omawia w naszym podcaście prawniczka Paula Skrzypecka:
Jak przetestować detektor w pytest?
Każdy typ danych dostaje test „znajduje poprawny” i test „ignoruje niepoprawny”. Plus test, że pozycje wskazują dokładnie wartość:
import pytest
from detectors import find_sensitive, nip_is_valid, pesel_is_valid
def kinds(text):
return [(f.kind, f.value) for f in find_sensitive(text)]
def test_valid_pesel_is_found():
assert kinds("PESEL: 44051401359.") == [("pesel", "44051401359")]
def test_pesel_with_wrong_checksum_is_ignored():
assert kinds("Numer zamówienia 44051401358") == []
def test_eleven_digit_order_number_is_not_a_pesel():
assert kinds("Zamówienie 12345678901") == []
def test_valid_nip_with_dashes_is_found():
assert kinds("NIP 987-654-32-10") == [("nip", "987-654-32-10")]
def test_nip_with_wrong_checksum_is_ignored():
assert kinds("NIP 987-654-32-11") == []
def test_email_with_polish_letters():
assert kinds("żółć@example.pl") == [("email", "żółć@example.pl")]
def test_phone_with_prefix_and_spaces():
assert kinds("+48 600 123 456") == [("phone", "+48 600 123 456")]
def test_positions_point_at_the_value():
text = "Mail: jan@example.com"
finding = find_sensitive(text)[0]
assert text[finding.start:finding.end] == finding.value
def test_findings_are_sorted_by_position():
result = kinds("600123456 a potem jan@example.com")
assert [k for k, _ in result] == ["phone", "email"]
@pytest.mark.parametrize("number,expected", [("44051401359", True), ("44051401350", False)])
def test_pesel_checksum(number, expected):
assert pesel_is_valid(number) is expected
def test_nip_checksum_rejects_remainder_ten():
assert nip_is_valid("1234567890") is False
Ostatni test pilnuje przypadku brzegowego z check != 10. Szerszy kontekst pisania testów w pytest znajdziesz w artykule o testowaniu kodu z API LLM.
Czy wyrażenia regularne do formularza i do wykrywania w tekście to to samo?
Nie, choć używają tego samego modułu. Walidacja formularza sprawdza, czy całe pole ma poprawny kształt, więc wzorzec zaczyna się od ^ i kończy na $. Wykrywanie w tekście szuka fragmentów w dowolnym miejscu, dlatego potrzebuje asercji (?<!\d) i (?!\d) zamiast kotwic. Jeśli chcesz poznać wersję dla pól formularza, zobacz walidację formularza w JavaScript.
Od czego zacząć pisanie testów detektora danych?
Zacznij od testów dla tego, czego detektor nie powinien łapać: numeru zamówienia, numeru faktury, kodu pocztowego. Test, że PESEL zostaje znaleziony, napisze każdy i zwykle przejdzie od razu. Fałszywe trafienia są groźniejsze, bo psują zaufanie do narzędzia: operator, który kilka razy zobaczy zaznaczony numer faktury, przestaje wierzyć pozostałym trafieniom.
Co dalej?
Mamy listę trafień z pozycjami. W kolejnej części zamienimy je na tokeny typu [PESEL_1] i zapiszemy tabelę mapowania, czyli pseudonimizację właściwą. Całą ścieżkę Pythona znajdziesz na stronie ścieżki rozwoju w Pythonie.

