Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions backend/src/scripts/explore-kassalapp.ts
Original file line number Diff line number Diff line change
@@ -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 = <T>(items: T[], key: (x: T) => string | undefined) => {
const m = new Map<string, number>();
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));
}
105 changes: 105 additions & 0 deletions docs/kassalapp.md
Original file line number Diff line number Diff line change
@@ -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økkel>`. 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?