Files
korting/docs/superpowers/specs/2026-09-05-korting-prijstracker-design.md
T
2026-09-05 14:30:28 +02:00

4.5 KiB

Korting — prijstracker design

Datum: 2026-09-05 Status: Approved

Doel

Een webdienst die dagelijks een lijst productpagina's bezoekt, de huidige prijs extraheert, en een email stuurt wanneer de prijs is veranderd sinds de vorige check. Gehost op Coolify, bereikbaar op korting.sjoerd.app, repo in Gitea (git.pietpiraat.online).

Architectuur

Eén Node.js/Express monoliet in één Docker-container:

  • server.js — Express app, basic-auth middleware, web-UI routes
  • db.js — SQLite setup + queries (better-sqlite3)
  • scraper.js — haalt een pagina op (fetch) en extraheert de prijs (cheerio)
  • mailer.js — verstuurt email via nodemailer (SMTP)
  • scheduler.jsnode-cron job, dagelijks, doorloopt alle producten
  • views/ — server-rendered HTML (geen frontend-framework)

Geen aparte services: scheduler draait in-process in dezelfde container als de webserver. SQLite-bestand staat op een persistent Coolify volume zodat data een redeploy overleeft.

Data model

Tabel products:

kolom type omschrijving
id integer PK
name text leesbare naam
url text productpagina-URL
last_price real, nullable laatst bekende prijs
last_checked_at datetime, nullable tijdstip laatste (succesvolle) check
last_check_status text, nullable bijv. ok / not_found / fetch_error
created_at datetime

Geen aparte prijshistorie-tabel (YAGNI) — kan later toegevoegd worden als er behoefte is aan een grafiek.

Prijs-extractie (scraper.js)

Volgorde van pogingen per pagina:

  1. JSON-LD (<script type="application/ld+json">) met @type: Productoffers.price
  2. Meta tags: meta[property="product:price:amount"] of og:price:amount
  3. Fallback regex: eerste €\s?\d+[.,]\d{2} patroon in de zichtbare pagina-tekst

Als niets oplevert: check telt als mislukt (last_check_status = 'not_found'), zichtbaar in de UI naast het product. Geen mail, geen wijziging aan last_price.

Scheduling & email

  • node-cron, schema 0 8 * * *, tijdzone Europe/Amsterdam.
  • Extra "nu controleren" knop in de UI om de hele check-run handmatig te triggeren (zelfde codepad als de cron-job).
  • Elk product wordt onafhankelijk verwerkt (try/catch per product) — één mislukte check blokkeert de andere producten niet.
  • Eerste keer dat een product wordt toegevoegd (last_price IS NULL): alleen baseline opslaan, geen mail.
  • Bij prijsverschil (nieuwe prijs ≠ last_price, en last_price niet null): mail versturen met onderwerp Prijs gewijzigd: {naam}, body met oude prijs, nieuwe prijs, en link naar het product.
  • Belangrijk: last_price (en last_checked_at) wordt in de database alleen bijgewerkt als de mail succesvol is verstuurd (of als er geen mail nodig was, bijv. baseline of geen wijziging). Zo blijft het systeem de volgende dag opnieuw proberen te mailen als er een tijdelijk SMTP-probleem was, in plaats van de wijziging stilzwijgend te verliezen.
  • Mailer config via env vars: SMTP_HOST (mail.sjoerd.app), SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_FROM, NOTIFY_EMAIL (ontvanger).

Beveiliging

  • Basic auth over alle routes (UI + endpoints) via env vars BASIC_AUTH_USER / BASIC_AUTH_PASS.

Deployment

  • Dockerfile: node:22-slim, production deps only, CMD ["node", "server.js"].
  • Persistent volume gemount op /data; SQLite-pad via env var DB_PATH (default /data/korting.db).
  • Repo aangemaakt in Gitea (git.pietpiraat.online).
  • Coolify-applicatie gekoppeld aan die repo, domain korting.sjoerd.app, env vars zoals hierboven, auto-deploy on push. Uitgevoerd via de deploying-to-coolify skill.

Error handling (samengevat)

situatie gedrag
netwerkfout bij ophalen productpagina gelogd, last_check_status = 'fetch_error', geen crash van de run
geen prijs gevonden op pagina gelogd, last_check_status = 'not_found', geen mail, last_price blijft ongewijzigd
SMTP-fout bij versturen mail gelogd, last_price blijft ongewijzigd zodat de volgende run opnieuw probeert te mailen

Testing

  • Unit tests voor scraper.js (Node's ingebouwde test runner) tegen HTML-fixtures: JSON-LD-pad, meta-tag-pad, regex-fallback-pad, en het "niets gevonden"-pad.
  • Handmatige verificatie van de volledige mail-flow via de "nu controleren"-knop na deploy.

Out of scope (mogelijke latere uitbreiding)

  • Headless browser (Playwright) als fallback voor pagina's die de prijs pas via JavaScript renderen.
  • Prijshistorie/grafiek per product.