Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013gwc3aCiyQxmCWQke8RvEz
6.0 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 naarurl(timeouttimeoutMs), leest de gerenderde HTML viapage.content(), en hergebruiktextractPrice()uitsrc/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): browser starten vóór het doorlopen van de producten, altijd sluiten
in een finally — nooit een browser die de hele dag blijft openstaan.
Alle producten die binnen die ene run een fallback nodig hebben delen
dezelfde browser-instance (nieuwe pagina per product).
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.gotofaalt of timet uit (timeoutMs, default 20000ms) → fallback retourneert{price: null, method: null, error}, exact zelfde vorm als de bestaandefetchAndExtractPrice-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.gotodie faalt/timet uit, en datpage.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 zonderfetchAndExtractPriceViaBrowserin 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.