Wekelijkse, idempotente dataverzameling in de reisbranche — volledig op GCP, alles Infrastructure-as-Code.
Context & probleem
Concurrentiedata in de reisbranche is vluchtig: prijzen en vertrekdata verschuiven dagelijks, pagina's verschijnen en verdwijnen. Een enkele scrape geeft een momentopname; waarde zit in de tijdreeks — prijsbewegingen, nieuwe vertrekken, verdwenen reizen. Tegelijk wil je niet elke run een volledige crawl van zware product-detailpagina's (PDP's) doen: dat is traag, duur op proxy-verkeer, en fragiel.
De kern van het ontwerp is die spanning expliciet maken in twee gescheiden componenten in plaats van één scraper te overladen met conditionele logica.
Architectuur: twee scrapers, twee filosofieën
De belangrijkste ontwerpbeslissing: niet één configureerbare scraper, maar twee componenten die elk één ding goed doen en bewust verschillende robuustheidsniveaus hebben.
| Snapshot-crawler | Archief-scraper (PDP) | |
|---|---|---|
| Rol | Goedkope sitemap-crawl, bepaalt wat er te scrapen valt | Diepe PDP-extractie, bouwt het historisch archief |
| Levensduur data | Wegwerp — elke run overschrijft | Permanent — append-only tijdreeks |
| HTTP | session.get() direct, geen retry | fetch_with_retry() met backoff + proxy |
| Robuustheid | Minimaal, mag falen | Volledig: dedup, fingerprinting, anomaly detection |
| BQ-schrijfmodus | overschrijven (snapshot) | WRITE_APPEND |
| Voedt | de archief-scraper | de dbt-laag |
Waarom de asymmetrie bewust is. De snapshot-crawler is een voedingskanaal: hij hoeft alleen de actuele set URL's en hun paden te leveren. Faalt een run, dan draai je opnieuw — er gaat niets verloren omdat de output toch wegwerp is. Hem volplempen met retry-logica en deduplicatie zou complexiteit toevoegen aan iets wat juist simpel en vervangbaar moet zijn.
De archief-scraper is het tegenovergestelde: elke succesvolle fetch is een onvervangbaar punt in een tijdreeks. Daar zit alle robuustheid — retries, idempotente IDs, contentfingerprinting, anomaliedetectie op rij-aantallen. De scheiding houdt beide componenten leesbaar: geen if is_snapshot:-takken die twee tegengestelde eisen in één codepad proberen te verzoenen.
Gedeelde library
Beide scrapers — en elke toekomstige scraper — bouwen op één gedeelde library. Geen class-hiërarchie, gewoon directe functies per verantwoordelijkheid.
| Module | Verantwoordelijkheid |
|---|---|
ids.py | Deterministische ID-generatie (make_scrape_id, make_scrape_url_id, make_scrape_url_departure_id) |
http.py | fetch_with_retry(session, url, timeout=15, is_api=False) → Response | None |
bq.py | push_rows, check_exists, get_scraped_paths, get_sitemap_urls |
config.py | Centrale config: project/dataset, proxy-toggle, delays, max retries |
log.py | Gestructureerde logging: log(severity, message, **extra) |
De library is de afspraak: een nieuwe scraper hoeft niet na te denken over hoe een ID eruitziet of hoe je naar BigQuery schrijft. Dat is één keer goed opgelost en wordt overal hergebruikt.
Determinisme & idempotentie
De ruggengraat van het hele platform. IDs zijn deterministisch, nooit random. Elke rij krijgt een ID via een base64-encoded MD5-hash over een vaste sleutelcompositie:
make_scrape_url_id() → b64_md5("<bron>|{vertrek_datum}|{page_path}")
Dit levert direct een aantal eigenschappen op:
- Idempotentie. Dezelfde reis op dezelfde datum produceert altijd hetzelfde ID. Een herhaalde run dupliceert dus niets —
check_exists()weet of een record al bestaat zonder fuzzy matching. - Stabiele join-sleutel. Hetzelfde ID-schema verbindt snapshot-, archief- en dbt-laag zonder fragiele meervoudige-kolom joins. De sleutel is reproduceerbaar vanuit de brondata zelf.
- Geen UUID/random. Een random ID zou bij elke run nieuw zijn — dan kun je geen entiteit over de tijd volgen, en is dedup onmogelijk zonder dure vergelijkingen.
Bovenop de IDs:
- Contentfingerprinting detecteert of een pagina inhoudelijk veranderd is, los van of het ID hetzelfde is — zo onderscheid je een echte prijswijziging van een ongewijzigde re-fetch.
- Anomaly detection op rij-aantallen per run: een crawl die plots 90% minder rijen oplevert is waarschijnlijk een gebroken selector of een geblokte proxy, geen legitieme krimp. Die run wordt geflagd in plaats van stilletjes het archief te vervuilen.
HTTP-laag & robuustheid
Alle PDP-verkeer loopt via fetch_with_retry(). De enige uitzondering is de sitemap-crawl, die bewust kaal session.get() gebruikt (wegwerp, mag falen).
- Retry met backoff rond transient fouten en proxy-hikkups, met een geconfigureerd maximum.
- Rotating proxy. Verkeer kan via een commerciële proxy-provider met IP-rotatie, getoggled via config. SSL-verificatie staat uit voor de proxy-endpoint (bewuste, gecontroleerde keuze binnen de proxy-context).
- Aparte delays voor API- versus pagina-requests, centraal in config — geen magische
sleep()-getallen verspreid door de codebase. Noneals expliciete failure.fetch_with_retry()geeft eenResponseofNone. De aanroeper moet de None-case afhandelen; geen exceptions die door de call-stack lekken.
BigQuery-datamodel
Twee schrijfregimes, passend bij de twee scrapers:
- Snapshot — overschrijvend. Altijd de actuele staat, geen historie nodig.
- Archief — strikt
WRITE_APPEND, nooit truncate. Elke run voegt toe aan de tijdreeks. Truncate zou betekenen: een operationele fout wist onherstelbaar historische data. Die deur is dichtgetimmerd — append-only is een harde regel, geen voorkeur.
Idempotentie (zie boven) maakt append veilig: dubbele runs leiden niet tot dubbele rijen omdat de deterministische IDs collisies vangen.
dbt-transformatielaag
Ruwe append-only data is geen dashboarddata. De dbt-laag transformeert in duidelijke lagen, elk model getest en gedocumenteerd:
staging → stg_* (1:1 opschoning van bron)
marts → fct_* / dim_* (business logic, feiten & dimensies)
dashboard→ dash_* (presentatie-views voor de frontend)
- Hoofd-fact:
fct_competitor__departures— de tijdreeks van vertrekken/prijzen per geanonimiseerde bron. - CTE's, geen geneste subqueries. Elke transformatie leesbaar van boven naar beneden.
- Elk model gedocumenteerd in
.ymlmét test-definities. Een model zonder tests/docs gaat niet mee.
BigQuery-craft die ertoe doet in dit domein:
DATE_TRUNC(date, WEEK(MONDAY))— weekbuckets op maandag, consistent met de wekelijkse cadans.APPROX_QUANTILES(col, 2)[OFFSET(1)]— mediaan zonder full sort, schaalt op grote volumes.SAFE_DIVIDE()overal — prijsverhoudingen en percentages knappen niet op nul-noemers.
Infrastructuur & operations
Het platform is serverless en volledig declaratief.
- Cloud Run Jobs voor de scrapers — geen idle compute, betalen per run.
- Cloud Workflows orkestreert de wekelijkse keten: snapshot-crawl → archief-scrape → dbt-run, met de afhankelijkheden expliciet.
- Terraform in productie. Elke GCP-resource — buckets, datasets, service-accounts, IAM-bindings, scheduler-triggers — staat in code. Geen console-klik die niet in de state zit.
- Secret Manager. Proxy-keys en credentials via environment variabelen, gevoed uit Secret Manager. Niets hardcoded, niets in de repo.
- Least-privilege IAM per workload. Elke scraper draait onder een eigen service-account met exact de rechten die het nodig heeft, niet meer.
- GitHub Actions CI. Tests draaien (
pytest) vóór elke deploy; Docker-images builden vanuit een gedeelde context zodat de shared library bereikbaar is.
Engineeringbeslissingen die ertoe doen
Wat dit project boven "een scriptje dat scrapet" tilt:
- Twee filosofieën i.p.v. één configureerbare moloch. Robuustheid waar het telt (archief), simpliciteit waar het mag (snapshot). Complexiteit wordt geplaatst, niet vermeden.
- Determinisme als fundament. Deterministische IDs maken idempotentie, veilige append, en stabiele joins in één klap mogelijk. Eén ontwerpkeuze, drie problemen opgelost.
- Append-only met vangrails. Historie is heilig; truncate bestaat niet. Anomaliedetectie voorkomt dat een gebroken run het archief stil vervuilt.
- De shared library als contract. Nieuwe scrapers erven robuustheid in plaats van die opnieuw te schrijven.
- Alles IaC, least-privilege, secrets uit de code. Productie is reproduceerbaar en auditeerbaar; de infra is zelf het bewijs van discipline.
Status
Het platform draait in productie met de wekelijkse cadans. De dbt-modellen voeden geteste dashboard-views; de tijdreeks groeit append-only verder. Volgende stappen liggen in de presentatielaag — live dashboards en exposures bovenop fct_competitor__departures.