← Blog

Data scraping

Data scraping herbouwd: 17 portals op Playwright + LLM

Een Rotterdamse foodimporteur met 44 medewerkers draaide een Selenium-scraper die om de week omviel. We herbouwden 'm op Playwright plus een LLM-extractor. Inkopers jagen geen prijzen meer om 7 uur.

Jacob Molkenboer· Oprichter · A Brand New Company· 9 jun 2026· 9 min
Leren leveranciersboek open op ivoren bureau, waaier papieren prijskaartjes met linnen touw, groen lint, rode lakzegel.

Het alarm van 6:47

Marije runt de dagelijkse operatie bij een Rotterdamse foodimporteur met 44 medewerkers. Ze is 36 minuten ver in haar dinsdag als haar telefoon trilt. De nachtelijke scraper, een Selenium-job die dagprijzen ophaalt uit zeventien leveranciersportals, is op drie ervan omgevallen. Eén daarvan is de grootste leverancier van verse vis. Haar inkopers beginnen om zeven uur. Vanaf half acht gaat de telefoon naar restaurants in de Randstad.

Ze weet wat haar te doen staat. Ze heeft het dit jaar al twintig keer gedaan. Laptop open, één voor één inloggen op elk portal, de prijskolommen kopiëren naar een gedeelde spreadsheet, en de spreadsheet plakken in het ERP. Per portal duurt het ongeveer dertig minuten als er niks geks gebeurt. Drie portals betekent dat een inkoper eerst iets anders gaat doen.

Dat was de stand van de scraper in februari. De brief van haar CFO in maart: we willen geen mens in deze loop, en we willen ook geen verkeerde prijzen.

De originele Selenium-stack

De vorige build was prima voor zijn tijd. Python, Selenium 4, een headless Chrome met een eigen Dockerfile, persistente cookies in een volume mount, een cron om 03:00 Amsterdam. Elk portal had zijn eigen handgeschreven scraper, gemiddeld zo'n 180 regels, die door de login klikte, de cookiebanner wegklikte, de "ja, ik wil verder"-tussenpagina, de datumkiezer, en dan prijzen uit een tabel las of een CSV downloadde.

Het werkte. Tot het niet meer werkte. Selectors waren het eerste dat ging rotten. Daarna begonnen portals Cloudflare Turnstile voor de login te zetten. Twee van de grotere leveranciers gingen op de schop. Een regionale verstopte zijn prijsblad ineens achter een PDF-generator in plaats van een HTML-tabel.

Per maand vielen er ongeveer twee portals om. Elke storing kostte ruwweg een uur dev-tijd om te patchen, plus een onbekende hoeveelheid inkoperstijd terwijl die patch geschreven werd. We hadden de cijfers van de afgelopen twaalf maanden: 27 incidenten, 41 dev-uren, en naar schatting 60 inkopersuren.

De opdracht voor de herbouw

De CFO vroeg niet om een herschrijving. Ze vroeg om twee dingen: minder telefoontjes om 6:47, en geen onverwachte margeschade door een verkeerd gelezen decimaal. Daar maakten we drie regels van.

  1. Overleef een portal-redesign zonder code-aanpassing, in elk geval meestal.
  2. Faal hard als prijzen niet kloppen. Post nooit een prijs waar het systeem niet zeker van is.
  3. Houd een audit trail bij van elke waarde die in het ERP belandt, met de screenshot en de ruwe extractie erbij.

Die drie klinken als productpraatjes, maar ze bepaalden de architectuur.

Playwright als browserlaag

We vervingen Selenium door Playwright. De redenen zijn inmiddels bekend. Auto-waiting haalt de bekendste klasse "het werkt lokaal"-bugs weg. De trace viewer maakt intermittente fouten debugbaar in plaats van mysterieus. Storage state kun je per portal serialiseren, waardoor login-flows één keer per week draaien in plaats van één keer per nacht, en dat scheelt een hoop CAPTCHAs.

De interessante keuze was niet Playwright boven Selenium. Het was om elk portal te behandelen als een dunne "navigeer naar de prijspagina"-functie, en niks meer. Geen tabel-parsing, geen CSV-opschoning, geen "vind de kolom met kg in de header". Dat ging allemaal naar de volgende fase.

Zo ziet een portal driver eruit in het nieuwe systeem:

// portals/koelhandel-westland.js
export const portal = {
  id: 'koelhandel-westland',
  schedule: '0 3 * * 1-5',
  async goToPriceSheet(page) {
    await page.goto('https://portaal.koelhandel-westland.nl/login');
    await page.getByLabel('Klantnummer').fill(process.env.KW_USER);
    await page.getByLabel('Wachtwoord').fill(process.env.KW_PASS);
    await page.getByRole('button', { name: 'Inloggen' }).click();
    await page.getByRole('link', { name: /dagprijzen/i }).click();
    await page.waitForLoadState('networkidle');
    return {
      kind: 'html',
      html: await page.content(),
      screenshot: await page.screenshot({ fullPage: true }),
    };
  },
};

Twintig tot veertig regels per portal in plaats van honderdtachtig. Als het formaat van een prijspagina verandert, raken we dit bestand niet aan. De driver mag dom zijn. Het is de taak van de extractor om met de layout om te gaan.

De LLM-extractor

Hier verschoof het werk naartoe. De extractor krijgt één van drie inputs (gerenderde HTML, een PDF-byte stream, of een Excel-bestand) en geeft een strikt JSON-object terug dat aan een schema voldoet. Het schema is het contract tussen deze fase en alles wat erna komt.

// extractor/schema.ts
export const PriceSheet = {
  type: 'object',
  required: ['portal_id', 'sheet_date', 'currency', 'lines'],
  properties: {
    portal_id: { type: 'string' },
    sheet_date: { type: 'string', format: 'date' },
    currency: { type: 'string', enum: ['EUR', 'USD'] },
    lines: {
      type: 'array',
      items: {
        type: 'object',
        required: ['supplier_sku', 'description', 'price', 'unit'],
        properties: {
          supplier_sku: { type: 'string' },
          description: { type: 'string' },
          price: { type: 'number' },
          unit: { type: 'string', enum: ['kg', '100kg', 'piece', 'crate'] },
          vat_included: { type: 'boolean' },
          notes: { type: 'string' },
        },
      },
    },
  },
};

We gebruiken een model met native structured output (het schema wordt afgedwongen op API-niveau, niet door een post-processor). De prompt zelf is korter dan mensen verwachten, omdat het schema het meeste werk doet. Ruwweg:

Haal elke productregel uit het meegestuurde prijsblad. Gebruik het schema. Lees eenheden zorgvuldig. Als een prijs per 100 kg is, zet unit op 100kg en deel niet. Niet normaliseren, niet converteren. Sla headers, footers, totalen en commentaarregels over.

Dat "niet normaliseren" is belangrijk. Het model is goed in lezen. Het is onbetrouwbaar in eenheidsconversie als binnen één blad de eenheden door elkaar lopen. Conversie hoort in code, waar je het kunt testen.

Normalisatie in code, niet in de prompt

Zodra de JSON terug is, draait er een deterministische pass:

  • Reken elke prijs om naar EUR per kilo. De regels staan in een YAML per portal, zodat een inkoper ze kan corrigeren zonder deploy.
  • Trek de BTW eraf als het portal die meerekent (in het ERP staan netto-prijzen).
  • Map supplier_sku naar onze interne SKU via een lookup-tabel. Onbekende SKUs worden niet stilletjes "nieuwe producten". Die gaan naar een queue.
  • Valideer dat de prijs binnen een band valt op basis van de mediaan over de afgelopen 30 dagen voor die SKU. De standaardband is plus of min 35 procent, per categorie aangescherpt (verse vis is breder dan tomaten in blik).
// pipeline/normalize.ts
function toEurPerKg(line: ExtractedLine, rules: PortalRules) {
  let priceEur = line.price;
  if (rules.currency !== 'EUR') priceEur = fx(rules.currency, 'EUR', priceEur);
  if (line.vat_included) priceEur = priceEur / (1 + rules.vatRate);
  const perKg = {
    kg: priceEur,
    '100kg': priceEur / 100,
    piece: priceEur / rules.pieceWeightKg(line.supplier_sku),
    crate: priceEur / rules.crateWeightKg(line.supplier_sku),
  }[line.unit];
  return Number(perKg.toFixed(4));
}

De unit-test-suite voor dit bestand is groter dan de hele scraper uit 2022.

Posten naar het ERP

Hun ERP heeft een REST API voor inkoopprijslijsten. We posten in batches per portal, één transactie per blad, met de screenshot in object storage en de URL ervan aan de regel gehangen. Elke geposte regel draagt drie identifiers: het portal-id, het run-id van de extractie, en de SHA-256 van de ruwe input. Mocht ooit iets gereproduceerd moeten worden, dan kan het.

await erp.priceLists.create({
  supplierId: portal.erpSupplierId,
  validFrom: sheet.sheet_date,
  evidenceUrl: screenshotUrl,
  extractionRunId: run.id,
  lines: lines.map((l) => ({
    sku: l.internal_sku,
    pricePerKgEur: l.normalized_price,
    sourceHash: l.source_hash,
  })),
});

Wat telt, is de evidenceUrl. Als een inkoper een prijs opvraagt, kan ze doorklikken naar de echte screenshot van de echte portalpagina op de echte dag. We leerden op de harde manier dat "de AI heeft het eruit gehaald" geen antwoord is dat iemand in operations op zichzelf accepteert.

Drift-detectie en de queue

De twee faalmodi waar we ons zorgen om maakten waren stille fouten en luide niets-resultaten. Stille fouten (een verkeerd gelezen decimaal, kilo's versus 100 kg door elkaar, een verouderde pagina uit een cache) worden gevangen door de band-check. Luide niets-resultaten (een portal-redesign waar de driver niet meer doorheen komt) worden gevangen door een Playwright-timeout op de navigatie.

Beide eindigen in dezelfde queue. Eén Slack-kanaal, één bericht per portal per dag, óf een groen vinkje óf een gele vlag met de screenshot en de ruwe extractie erbij. Marije ziet zo'n twee vlaggen per week. De meeste zijn binnen vijf minuten opgelost (ze klikt op "accept" en de prijs wordt geboekt, of op "investigate" en een inkoper neemt over).

Kernpunt

Het model leest. Code converteert. Een mens oordeelt. Elke laag mag eerlijk slecht zijn in de andere twee.

Negentig dagen verder

Een paar cijfers uit de eerste drie maanden in productie, gemeten tegen de twaalf maanden Selenium-tijdperk ervoor.

  • Portals die onbemand draaien: 17, op van 11.
  • Incidenten die dev-werk vroegen: 4, omlaag van een ritme van ongeveer 7 per kwartaal.
  • Portal-redesigns overleefd zonder code-aanpassing: 3 van de 4 die voorkwamen. De vierde (een login-flow die naar SMS-OTP verhuisde) kostte twee uur.
  • Gemiddelde extractietijd per dag: 14 minuten voor alle portals parallel.
  • Inkoperstijd besteed aan prijzen overtikken: nul, omlaag van geschat 60 uur per jaar.
  • Verkeerd geboekte prijzen: nul gedetecteerd. We hadden één bijna-misser die de band-check ving (een portal stuurde tijdelijk een EUR/100kg-blad met de EUR/kg-header, de band sloeg aan, Marije belde de leverancier om het te bevestigen).

Wat ons achteraf verraste, was hoeveel van de waarde in het ontwerp van de queue zat in plaats van in het model. De extractor doet, eerlijk gezegd, makkelijk werk. Het echte werk zat in beslissen wat te doen als hij fout zit, en "fout" goedkoop maken.

Waarschuwing

Als je dit bouwt: laat het model niet normaliseren. Vraag het om te lezen, niet om te converteren. Elke eenheidsconversie in een prompt is een bug die wacht op de maandag na een lang weekend.

Wat we anders zouden doen

Als we de herbouw opnieuw zouden doen, zouden twee dingen anders gaan.

Ten eerste zouden we de band-check-regels per categorie vanaf dag één opschrijven, in plaats van te beginnen met een globale plus of min 35 procent en die later aan te scherpen. De globale default liet in week twee twee kleine missers door. Niks fundamenteels, maar het vertrouwen brokkelde twee weken af.

Ten tweede zouden we niet zes weken lang onze eigen retry-logica bovenop Playwright bouwen. Playwrights trace viewer plus vijf regels retry-with-backoff is genoeg. We hebben een hele failure-taxonomie overgebouwd en daarna weer weggegooid. De les, opgeschreven zodat we 'm niet steeds opnieuw leren: het model is het goedkope deel van zo'n pipeline, en de plumbing eromheen is waar het budget heen gaat.

Toen we de extractie- en automatiseringspipeline bouwden voor de Rotterdamse importeur, was het lastigste dat elk leveranciersportal BTW anders uitdrukte en dat de inkopers de regels nooit hadden opgeschreven. We hebben dat opgelost door een middag bij twee inkopers te zitten en hun stille kennis om te zetten in de YAML-bestanden die de normalisatie aansturen. De scraper was de makkelijke helft.

Het kleinste dat je vandaag kunt doen: open je eigen broze scraper, zoek het bestand met de meeste if portal == ...-takken erin, en vraag je af of die layoutkennis kan verhuizen naar een schema-gebonden modelaanroep en uit je code. Zo ja, dan volgt de rest van de herbouw uit die ene beslissing.

Kern

Het model leest, code converteert, een mens oordeelt. Maak 'fout' goedkoop en de rest van de pipeline volgt uit die ene beslissing.

FAQ

Waarom Playwright in plaats van Selenium voor dit soort werk?

Auto-waiting haalt de meeste 'werkt-lokaal'-bugs weg, de trace viewer maakt intermittente fouten debugbaar, en met storage state sla je nachtelijke re-logins over. Selenium kan dit ook, met meer code.

Waarom laat je het model de eenheidsconversie niet ook doen?

Modellen zijn onbetrouwbaar in rekenen als binnen één blad EUR/kg en EUR/100kg door elkaar staan. Wij laten het model lezen en laten deterministische code converteren. De conversielaag is het meest geteste deel van de pipeline.

Hoe voorkom je dat een verkeerde prijs in het ERP belandt?

Elke regel wordt voor het boeken getoetst aan een mediaan-band van 30 dagen per categorie. Alles wat buiten de band valt gaat naar een Slack-queue met screenshot en ruwe extractie. Een mens keurt goed of zoekt het uit.

Wat gebeurt er als een leveranciersportal helemaal opnieuw wordt ontworpen?

De meeste redesigns overleven, omdat de layoutkennis in de extractor zit en niet in de code. Als de login of navigatie verandert, moet de Playwright-driver wel aangepast worden, meestal binnen twee uur.

data scrapingautomationcase studyintegrationsprocess automationworkflow

Iets bouwen?

Start een project