From da0e79e5096d4bc2535c79b68f9f7c5637ec45d4 Mon Sep 17 00:00:00 2001 From: Ingrihka Date: Fri, 2 Oct 2026 14:08:17 +0200 Subject: [PATCH] docs: utforsk Kassalapp API og dokumenter funn (#6) --- backend/src/scripts/explore-kassalapp.ts | 78 +++++++++++++++++ docs/kassalapp.md | 105 +++++++++++++++++++++++ 2 files changed, 183 insertions(+) create mode 100644 backend/src/scripts/explore-kassalapp.ts create mode 100644 docs/kassalapp.md diff --git a/backend/src/scripts/explore-kassalapp.ts b/backend/src/scripts/explore-kassalapp.ts new file mode 100644 index 0000000..9301ecb --- /dev/null +++ b/backend/src/scripts/explore-kassalapp.ts @@ -0,0 +1,78 @@ +// Utforsker Kassalapp-dataene for issue #6. +// Kjør fra backend-mappen: npx tsx src/scripts/explore-kassalapp.ts +import 'dotenv/config'; + +const KEY = process.env.KASSALAPP_API_KEY; +if (!KEY) throw new Error('KASSALAPP_API_KEY mangler i backend/.env'); + +const BASE = 'https://kassal.app/api/v1/'; +const PAGES = 5; // 5 sider à 100 varer +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +async function get(path: string): Promise<{ status: number; body: any }> { + const res = await fetch(BASE + path, { + headers: { Authorization: `Bearer ${KEY}`, Accept: 'application/json' }, + }); + const text = await res.text(); + let body: unknown = text; + try { body = JSON.parse(text); } catch { /* ikke JSON */ } + await sleep(1100); // holder oss under 60 kall i minuttet + return { status: res.status, body }; +} + +const count = (items: T[], key: (x: T) => string | undefined) => { + const m = new Map(); + for (const x of items) { + const k = key(x) ?? '(mangler)'; + m.set(k, (m.get(k) ?? 0) + 1); + } + return Object.fromEntries([...m].sort((a, b) => b[1] - a[1])); +}; + +// 1. /categories og /labels +for (const path of ['categories', 'labels']) { + const { status, body } = await get(path); + const data = body?.data ?? body; + console.log(`\n== /${path}: status ${status}, ${Array.isArray(data) ? data.length + ' elementer' : ''}`); + console.log(JSON.stringify(Array.isArray(data) ? data.slice(0, 5) : data, null, 1).slice(0, 1500)); + if (path === 'labels' && Array.isArray(data)) { + console.log('Alle label-navn:', data.map((l: { name: string }) => l.name).join(', ')); + } +} + +// 2. Noen sider med produkter +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const products: any[] = []; +for (let page = 1; page <= PAGES; page++) { + const { status, body } = await get(`products?size=100&page=${page}`); + if (status !== 200) { console.log('products side', page, 'status', status); break; } + products.push(...body.data); +} +const n = products.length; +const pct = (k: number) => `${k} av ${n} (${((100 * k) / n).toFixed(1)} %)`; +console.log(`\n== Produkter hentet: ${n}`); +console.log('Mangler EAN: ', pct(products.filter((p) => !p.ean).length)); +console.log('Mangler kategori: ', pct(products.filter((p) => !p.category?.length).length)); +console.log('Mangler bilde: ', pct(products.filter((p) => !p.image).length)); +console.log('Mangler pris: ', pct(products.filter((p) => p.current_price == null).length)); +console.log('Butikker:', count(products, (p) => p.store?.code)); +console.log('Labels:', count(products.flatMap((p) => p.labels ?? []), (l) => l.name)); +console.log('Toppkategorier:', count(products.filter((p) => p.category?.length), (p) => + [...p.category].sort((a, b) => a.depth - b.depth)[0].name)); + +// 3. Samme EAN i flere kjeder? +const eans = count(products.filter((p) => p.ean), (p) => p.ean); +const repeated = Object.entries(eans).filter(([, c]) => c > 1).length; +console.log(`\nEAN som går igjen i utvalget: ${repeated} av ${Object.keys(eans).length}`); + +const ean = '7038010014604'; // Norvegia 500 g +const { status, body } = await get(`products/ean/${ean}`); +const hits = body?.data?.products ?? body?.data ?? []; +console.log(`\n== /products/ean/${ean}: status ${status}`); +if (Array.isArray(hits)) { + console.log('Butikker med denne EAN-en:', hits.filter((p: { store?: unknown }) => p.store).map((p: { store?: { code: string }; current_price?: number | { price: number } }) => + `${p.store?.code} ${typeof p.current_price === "object" ? p.current_price?.price : p.current_price ?? "-"} kr`).join(', ')); +} else { + console.log(JSON.stringify(body).slice(0, 500)); +} diff --git a/docs/kassalapp.md b/docs/kassalapp.md new file mode 100644 index 0000000..06f6cdd --- /dev/null +++ b/docs/kassalapp.md @@ -0,0 +1,105 @@ +# Kassalapp API – funn (issue #6) + +Notater fra utforsking av `/api/v1/products`, `/products/ean/{ean}`, `/categories` og `/labels`. Grunnlag for databaseskjemaet (#7) og importskriptet (#8). + +## Tilgang + +- Autentisering: `Authorization: Bearer `. Nøkkelen ligger i `backend/.env` som `KASSALAPP_API_KEY` og committes aldri. +- Grense: 60 kall i minuttet. Svar `429 Too Many Requests` hvis den overskrides. +- `size` er maks 100 varer per kall. + +**Hvem trenger nøkkel?** Bare den som kjører importen (`scripts/import.ts`, #8). Importen lagrer dataene i en JSON-dump, og resten av gruppa fyller sin lokale database fra dumpen med `scripts/seed.ts`, uten nøkkel. Nettsiden og GraphQL-backenden snakker aldri med Kassalapp direkte: + +```text +Kassalapp API → import.ts → JSON-dump → seed.ts → PostgreSQL → GraphQL-backend → nettsiden +``` + +Trenger flere nøkkel (f.eks. for å kjøre `explore-kassalapp.ts`), lager de en gratis selv på kassal.app eller får den delt privat. Aldri i GitHub. + +Testkall: + +```bash +source backend/.env && curl -s -H "Authorization: Bearer $KASSALAPP_API_KEY" \ + "https://kassal.app/api/v1/products?search=norvegia&size=3" +``` + +## Felt på en vare + +| Felt | Eksempel | Bruk hos oss | +| --- | --- | --- | +| `id` | `1435` | Kassalapps ID. Unik per vare **per butikk** | +| `ean` | `7038010014604` | Strekkode. Samme vare i flere butikker har samme EAN | +| `name` | `Norvegia 26% Skorpefri 500g` | Navn | +| `brand`, `vendor` | `Norvegia`, `Tine sa` | Merke og leverandør | +| `image` | URL til bilde | Bilde (lastes fra deres server, lagres ikke hos oss) | +| `category` | liste med `id`, `name`, `depth` | Kategori i flere nivåer, se under | +| `description`, `ingredients` | tekst | Produktsiden | +| `current_price` | `89.9` | Pris i kr | +| `current_unit_price` | `179.8` | Kilo-/literpris | +| `weight`, `weight_unit` | `500`, `g` | Mengde | +| `store` | `{ name: "Joker", code: "JOKER_NO", logo }` | Butikken prisen gjelder | +| `allergens` | `code`, `display_name`, `contains` (`YES`/`NO`) | Allergener på produktsiden | +| `nutrition` | `code`, `display_name`, `amount`, `unit` | Næringsinnhold per 100 g | +| `labels` | `name`, `display_name`, `icon` | Merking, f.eks. `nyt_norge` | +| `price_history` | liste med pris og dato | Brukes ikke | +| `updated_at` | dato | Når prisen sist ble oppdatert | + +## Funn fra utforskingen + +Kjørt med `npx tsx src/scripts/explore-kassalapp.ts` (fra `backend/`), 500 varer (5 sider à 100). + +### Datakvalitet + +| Mangler | Andel | +| --- | --- | +| EAN | 23 av 500 (4,6 %) | +| Kategori | 59 av 500 (11,8 %) | +| Bilde | 0 av 500 | +| Pris | 0 av 500 | + +Varer uten EAN kan ikke slås sammen på tvers av kjeder, så de hoppes over i importen. Varer uten kategori samles under «Ukategorisert» (som beskrevet i #8). + +### Samme EAN per kjede + +- 500 varer ga bare 320 unike EAN-er, og 124 av dem gikk igjen i flere kjeder. +- `/products/ean/7038010014604` (Norvegia 500 g) ga treff i Joker, Spar, Meny, Oda, Engrossnett, Bunnpris og Kiwi. +- Konklusjon: lagre varen én gang per EAN, og pris i egen tabell med én rad per kjede. +- I EAN-endepunktet er `current_price` et objekt, ikke et tall som i `/products`. Må håndteres i importen. + +### Butikker + +I utvalget: Joker (169), Meny (154), Spar (146), Oda (31). EAN-oppslaget viser i tillegg Kiwi, Bunnpris og Engrossnett. + +### Kategorier + +- `/categories` gir en trestruktur med `id`, `parent_id` og `name` (f.eks. Frukt & grønt → Frukt → Sitrusfrukt). +- Kallet ga bare 10 kategorier, så endepunktet er trolig paginert. Må sjekkes i importen. +- På varene ligger kategorien som en liste med `depth` (laveste = toppnivå). +- **De første sidene av `/products` er skjeve:** 305 av 500 var Barneprodukter og 129 Dessert. Importen må derfor hente per kategori eller søkeord, ikke bare side 1, 2, 3 … + +### Merking (`/labels`, 48 stk.) + +Kobling til filtrene i designet: + +| Filter i designet | Labels | +| --- | --- | +| Økologisk | `debio`, `euroleaf`, `krav` | +| Norskprodusert | `nyt_norge` | +| Laktosefri | Finnes ikke som label. Kan utledes fra allergenet `melk` med `contains: "NO"` | + +Andre som kan være nyttige: `vegan`, `vegetarian`, `nokkelhull`, `frysevare`, `fairtrade`, `msc`. Emballasje-labels (`resin1–7`, `Papp`, `Plastemballasje` osv.) trenger vi ikke. + +### Paginering + +Svaret har `links.next`, men ikke totalt antall (`links.last` er `null`). Importen må hente til `next` er `null`. + +### Store svar + +`price_history` og `labels[].about` gjør hver vare stor. Vi lagrer bare feltene i tabellen over. + +## Åpne spørsmål til #7 og #8 + +- Hvilke kjeder tar vi med? Forslag: de kjedene vi har sett i dataene (Kiwi, Meny, Spar, Joker, Bunnpris, Oda). Rema 1000 og Coop har vi ikke sett ennå, så det må sjekkes om de finnes. +- Målet i #8 er minst 20 000 produkter. Siden samme vare kommer én gang per kjede, må importen trolig hente flere sider enn 200 for å få 20 000 unike EAN-er (100 per kall, ca. 1 kall i sekundet, altså noen minutter). +- Hvor stor blir JSON-dumpen, og skal den i git? Med bare feltene vi trenger og gzip bør den bli noen få MB. Blir den mye større, deles den heller i Teams. +- Er `/categories` paginert?