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