957555bdbe
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013gwc3aCiyQxmCWQke8RvEz
129 lines
6.2 KiB
Markdown
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.
|