From 1d3d1caeb12af2cded43f58636d3ae9b9a2a98fd Mon Sep 17 00:00:00 2001 From: Sjoerd de Vries Date: Sat, 5 Sep 2026 17:18:23 +0200 Subject: [PATCH] Add design spec for Playwright fallback scraping Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_013gwc3aCiyQxmCWQke8RvEz --- .claude/scheduled_tasks.lock | 1 + ...9-05-korting-playwright-fallback-design.md | 125 ++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 .claude/scheduled_tasks.lock create mode 100644 docs/superpowers/specs/2026-09-05-korting-playwright-fallback-design.md diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock new file mode 100644 index 0000000..875e463 --- /dev/null +++ b/.claude/scheduled_tasks.lock @@ -0,0 +1 @@ +{"sessionId":"9f5baf12-a8b2-498c-93b8-0f8b93ea8913","pid":256372,"procStart":"41273235","acquiredAt":1788612906471} \ No newline at end of file diff --git a/docs/superpowers/specs/2026-09-05-korting-playwright-fallback-design.md b/docs/superpowers/specs/2026-09-05-korting-playwright-fallback-design.md new file mode 100644 index 0000000..efa3e50 --- /dev/null +++ b/docs/superpowers/specs/2026-09-05-korting-playwright-fallback-design.md @@ -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` — 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.