ChatGPT i Google działają bez konta. Do Claude skopiujemy polecenie (wymaga konta).
Scraping ksiąg wieczystych i rejestrów publicznych to jeden z tych problemów, które wyglądają prosto na diagramie architektonicznym, a potem konsumują 60% czasu zespołu w produkcji. Napisałeś adapter, dodałeś kilka selektorów CSS, działa na stagingu. Deploy do produkcji. Tydzień później okazuje się, że 20% rekordów ma puste pola, ale nikt tego nie zauważył, bo HTTP 200 OK. Klient dzwoni, że system rekomendował inwestycję w nieruchomość z nieujawnioną hipoteką. Sprawdzasz logi – scraper pobrał stronę, ale struktura HTML się zmieniła i selektor zwrócił null. Zapisało się do bazy. Poszło dalej w pipeline. Nikt nie zaalarmował.
To nie jest problem debugowania selektorów. To problem architektury, która nie zakłada, że źródła danych będą się zmieniać, kłamać i zawodzić. Systemy scraperowe działają świetnie do momentu, kiedy przestają – i zazwyczaj dowiadujesz się o tym pośrednio, przez skutki w danych, nie przez alert.
Dlaczego hardcodowane selektory to technical debt z odsetkami
Większość scraperów rozpoczyna życie jako skrypt z kilkunastoma linijkami BeautifulSoup albo Puppeteer. Kilka selektorów CSS, może XPath jak ktoś jest wyrafinowany. Działa. Problem w tym, że „działa” ma bardzo krótki okres ważności w kontekście stron rządowych.
Elektroniczne księgi wieczyste, rejestry sądowe, portale gmin – te systemy nie są projektowane z myślą o programatycznym dostępie. Zmiana layoutu następuje bez ostrzeżenia, bo developerzy tych stron nawet nie wiedzą, że ktoś je scrapuje. W jednym projekcie zmiana layoutu działu IV księgi wieczystej (ten z obciążeniami – najbardziej krytyczny fragment) nastąpiła w piątek o 16:00. W poniedziałek rano okazało się, że przez cały weekend system przetwarzał nieruchomości bez informacji o hipotekach. Rekordy oznaczone jako „brak obciążeń”, bo selektor „.debt-section .amount” przestał działać. Strona dodała dodatkowy wrapper div.
Hardcodowane selektory to długi techniczny, który nie zmniejsza się w czasie – tylko rośnie, bo z każdym nowym źródłem danych masz kolejny set kruchych zależności od struktury HTML. Refaktoring nie pomaga, bo problem nie jest w jakości kodu, tylko w samym modelu – implicit contract z zewnętrznym systemem, który nie wie o twojej egzystencji i zmienia się arbitralnie.
Gorsze od awarii jest to, że większość takich problemów nie objawia się błędem. Scraper dostaje HTTP 200, parsuje HTML, nie znajduje elementu, zwraca empty string albo null. Zapisujesz do bazy. Jeśli nie masz explicit validation, rekord wygląda poprawnie – wszystkie required fields wypełnione, tylko wartości są puste albo domyślne. To idzie dalej w pipeline. NLP próbuje ekstrakcji z pustych stringów. Pattern recognition nie znajduje wzorców, ale nie rzuca błędu, tylko zwraca low confidence. I masz nieruchomość w systemie, która wygląda jak prawidłowo przetworzona, ale oparta jest na incomplete data.
Symptomy widzisz dopiero pośrednio. Spadek średniej liczby wykrytych obciążeń na nieruchomość. Wzrost liczby rekordów z pustymi polami w konkretnych kategoriach. Albo – co najgorsze – klient zgłasza, że rekomendowaliście inwestycję w nieruchomość, która ma trzy hipoteki w KW.
Contract validation zamiast optimism
Rozwiązanie nie polega na „lepszych selektorach”. Polega na tym, żeby przestać udawać, że znasz strukturę odpowiedzi. Zamiast tego traktujesz każdą odpowiedź ze źródła jako untrusted input, który musi przejść explicit contract validation przed wejściem do pipeline”u.
Contract to nie jest schema validation w sensie JSON Schema. To zestaw asercji o strukturze i zawartości odpowiedzi, które muszą być spełnione, żeby uznać dane za użyteczne. Dla księgi wieczystej dział IV:
def validate_kw_section_iv(response):
checks = {
"has_section": response.select_one(".section-iv") is not None,
"has_entries_table": response.select_one(".entries-table") is not None,
"has_minimum_columns": len(response.select(".entries-table th")) >= 4,
"debt_rows_parseable": all(
row.select_one(".debt-type") and row.select_one(".amount")
for row in response.select(".entries-table tr.debt")
)
}
if not all(checks.values()):
raise ContractViolation(
source="ekw.ms.gov.pl",
checks=checks,
html_hash=hash(response.text)
)
Nie sprawdzasz tylko czy element istnieje. Sprawdzasz czy struktura ma sens biznesowy – czy tabela ma odpowiednią liczbę kolumn, czy wiersze z długami mają wymagane pola, czy sekcja w ogóle jest obecna. Jak któryś check failuje, nie próbujesz kontynuować z partial data. Rzucasz exception, logujesz violated checks, zapisujesz hash HTML dla porównania.
To nie jest obrona przed błędami w danych źródłowych. To obrona przed cichymi błędami w twoim systemie. Jeśli struktura się zmienia, dowiadujesz się natychmiast przez ContractViolation, nie przez to że klient znajdzie błąd w raporcie trzy tygodnie później.
Contract validation przesuwa problem z „jak wykryć że coś poszło nie tak” na „co zrobić kiedy wiem że coś poszło nie tak”. To dużo lepsze miejsce do bycia.
Circuit breaker: kiedy przestać próbować
Masz contract validation. Detekcja błędów działa. Teraz problem: co robisz gdy 100 requestów z rzędu failuje contract validation? Naiwne podejście: retry każdy request, loguj błąd, próbuj dalej. Efekt: system bombarduje źródło, które już nie działa, kolejka rośnie, processing grid sprawdza w kółko to samo źródło próbując zrobić progress, wszystko stoi.
Circuit breaker pattern to mechanizm który mówi „ok, próbowałem 10 razy, wszystko failuje, przestaję próbować na jakiś czas”. Implementacja dla źródła danych:
class SourceCircuitBreaker:
def init(self, failure_threshold=5, timeout=300):
self.failure_count = 0
self.failure_threshold = failure_threshold
self.timeout = timeout
self.state = "closed" # closed | open | half_open
self.opened_at = None
def call(self, func):
if self.state == "open":
if time.time() - self.opened_at > self.timeout:
self.state = "half_open"
else:
raise CircuitOpen(f"Circuit open for {self.timeout}s")
try:
result = func()
if self.state == "half_open":
self.reset()
return result
except ContractViolation:
self.record_failure()
raise
def record_failure(self):
self.failure_count += 1
if self.failure_count >= self.failure_threshold:
self.state = "open"
self.opened_at = time.time()
Circuit działa per-source. Księgi wieczyste mogą mieć problem, ale rejestry sądowe działają – nie chcesz blokować całego systemu. Stan „half_open” to testowanie recovery – po timeout próbujesz jeden request, jeśli przejdzie, reset countera, jeśli nie – circuit z powrotem open.
Kluczowa decyzja: co jest failure threshold. Za nisko – circuit otwiera się na transient errors (timeout sieciowy, jeden malformed response). Za wysoko – system próbuje długo zanim się podda, marnując resources. W praktyce threshold zależy od charakterystyki źródła. Dla stabilnych API to może być 10-20. Dla scraperów rządowych 3-5, bo jak coś się zepsuło, zazwyczaj zostaje zepsute na dłużej.
Half-open state jest krytyczny. Bez niego circuit który się raz otworzył nigdy się nie zamyka automatycznie – trzeba manual intervention. Z half-open system sam wykrywa recovery źródła. Timeout przed próbą half-open to trade-off: krótki timeout = szybsze wykrycie recovery, ale częstsze próby niepotrzebne. Długi timeout = mniej requestów, ale wolniejsze recovery.
Quarantine queue: izolacja niepewnych danych
Contract validation złapała problem. Circuit breaker przestał bombardować źródło. Ale co z rekordami, które już są w systemie? Masz 1000 nieruchomości w queue do przetworzenia, 50 z nich ma incomplete data bo struktura się zmieniła w trakcie scraping session. Standardowy flow: failed record -> retry -> fail again -> dead letter queue -> ktoś kiedyś spojrzy.
Problem z tym podejściem: tratowane wszystkie failures tak samo. Ale nie wszystkie failures są równe. Network timeout to transient error – retry ma sens. Contract violation to structural problem – retry bez zmiany kodu nie pomoże. A partial success to jeszcze inna kategoria – masz część danych, pytanie czy są użyteczne.
Quarantine queue to osobna ścieżka dla rekordów, które nie przeszły validation, ale nie są jeszcze definitywnie failed. To zona gdzie dane czekają na jedną z trzech rzeczy: retry po deploy nowej wersji parsera, human review, albo eskalację do proper failure.
Implementacja to osobna tabela/kolekcja z metadatą:
– „record_id” – co próbowaliśmy przetworzyć
– „source” – które źródło danych
– „failure_reason” – który check w contract validation failnął
– „raw_response” – pełna odpowiedź ze źródła (żeby można było reprocess)
– „html_hash” – fingerprint struktury
– „quarantined_at” – timestamp
– „attempts” – ile razy próbowaliśmy
– „status” – „quarantined | reviewed | reprocessing | failed”
Różnica między quarantine a dead letter queue: quarantine zakłada że problem może być fixable. DLQ to koniec drogi. Quarantine to poczekajnia.
Retry logic dla quarantine nie jest time-based. Nie próbujesz automatycznie co X minut. Próbujesz gdy:
1. Deploy nowej wersji parsera (bo może naprawiłeś selektor)
2. Źródło danych zmienia hash struktury z powrotem (bo może rollback na ich stronie)
3. Human review zaaprobował partial data jako „good enough”
Ten trzeci punkt jest ważny. Czasem masz 95% pól wypełnionych, 5% nie. W zależności od business logic to może być akceptowalne. Księga wieczysta z wszystkimi działami oprócz działu II (właściciele) – jeśli twoim case”em są obciążenia, partial data wystarcza. Ale to musi być explicit decision, nie automatyczne „przyjmę co mam”.
Podczas jednego wdrożenia okazało się że klient naprawdę potrzebował tylko działów III i IV (prawa i obciążenia). Dział I i II były nice-to-have. Gdy parser zaczął failować na nowym layoucie działu II, początkowa reakcja była „wszystko failuje, nic nie działa”. Po rozmowie ustaliliśmy że rekordy z działami III i IV ale bez II mogą iść dalej w pipeline z flagą „incomplete: missing_section_ii”. System wrócił do działania w godzinę zamiast czekać tydzień na fix parsera dla sekcji, która nie była krytyczna.
Drift detection: wiedzieć że coś się zmienia zanim wszystko padnie
Contract validation łapie broken structure. Ale co z subtle changes? Sekcja nadal istnieje, selektor działa, ale tabela ma teraz 6 kolumn zamiast 5. Albo class name zmienił się z „.debt-entry” na „.debt-item”. Parser może dalej działać, ale parsuje złe kolumny. Albo przestaje parsować część danych.
Drift detection to monitoring zmian w strukturze odpowiedzi, nawet jak contract validation przechodzi. Podstawowa implementacja: schema fingerprinting.
Dla każdej odpowiedzi ze źródła generujesz fingerprint struktury – nie pełny hash HTML (bo content się zmienia), ale hash selektorów, klas, struktury DOM:
def structure_fingerprint(response):
structure = {
"selectors": sorted([
el.name + "." + ".".join(el.get("class", []))
for el in response.select("*")
]),
"tables": [
{"cols": len(table.select("th")), "rows": len(table.select("tr"))}
for table in response.select("table")
],
"forms": [len(form.select("input")) for form in response.select("form")]
}
return hashlib.sha256(json.dumps(structure, sort_keys=True).encode()).hexdigest()
Nie dokładny hash – nie obchodzi cię czy div ma id „container-123” czy „container-456”. Obchodzi cię struktura: jakie elementy, jakie klasy, ile kolumn w tabelach.
Logujesz fingerprint dla każdej odpowiedzi. Tracking per source:
– Jaki fingerprint widziałeś najczęściej w ostatnich 24h (dominant fingerprint)
– Kiedy pojawił się nowy fingerprint
– Ile % requestów ma nowy fingerprint
Alert nie na „pojawił się nowy fingerprint” – to byłoby false positive przy każdym deploy na ich stronie. Alert na „nowy fingerprint stanowi >20% odpowiedzi i dominant fingerprint spada poniżej 50%”.
To daje ci okno ostrzegawcze. Struktura zaczyna się zmieniać, ale nie wszystko jest jeszcze broken. Możesz:
– Sprawdzić nowy fingerprint i zobaczyć co się zmieniło
– Przygotować nową wersję parsera
– Zdecydować czy to A/B test po ich stronie czy permanent change
– Deploy fix zanim circuit breaker się otworzy
Drift detection zamieniło kilka nocnych „wszystko nie działa” emergencies w planned updates. Widzisz że struktura się zmienia, aktualizujesz parser, deploy, system płynnie przechodzi na nową strukturę.
"Najgorsze awarie nie otwierają circuit breakera. Zwracają `200 OK`, przechodzą przez pipeline i przez kilka dni cierpliwie czekają, aż ktoś zauważy, że produkcja przetwarza już coś zupełnie innego niż dane."
Rate limiting: adaptive throttling zamiast fixed limits
Rejestry publiczne nie publikują rate limits. Nie ma header „X-RateLimit-Remaining”. Po prostu dostajesz 429, albo IP ban, albo captcha. A czasem captcha pojawia się losowo nawet przy niskim traffic, bo ich system anti-bot jest… kreatywny.
Fixed rate limit (np. 10 req/s) nie działa, bo nie wiesz jaki jest rzeczywisty limit. Za wysoko – dostajesz bany. Za nisko – marnujesz capacity.
Adaptive rate limiting to mechanizm który adjustuje rate based on observed behavior źródła:
– Start z conservative rate (np. 1 req/s)
– Jeśli wszystkie requesty succeed i response time stabilny – zwiększ rate o 10%
– Jeśli widzisz 429/503 albo response time spike – zmniejsz rate o 50%
– Jeśli widzisz captcha – zmniejsz drastycznie i czekaj
Implementacja wymaga tracking per-source metrics:
– Success rate w sliding window (np. ostatnie 100 requestów)
– p95 response time
– Error rate breakdown (network errors vs HTTP errors vs contract violations)
Kluczowe: nie zwiększaj rate od razu po jednym sukcesie. Zwiększaj stopniowo i tylko jeśli sustained success. Źródła rządowe mają rate limits które są enforce”owane w różnych window – może być 100 req/min ale 1000 req/hour. Jak agresywnie zwiększasz rate, możesz trafić w hourly limit nawet jak minute limit jest ok.
Proxy rotation pool to druga warstwa obrony. Masz pulę proxy/IP, każde tracked osobno:
– Ile requestów z tego IP w ostatniej godzinie
– Czy widziano 429/captcha z tego IP
– Health status: „healthy | degraded | banned”
Request scheduling wybiera proxy based on health i load. Banned proxy idzie do cooldown (np. 24h) zanim wraca do poolu. To chroni przed cascade failure – jeden banned IP nie stopuje całego systemu.
W praktyce adaptive rate limiting + proxy rotation pozwala na 3-5x wyższy throughput niż fixed conservative limit, przy niższym ryzyku bana, bo system sam się adjustuje do rzeczywistych limitów źródła.
Persistence i reprocessing strategy
Ostatni kawałek układanki: co się dzieje z danymi w różnych stadiach failure. Masz record który:
1. Failed contract validation -> quarantine
2. Przeszedł validation ale NLP extraction miało low confidence -> quarantine
3. Przeszło wszystko ale drift detection pokazuje że struktura się zmieniała podczas scraping session -> potential data quality issue
Persistence strategy:
– Raw response zawsze zapisywany (S3/blob storage, indexed by record_id + timestamp)
– Parsed structured data zapisywane tylko po successful validation
– Quarantine table zawiera pointer do raw response, nie duplikuje danych
– Audit trail każdego processing attempt
Reprocessing nie jest „uruchom ponownie cały pipeline”. To selective reprocessing rekordów które są flagged:
– Records w quarantine po deploy nowej wersji parsera
– Records z okresu kiedy drift detection wykrył structural change
– Records z source który był w circuit breaker open state
Idempotency jest krytyczna. Reprocessing tego samego raw response musi dawać ten sam result. To znaczy że processing logic nie może zależeć od external state który się zmienia (timestamps, random values, external API calls które nie są cachowane).
Checkpoint pattern: każdy stage pipeline zapisuje swój output osobno. Scraping zapisuje raw responses. Parsing zapisuje structured data. NLP zapisuje extractions. Nie ma „transaction” przez wszystkie stage”e – każdy stage jest independent i może być reprocessed osobno.
To pozwala na partial reprocessing. Zmienił się NLP model – reprocessujesz od parsing output, nie scrapujesz ponownie. Zmienił się parser – reprocessujesz od raw responses, ale nie dla wszystkich rekordów, tylko dla tych z okresu kiedy struktura była unstable.
Storage cost rośnie, ale to trade-off za operational flexibility. W jednym incydencie źródło było broken przez tydzień, 10k rekordów w quarantine. Po fix parsera reprocessing zajął 2 godziny z saved raw responses vs kilka dni ponownego scrapingu (rate limits, circuit breakers, availability źródła).
"Rate limiting działa świetnie, dopóki druga strona też zachowuje się jak system. Przy publicznych rejestrach częściej optymalizujesz pod coś pomiędzy undocumented API, losowym anti-botem i infrastrukturą, która sama nie wie, ile requestów dziś toleruje."
Symptomy problemów w produkcji których nie widać na stagingu
Staging testuje happy path. Produkcja daje ci edge cases których nie anticipated. Typowe symptomy że defensive architecture nie jest wystarczająca:
Spadek liczby wypełnionych pól per record w czasie. Nie nagle – stopniowo. Oznacza subtle structural drift który nie failuje validation ale parsuje częściowe dane. Tracking average non-null fields per record w time series pokazuje to wyraźnie.
Wzrost low-confidence extractions z NLP. Jeśli nagle 40% extractions ma confidence <0.7 zamiast normalnych 10%, prawdopodobnie input data quality się pogorszyła. To downstream symptom upstream structural problem.
Bimodal distribution response time ze źródła. Normalnie masz median 200ms, p95 500ms. Nagle widzisz dwa peaks: jeden przy 200ms, drugi przy 5000ms. Oznacza że część requestów trafia w rate limiting lub anti-bot checks. Prekursor bana.
Circuit breaker flapping. Otwiera się, zamyka, otwiera znowu w krótkich intervalach. Oznacza że threshold jest źle ustawiony albo źródło jest intermittently broken. Albo – częściej – masz race condition w half-open logic.
Rośnie quarantine queue ale nie maleje po deploy fixów. Oznacza że reprocessing logic nie działa albo failures nie są naprawdę fixable (np. źródło jest permanently broken i trzeba alternatywnego źródła).
Te symptomy nie są binary failures. To degradacja. I bez proper observability nie zauważysz dopóki klient nie zgłosi że dane są incomplete.
"Staging zwykle potwierdza, że system działa. Produkcja sprawdza coś znacznie ciekawszego: jak długo może działać źle, zanim którakolwiek z metryk uzna to za problem."
Wnioski praktyczne
Defensive architecture dla scrapingu nie polega na tym żeby nie mieć failures. Polega na tym żeby wiedzieć kiedy failujesz, izolować failures żeby nie propagowały się, i móc recovery bez manual intervention albo full reprocessing. Źródła które scrapujesz będą się zmieniać. To nie jest „jeśli”, to „kiedy”. System który zakłada stable structure to system który będzie wymagał constant firefighting. System który zakłada chaos i ma mechanizmy obrony działa nawet jak źródła się zmieniają, bo degraduje gracefully zamiast cicho produkować bad data.
Największy błąd to assumption że jak dostałeś HTTP 200, to masz dobre dane. Nie masz. Masz odpowiedź. Czy to są użyteczne dane – to dopiero pytanie które musisz zadać przez validation, monitoring i explicit quality checks. Produkcja bardzo szybko weryfikuje optimism.
Podsumowanie
Najbardziej niebezpieczny scraper nie jest tym, który zwraca błąd. Jest nim ten, który przez trzy tygodnie odpowiada 200 OK, zapisuje rekordy do bazy i wygląda zdrowo na wszystkich podstawowych metrykach, podczas gdy po cichu produkuje coraz gorsze dane.
Po kilku miesiącach na produkcji przestajesz więc traktować zewnętrzny HTML jak źródło danych, a zaczynasz jak niezaufany protokół bez wersjonowania i bez właściciela, do którego możesz zadzwonić. Każda odpowiedź może zmienić strukturę, każdy sukces wymaga walidacji, a możliwość zatrzymania pipeline’u jest czasem ważniejsza niż jego throughput.
Ostatecznie najwięcej problemów nie powodują spektakularne awarie. Powodują je systemy, które technicznie nadal działają, tylko już nie robią tego, co zakładaliśmy. Produkcja ma wyjątkowy talent do znajdowania różnicy między tymi dwiema rzeczami.
Zobacz powiązane case studies i analizy
Procesy, architektura i workflow powiązane z tematami poruszanymi w tym materiale – od integracji i realtime systems po automatyzacje operacyjne.