# 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` — 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.