# 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): 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.