Add design spec for Playwright fallback scraping
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013gwc3aCiyQxmCWQke8RvEz
This commit is contained in:
@@ -0,0 +1 @@
|
||||
{"sessionId":"9f5baf12-a8b2-498c-93b8-0f8b93ea8913","pid":256372,"procStart":"41273235","acquiredAt":1788612906471}
|
||||
@@ -0,0 +1,125 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user