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

126 lines
6.0 KiB
Markdown

# 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): 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.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.