← Terug naar overzicht

TechTracker

Een dagelijkse price-tracking pipeline voor audioproducten (Python + Postgres), met een Next.js-frontend die prijsgeschiedenis, AI-productbeschrijvingen, met semantic search functionaliteit.

Screen capture van TechTracker
  • Python
  • PostgreSQL
  • pgvector
  • TypeScript
  • Next.js
  • OpenAI API

Probleem & doel

Ik wilde een end-to-end data-project bouwen: een pipeline die dagelijks zelfstandig draait, data van betekenis verzamelt, en die data vervolgens bruikbaar maakt via een web-applicatie. TechTracker volgt dagelijks prijzen van koptelefoons, oordopjes, speakers en soundbars bij een Nederlandse webshop, bouwt daar een betrouwbare prijsgeschiedenis mee op, en laat gebruikers prijsdalingen zien. Op dit moment gaat het om zo’n 700 producten, verdeeld over die vier categorieën.

Demo

Twee screen captures van de belangrijkste features:

  • Productpagina
    Prijsinformatie, de AI-gegenereerde “Prijs inzicht” en “Over dit product” teksten en prijsgeschiedenis, in één oogopslag.
Productpagina van TechTracker met prijsgeschiedenis-grafiek en AI-gegenereerde teksten
  • Semantic search
    Met een zoekopdracht in gewone taal krijg je te zien welk product het best past bij je wensen, met een AI-samenvatting waarom dit de beste match is.
Semantic search op TechTracker: een zoekopdracht in gewone taal levert de beste match met AI-samenvatting op

Tech stack

De stack is verdeeld naar taak: Python voor alles wat met scraping en data-verwerking te maken heeft, TypeScript/Next.js voor de webapplicatie, en Postgres als gedeelde bron van waarheid daartussenin.

  • Python — de scraping- en pipeline-laag (requests, BeautifulSoup, psycopg2)
  • PostgreSQL (Railway) — centrale opslag voor producten, prijsgeschiedenis en gedetecteerde deals, met de pgvector-extensie voor de productembeddings
  • TypeScript / Next.js (Vercel) — de webapplicatie: server-rendered pagina’s, Server Actions en een streamende Route Handler voor de AI-samenvatting
  • OpenAI APItext-embedding-3-small voor embeddings, gpt-4o-mini voor de semantic-search-samenvatting (zie AI-features)
  • Anthropic API — Claude Haiku voor productbeschrijvingen en prijsinzichten (zie AI-features)
  • Telegram Bot API — uitgaande price-drop-alerts
  • Upstash Redis — rate limiting op de semantic-search-endpoint

Architectuur

Het project bestaat uit twee losse repositories met elk hun eigen verantwoordelijkheid:

product_scraper (Python)                price-tracker-web (Next.js/TypeScript)
─────────────────────────                ───────────────────────────────────────
discover_products.py  (wekelijks)         Server-rendered pagina's per categorie
scrape_price_history.py (dagelijks x2)    en productdetail
detect_drops.py       (inline)            AI-productbeschrijvingen + semantic search
send_alerts.py        (na daily run)      (OpenAI embeddings + pgvector)
        │                                          │
        └──────────────► PostgreSQL (Railway) ◄────┘
                          + pgvector-extensie

                          Telegram Bot API
                          (price-drop alerts)

Het databaseschema:

  • products — alle producten met naam, merk, categorie, specs, de AI-gegenereerde teksten en de embedding-vector
  • price_history — per product dagelijks één rij (prijs, beschikbaarheid); draagt daarmee het meeste datavolume
  • scrape_runs — audit log per pipeline-run
  • price_drops — alleen de rijen die aan de deal-regel voldoen

Daarbovenop staan een paar SQL-views die de deal-logica herbruikbaar maken in plaats van het telkens opnieuw te schrijven:

  • deal_candidates — combineert per product de actuele prijs, de 30-dagen-hoogste-prijs en de dalingslogica in één query; dit is de bron voor elke deal-weergave op de site
  • homepage_topdealsdeal_candidates, top 3 op grootste besparing, voor de “beste deals”-sectie op de homepage

Data pipeline

Dit is het fundament van het project: zonder betrouwbare, dagelijks bijgewerkte data heeft geen van de features op de site iets om op te bouwen.

  • Discovery (wekelijks) — crawlt categoriepagina’s, ontdekt nieuwe producten en verrijkt ze met metadata van de productpagina (specs, beschrijving, afbeelding).
  • Price scrape (dagelijks, tweemaal) — scraped prijs en beschikbaarheid voor alle actieve producten, schrijft dit idempotent weg naar price_history (een herhaalde run op dezelfde dag geeft geen dubbele records). Een recovery-run (--missed-only) vangt producten op die de eerste run miste.
  • Observability — elke run logt een samenvatting (aantal ontdekt, gescraped, ingevoegd, overgeslagen, gefaald, duur) naar scrape_runs; één falend product stopt de rest van de run niet.
  • Scraping-veiligheid — request-jitter (2,5–5s), timeouts, retry-met-backoff op 429/5xx-fouten, een maximum aantal pagina’s per crawl, en een dagelijkse (niet continue) cadans.

Heeft van 1 maart t/m 22 augustus 2026 dagelijks gedraaid, aanvankelijk met 1 categorie (~200 producten), sinds 3 mei met alle 4 (~700-800 producten). Van de 174 dagelijkse runs in die periode hadden er 82 de status “partial” (minstens 1 product mislukt, nooit meer dan 12 van de ~700-800) — stuk voor stuk volledig opgevangen door de recovery-run, zonder handmatig ingrijpen.

Website

De site zelf bestaat uit vier pagina-types, allemaal server-rendered vanuit dezelfde databaselaag:

  • Homepage — de “Zoek met AI”-sectie, de top 3 deals van dit moment (homepage_topdeals) en een overzicht van de vier categorieën met aantal producten en aantal deals per categorie.

  • Categoriepagina (/koptelefoons, /oordopjes, …) — een grid met alle producten in die categorie, met merk- en prijsfilters en sortering. Elke kaart krijgt automatisch een deal-badge zodra het product aan de deal-regel voldoet.

    Categoriepagina van TechTracker met merk- en prijsfilters en deal-badges
  • Deals-pagina (/deals) — alle actuele deals (deal_candidates), met tabs per categorie en dezelfde merk-/prijsfilters als de categoriepagina’s, client-side gefilterd zodat de pagina statisch kan blijven.

    Deals-pagina van TechTracker met tabs per categorie
  • Productpagina — prijsblok met link naar de webshop, de AI-“Prijs inzicht”-kaart (alleen zichtbaar als het product op voorraad is), de AI-productbeschrijving, de prijsgeschiedenis-grafiek (30/60/90 dagen) en een specificatietabel per categorie.

AI-features

1. AI-productbeschrijvingen. Bij elke discovery genereert de scraper eenmalig een neutrale, feitelijke productbeschrijving (2-3 zinnen, Claude Haiku) op basis van de gescrapete specificaties (ai_description). Zonder specs wordt er niets gegenereerd, liever geen tekst dan een tekst die iets verzint. Deze beschrijving staat op de productpagina in de “Over dit product”-kaart.

2. Prijsinzichten. Een automatisch gegenereerde tekst die de huidige prijs in de context van de prijsgeschiedenis plaatst: is dit de laagste prijs in 90 dagen, de laagste in 30 dagen, de hoogste in 30 dagen, of gewoon een schommeling? Het resultaat (ai_deal_description) staat op de productpagina in een blauwe “Prijs inzicht”-kaart. Hoe die tekst tot stand komt (een hybride van een vast template en een LLM-call) staat beschreven onder de belangrijke beslissingen.

3. Semantic search. Een gebruiker beschrijft in gewone taal wat die zoekt (bijv. “draadloze koptelefoon voor sport met goede pasvorm”). De query wordt via OpenAI’s text-embedding-3-small omgezet naar een 1536-dimensionale vector, die met pgvector (cosine similarity) wordt vergeleken met vooraf berekende embeddings van alle producten. De beste match krijgt een uitgelichte “Beste match”-banner met de bijbehorende AI-beschrijving (zie de screen capture bij Demo).

4. AI-samenvatting van de “Beste match”. Na een zoekopdracht genereert een tweede AI-call een korte alinea die vertelt waarom de beste match is gekozen. Deze tekst is gegrond op de echte resultaatdata (naam, merk, prijs, voorraad, beschrijving) om hallucinatie te voorkomen.

Telegram-alerts

Los van de website draait er nog een kleine zijtak: detect_drops.py vergelijkt na elke scrape de nieuwe prijs met de vorige en slaat dalingen die voldoen aan bepaalde criteria (≥5% daling, prijs ≥€150) op in price_drops. send_alerts.py stuurt die als Telegram-bericht. Dit is niet de logica achter de deal-indicator op de site zelf: die draait, zoals hierboven beschreven, rechtstreeks op de deal_candidates-view.

Belangrijkste beslissingen & afwegingen

Data Pipeline

  • Recovery-run vult alleen gaten, scraped nooit alles opnieuw. Een enkele dagelijkse pass over ~700 producten kent altijd een paar mislukte scrapes (tijdelijke netwerkfout, een keer geblokkeerd). In plaats van de hele scrape een tweede keer te draaien (dubbele belasting op de bron en dubbele looptijd, ook voor producten die al gelukt zijn) selecteert de recovery-run (--missed-only) uitsluitend producten zonder price_history-rij voor vandaag. De query is simpelweg “ontbreekt er een rij voor vandaag”: geslaagde producten worden met rust gelaten, nooit opnieuw gescraped of overschreven.
  • Specs als JSONB, niet als vaste kolommen. De vier categorieën delen een deel van hun specificaties, maar niet allemaal — en zelfs schijnbaar vergelijkbare velden verschillen soms in vorm (bv. “Waterdichtheid” als tekstwaarde vs. “Waterbestendig” als ja/nee). In plaats van één gedeeld kolommenschema te forceren, staan specs per categorie in een JSONB-kolom met hun eigen sleutel/waarde-paren. Uniformering is daarmee een presentatievraagstuk voor de frontend, geen opslagbeperking vooraf.
  • Gebruik van een active-vlag voor producten die niet meer beschikbaar zijn. Een product wordt op active = false gezet bij drie opeenvolgende 404’s, of een langdurige “niet op voorraad”-periode. Producten worden nooit hard verwijderd, want de prijsgeschiedenis blijft waardevol. Wel stopt de dagelijkse price-scrape voor zo’n product, dus er komen geen nieuwe price_history-rijen meer bij. Op de site betekent dit: het product verdwijnt volledig uit categorie- en deals-overzichten (die filteren op active = true). Een product wordt weer actief zodra het weer opduikt bij de wekelijkse discovery.

AI functionaliteit

  • Template-zin voor de meerderheid, LLM voor de uitzondering. Bij elke prijswijziging bepaalt de scraper eerst of er iets bijzonders aan de hand is: een 90- of 30-dagenrecord, of gewoon een schommeling? Voor die laatste (zo’n 53% van de dagelijkse prijswijzigingen) schrijft de scraper een vaste template zin, zonder LLM-call: geen kosten, en de tekst klopt toch. Alleen bij een echt noemenswaardig moment wordt de LLM aangeroepen, met een sterk ingeperkte prompt (exacte cijfers, geen verzonnen tijdsperiodes, vaste prijsnotatie) om afwijkende tekst te voorkomen. Zo blijven de kosten laag zonder in te leveren op kwaliteit bij de veranderingen die er echt toe doen.
  • Prijs staat niet in de embedding. Embeddings worden nooit opnieuw gegenereerd, terwijl prijzen dagelijks veranderen. Een prijs in de vector zou blijvend verouderen. Prijs en merk lopen daarom als harde SQL-filters naast de zoekopdracht, niet erin.
  • Een vaste relevantiedrempel, met een uitzondering voor korte zoekopdrachten. Een cosine-distance-drempel filtert irrelevante resultaten weg, maar korte categorienamen als “speaker” vielen er soms net buiten. Oplossing: query’s met een bekend categorie- of merkwoord slaan de drempel over, zonder het te verzwakken voor echt irrelevante zoekopdrachten.

Lessen & vervolg

Robuuste error handling, op twee niveaus. Binnen één run krijgt elk product zijn eigen foutafhandeling: een 404 (product weg) wordt direct geaccepteerd, zonder retry; een tijdelijke fout (timeout, 5xx) krijgt wel een nieuwe poging, met backoff. Elk product heeft bovendien zijn eigen database-transactie, zodat één mislukking de rest van de run niet blokkeert.

Tussen runs zat de grootste verbetering. De eerste versie greep pas in bij een grote mislukking (meer dan 20% van de producten in één run) en plande dan een retry na 1, 2 en 4 uur. Zo’n retry scrapete echter alle producten opnieuw, ook de producten die al gelukt waren. Dit was onnodig, want die stonden al goed. Dit terwijl de veelvoorkomende situatie van een paar losse mislukte producten (ruim de helft van de dagen, nooit meer dan een paar procent) viel onder die 20%-drempel en dus helemaal geen retry kreeg. Vervangen door een simpelere aanpak: een aparte dagelijkse recovery-run die checkt welke producten nog geen price_history-rij voor vandaag hebben, en alleen díe opnieuw scrapet — ongeacht hoe klein of groot het aantal is.

Front-end-wijzigingen beginnen vaak in de back-end. Grotere front-end-features bleken telkens eerst een back-end-voorbereiding nodig te hebben. Productpagina’s toevoegen vroeg om een nieuwe slug-kolom, vooraf gevuld via een backfill-script. De AI-“Prijs inzicht”-feature moest worden ingehaakt in de bestaande dagelijkse price-scrape, zonder die te breken. De AI-productbeschrijvingen zijn eerst in de wekelijkse discovery-logica gebouwd en pas daarna in bulk gevuld voor alle bestaande producten.

Wat ik anders zou doen:

  • Een evaluatielaag voor de AI-features ontbreekt — ik heb bijvoorbeeld nooit systematisch gemeten hoe goed de semantic search daadwerkelijk presteert.
  • Kritischer zijn over wélke AI-generatie daadwerkelijk nodig is: elke prijswijziging een LLM-call laten genereren kost geld, ook bij een standaard schommeling zonder nieuwswaarde (vandaar uiteindelijk de template-vs-LLM-afweging, zie hierboven).
  • Twee architecturale beperkingen die ik nu bewust laat staan: 1. Alles is gebouwd rond één retailer (een tweede retailer vergelijken zou een gedeelde product-identiteit, een retailer-dimensie in price_history, en een herbouwde front-end vereisen). 2. Er wordt maar één prijs per dag vastgelegd, terwijl prijzen soms binnen een dag meerdere keren wijzigen.

Van live product naar showcase

TechTracker is oorspronkelijk opgezet met een affiliate-verdienmodel in gedachten. Bij het uitwerken daarvan liep ik tegen vraagstukken rond auteursrecht, databankenrecht en affiliate-voorwaarden. Dit vereiste een zorgvuldigere aanpak dan in dit stadium paste — denk aan een overstap naar een officiële product-feed in plaats van scraping. Ik heb er daarom voor gekozen het project als technische showcase te laten staan, zonder actief verdienmodel.

De productie-infrastructuur die hierboven beschreven staat (Railway, Vercel, de dagelijkse cronjobs) heb ik inmiddels afgebouwd — het project draait nu lokaal, met een volledige snapshot van de verzamelde data. De pipeline en de website functioneren nog steeds precies zoals hierboven beschreven; alleen de hosting is anders. De code (inclusief de volledige commit-historie) staat hieronder.

Repository

De code is verdeeld over twee repositories: