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

129 lines
6.2 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) — **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.