Files
korting/docs/superpowers/specs/2026-09-05-korting-playwright-fallback-design.md
2026-09-05 17:21:30 +02:00

6.2 KiB

Korting — Playwright-fallback voor prijs-extractie

Datum: 2026-09-05 Status: Approved Bouwt voort op: 2026-09-05-korting-prijstracker-design.md

Aanleiding

Een gebruiker meldde dat fetchAndExtractPrice faalt voor https://www.iciparisxl.nl/... met fetch_error HTTP 403. Onderzoek (systematic-debugging) toonde aan dat dit domein achter Akamai Bot Manager draait: elk verzoek dat niet als "echte browser" wordt herkend krijgt al op de edge een 403 Access Denied (server: AkamaiGHost), vóórdat de eigenlijke website bereikt wordt. Bevestigd zowel met een directe curl-test (ook met browser-achtige User-Agent) als via de live app zelf (identieke fetch_error HTTP 403 in de logs). Dit is geen bug in de bestaande scraper-logica — die blijft correct voor de meeste shops — maar een structurele blokkade die headers alleen niet oplossen.

Belangrijk: een headless-browser fallback is geen garantie tegen Akamai specifiek (die kan ook headless browsers detecteren), maar is de enige realistische route voor sites die de prijs pas client-side renderen, en verbetert de dekking voor dat bredere geval.

Doel

Wanneer de bestaande fetchAndExtractPrice geen prijs oplevert (fout of price: null), probeert het systeem het opnieuw met een echte headless Chromium-browser (Playwright) voordat de check als mislukt wordt geregistreerd.

Architectuur

Nieuwe module src/browserScraper.js, losstaand van src/scraper.js (die blijft ongewijzigd — alle bestaande tests blijven geldig):

  • launchBrowser() -> Promise<Browser> — start één Chromium-instance (Playwright).
  • fetchAndExtractPriceViaBrowser(browser, url, { timeoutMs = 20000 }) -> Promise<{ price: number|null, method: string|null, error: Error|null }> — opent een nieuwe pagina op de meegegeven browser, navigeert naar url (timeout timeoutMs), leest de gerenderde HTML via page.content(), en hergebruikt extractPrice() uit src/scraper.js (JSON-LD → meta → regex, exact dezelfde tiers) om de prijs eruit te halen. Sluit de pagina altijd (try/finally), nooit de browser zelf.

Wijziging in checkProducts.js

checkProduct(product, deps) krijgt een optionele extra dependency: deps.fetchAndExtractPriceViaBrowser(url) -> Promise<{price,method,error}> (curried met de browser-instance door de composition root — checkProducts.js weet niets van Playwright zelf, blijft puur dependency-injected zoals nu).

Logica: eerst zoals nu fetchAndExtractPrice(product.url) aanroepen. Als dat resultaat een error heeft of price === null, én deps.fetchAndExtractPriceViaBrowser is meegegeven, probeer de fallback en gebruik dát resultaat verder (ook als de fallback zelf ook faalt — de bestaande classificatie hieronder werkt op wat dan ook het laatste resultaat is). Fallback wordt nooit aangeroepen als de eerste poging al een prijs opleverde.

De rest van de functie (baseline/unchanged/changed+mail/mail_error/ fetch_error/not_found, en de regel dat last_price/last_checked_at alleen bij een volledig geslaagde check worden bijgewerkt) blijft ongewijzigd.

Lifecycle (in server.js)

Eén browser per runCheck()-aanroep (zowel de dagelijkse cron als de "nu controleren"-knop delen dit patroon, want beide roepen runCheck() aan) — lazy: de browser start pas op het moment dat de eerste fallback binnen die run daadwerkelijk nodig is (dus niet als alle producten al gewoon ophaalbaar zijn via de normale fetch). Eenmaal gestart wordt diezelfde instance hergebruikt voor de rest van die run (nieuwe pagina per product), en aan het eind van de run altijd gesloten in een finally — nooit een browser die de hele dag blijft openstaan, en geen onnodige browser-start op runs die 'm niet nodig hebben.

Docker

De runtime-stage van de Dockerfile installeert Chromium + de benodigde systeemlibraries via npx playwright install --with-deps chromium, ná het kopiëren van de app (zodat de juiste Playwright-versie uit node_modules gebruikt wordt). playwright wordt een production dependency (niet dev) zodat npm ci --omit=dev in de build-stage 'm wél meeneemt naar de runtime-stage. Dit vergroot de image en de build-tijd merkbaar (geaccepteerd trade-off, al gecommuniceerd).

Error handling

  • page.goto faalt of timet uit (timeoutMs, default 20000ms) → fallback retourneert {price: null, method: null, error}, exact zelfde vorm als de bestaande fetchAndExtractPrice-fouten. Geen crash van de run.
  • Pagina wordt altijd gesloten, ook bij een gooiende page.goto (try/finally), zodat er geen paginas/geheugen lekken binnen een run met meerdere fallback-pogingen.
  • Browser wordt altijd gesloten aan het eind van runCheck(), ook als er onderweg een fout optreedt (try/finally rond de hele check-run).

Testing

  • test/browserScraper.test.js: test tegen een nep browser/pagina- object (zelfde dependency-injection-patroon als de rest van de codebase — geen echte Chromium nodig in de testrun): succesvolle extractie, page.goto die faalt/timet uit, en dat page.close() altijd wordt aangeroepen (ook bij een fout).
  • test/checkProducts.test.js: nieuwe tests die bevestigen dat de fallback (a) wordt aangeroepen wanneer de eerste poging faalt, (b) niet wordt aangeroepen wanneer de eerste poging al een prijs oplevert, en (c) dat bestaande tests zonder fetchAndExtractPriceViaBrowser in de deps ongewijzigd blijven werken (de dependency is optioneel, achterwaarts compatibel).
  • Geen geautomatiseerde test voor de Dockerfile-wijziging zelf — net als bij de oorspronkelijke Dockerfile-taak, een handmatige build+run-verificatie (ditmaal ook een handmatige check dat Chromium daadwerkelijk een pagina kan laden binnen de container).

Out of scope

  • Geen garantie dat dit specifieke Akamai-domein (iciparisxl.nl) hierdoor gaat werken — dat hangt af van Akamai's detectie-niveau voor dat domein op dat moment. Dit wordt niet apart getest tegen dat specifieke domein als onderdeel van de geautomatiseerde testsuite (te broos/afhankelijk van een externe, veranderlijke anti-bot-dienst); wel handmatig te proberen na deploy.
  • Playwright-stealth-plugins of andere anti-detectie-technieken — niet gevraagd, voegt complexiteit toe zonder gegarandeerd resultaat.