diff --git a/.prettierignore b/.prettierignore index 47d7191..632fc01 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,3 +4,7 @@ node_modules dist coverage package-lock.json + +# Skrevet av npm run import:tools. Formatet bestemmes av skriptet, slik at en +# ny import ikke gir formateringsfeil. +backend/src/seed/data diff --git a/README.md b/README.md index ab575d4..230acd8 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,9 @@ I den ferdige løsningen ligger dataene i en database: Annonser, bookinger og vurderinger er de brukergenererte dataene som lagres. Eksempelannonsene seedes i PostgreSQL via `backend/` (samme innhold som mock). -Om vi henter produktdata fra en ekstern kilde, er ikke avklart. +For å teste skalering fyller `npm run seed:large` databasen med 10 000 +genererte annonser. Verktøytypene og merkene er ekte og hentet fra Wikidata, +mens selve annonsene genereres. Se [docs/datasett.md](docs/datasett.md). ### Planlagt videre @@ -212,6 +214,18 @@ npm run seed # forvent 0 nye annonser andre gang Forvent **10** rader i `listings`. Seed-data tilsvarer `src/data/mockListings.ts` (id `listing-001`–`listing-010`). +**7. Stort datasett:** + +```bash +npm run seed:large # 10 000 genererte annonser +npm run seed:large -- --count 50000 # annet antall +npm run seed:large -- --replace # erstatt tidligere genererte +``` + +Forvent **10 010** rader i `listings` etter første kjøring, og «0 nye» om du +kjører den på nytt. Tar under ett sekund for 10 000 annonser. Datakilde, +fordelinger og målinger står i [docs/datasett.md](docs/datasett.md). + **Bilder og deploy:** Seed lagrer `image_url` under `/project2/assets/listings/`. Det gjelder VM/Apache senere — se [deploy/apache/README.md](deploy/apache/README.md). Lokal frontend bruker fortsatt mock og importerte SVG-er. @@ -296,7 +310,8 @@ Vitest laster bare `backend/.env.test` (ikke `.env`). Hvis | `npm run format:check` | Sjekker formatering uten å endre filer | Backend (`cd backend`): `npm run dev`, `npm run start`, `npm run migrate`, -`npm run seed`, `npm run typecheck`, `npm test`. +`npm run seed`, `npm run seed:large`, `npm run import:tools`, +`npm run typecheck`, `npm test`. ESLint finner feil i koden, mens Prettier bestemmer formateringen. `eslint-config-prettier` ligger sist i `eslint.config.js` og slår av @@ -317,7 +332,7 @@ backend/ src/db/ Tilkoblingspool og repositories src/graphql/ GraphQL-schema, resolvers og API-tester src/scripts/ Seed og andre engangsskript - src/seed/ Seed-data for eksempelannonser + src/seed/ Seed-data, verktøymodeller fra Wikidata og generator for stort datasett src/server.ts Apollo Server (GraphQL) src/ types/ Felles datatyper, for eksempel Listing @@ -427,6 +442,7 @@ utlån, korte og lange titler, tomt tilbehør og annonser uten bilde. Se | [docs/prosjektstandard.md](docs/prosjektstandard.md) | Branches, commit-format, pull requests og krav før merge | | [docs/design.md](docs/design.md) | Farger, typografi, kontrast og felles komponenter | | [docs/testing.md](docs/testing.md) | Tester, kontroller, resultater og det som gjenstår | +| [docs/datasett.md](docs/datasett.md) | Datakilde, generert datasett og målinger | | [docs/vm-backend.md](docs/vm-backend.md) | PostgreSQL og backend på NTNU-VM | | [deploy/apache/README.md](deploy/apache/README.md) | Apache-mal for /project2/ og API-proxy | | [docs/ki-bruk.md](docs/ki-bruk.md) | Hvordan vi bruker og kontrollerer KI | diff --git a/backend/migrations/1791978982612_add-listings-created-at.ts b/backend/migrations/1791978982612_add-listings-created-at.ts new file mode 100644 index 0000000..7fa15a5 --- /dev/null +++ b/backend/migrations/1791978982612_add-listings-created-at.ts @@ -0,0 +1,24 @@ +import type { ColumnDefinitions, MigrationBuilder } from 'node-pg-migrate'; + +export const shorthands: ColumnDefinitions | undefined = undefined; + +/** + * Legger til når annonsen ble opprettet. Trengs for å sortere på «nyeste» og + * for å spre genererte annonser utover tid. + * + * Eksisterende rader får tidspunktet migrasjonen kjøres. Nye rader får + * tidspunktet de settes inn, med mindre seed-skriptet oppgir et eget. + */ +export async function up(pgm: MigrationBuilder): Promise { + pgm.addColumn('listings', { + created_at: { + type: 'timestamptz', + notNull: true, + default: pgm.func('current_timestamp'), + }, + }); +} + +export async function down(pgm: MigrationBuilder): Promise { + pgm.dropColumn('listings', 'created_at'); +} diff --git a/backend/package.json b/backend/package.json index 4608eab..ebd24a7 100644 --- a/backend/package.json +++ b/backend/package.json @@ -12,6 +12,8 @@ "start": "tsx --env-file=.env src/server.ts", "migrate": "node-pg-migrate up --envPath .env", "seed": "tsx --env-file=.env src/scripts/seed.ts", + "seed:large": "tsx --env-file=.env src/scripts/seedLarge.ts", + "import:tools": "tsx src/scripts/importToolModels.ts", "typecheck": "tsc --noEmit", "test": "vitest run", "test:watch": "vitest" diff --git a/backend/src/scripts/importToolModels.ts b/backend/src/scripts/importToolModels.ts new file mode 100644 index 0000000..f7b3e32 --- /dev/null +++ b/backend/src/scripts/importToolModels.ts @@ -0,0 +1,180 @@ +/** + * Henter verktøytyper og merker fra Wikidata og lagrer dem i + * src/seed/data/toolModels.json. + * + * Resultatet committes, slik at seeding ikke er avhengig av at Wikidata er + * tilgjengelig. Kjør skriptet på nytt bare når listene i toolSources.ts endres: + * + * npm run import:tools + * + * Wikidata-data er lisensiert CC0, så vi kan bruke og lagre dem fritt. + */ + +import { mkdir, writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; + +import { + toolModelsPath as outputPath, + type Brand, + type ToolModelsFile, + type ToolType, +} from '../seed/toolModels.js'; +import { brandSources, toolTypeSources } from '../seed/toolSources.js'; + +const SPARQL_ENDPOINT = 'https://query.wikidata.org/sparql'; +// Wikimedia krever en User-Agent som sier hvem som sender forespørselen. +const USER_AGENT = + 'T36-Project2-verktoyutlan/1.0 (NTNU IT2810 studentprosjekt; https://git.ntnu.no/IT2810-H26/T36-Project-2)'; + +interface WikidataEntity { + nbLabel: string | null; + enLabel: string | null; +} + +/** Henter norsk og engelsk navn for alle ID-ene i én spørring. */ +async function fetchEntities( + ids: string[], +): Promise> { + const query = ` + SELECT ?item ?nbLabel ?enLabel WHERE { + VALUES ?item { ${ids.map((id) => `wd:${id}`).join(' ')} } + OPTIONAL { ?item rdfs:label ?nbLabel FILTER(LANG(?nbLabel) = "nb") } + OPTIONAL { ?item rdfs:label ?enLabel FILTER(LANG(?enLabel) = "en") } + }`; + + const response = await fetch( + `${SPARQL_ENDPOINT}?query=${encodeURIComponent(query)}`, + { + headers: { + Accept: 'application/sparql-results+json', + 'User-Agent': USER_AGENT, + }, + }, + ); + + if (!response.ok) { + throw new Error( + `Wikidata svarte ${response.status} ${response.statusText}. Prøv igjen om litt.`, + ); + } + + const json = (await response.json()) as { + results: { bindings: Record[] }; + }; + + const entities = new Map(); + for (const row of json.results.bindings) { + const id = row.item?.value.split('/').pop(); + if (!id) continue; + // En ID kan gi flere rader når den har flere navn på samme språk. + // Første treff beholdes, så resultatet blir det samme hver gang. + const existing = entities.get(id); + entities.set(id, { + nbLabel: existing?.nbLabel ?? row.nbLabel?.value ?? null, + enLabel: existing?.enLabel ?? row.enLabel?.value ?? null, + }); + } + return entities; +} + +/** + * Wikidata skriver noen verktøy med stor forbokstav («Båndsag»). Navnene skal + * brukes midt i titler, så første bokstav gjøres liten. Merkenavn berøres ikke. + */ +function toLowerFirst(text: string): string { + return text.charAt(0).toLocaleLowerCase('nb-NO') + text.slice(1); +} + +function buildToolTypes(entities: Map): ToolType[] { + const missing: string[] = []; + + const toolTypes = toolTypeSources.map((source): ToolType => { + const entity = entities.get(source.wikidataId); + const name = entity?.nbLabel ?? source.fallbackName; + if (!entity || !name) { + missing.push(source.wikidataId); + } + return { + wikidataId: source.wikidataId, + name: toLowerFirst(name ?? ''), + category: source.category, + nameSource: entity?.nbLabel ? 'wikidata' : 'manual', + }; + }); + + if (missing.length > 0) { + throw new Error( + `Fant ikke navn for ${missing.join(', ')}. Sjekk ID-ene eller legg til fallbackName i toolSources.ts.`, + ); + } + + const duplicates = toolTypes + .map((type) => type.name) + .filter((name, index, names) => names.indexOf(name) !== index); + if (duplicates.length > 0) { + throw new Error( + `Samme verktøynavn finnes flere ganger: ${duplicates.join(', ')}.`, + ); + } + + return toolTypes.sort( + (a, b) => + a.category.localeCompare(b.category) || + a.name.localeCompare(b.name, 'nb'), + ); +} + +function buildBrands(entities: Map): Brand[] { + return brandSources + .map((source): Brand => { + const entity = source.wikidataId + ? entities.get(source.wikidataId) + : undefined; + if (source.wikidataId && !entity) { + throw new Error( + `Fant ikke ${source.wikidataId} (${source.name}) i Wikidata.`, + ); + } + return { + name: source.name, + wikidataId: source.wikidataId, + wikidataLabel: entity?.enLabel ?? entity?.nbLabel ?? null, + categories: source.categories, + }; + }) + .sort((a, b) => a.name.localeCompare(b.name, 'nb')); +} + +async function importToolModels(): Promise { + const ids = [ + ...toolTypeSources.map((source) => source.wikidataId), + ...brandSources.flatMap((source) => + source.wikidataId ? [source.wikidataId] : [], + ), + ]; + const entities = await fetchEntities([...new Set(ids)]); + + const file: ToolModelsFile = { + source: { + name: 'Wikidata', + url: 'https://www.wikidata.org', + license: 'CC0 1.0', + retrieved: new Date().toISOString().slice(0, 10), + }, + toolTypes: buildToolTypes(entities), + brands: buildBrands(entities), + }; + + await mkdir(dirname(outputPath), { recursive: true }); + await writeFile(outputPath, `${JSON.stringify(file, null, 2)}\n`, 'utf8'); + + const fromWikidata = file.toolTypes.filter( + (type) => type.nameSource === 'wikidata', + ).length; + const brandsWithId = file.brands.filter((brand) => brand.wikidataId).length; + console.info( + `Lagret ${file.toolTypes.length} verktøytyper (${fromWikidata} med norsk navn fra Wikidata) og ${file.brands.length} merker (${brandsWithId} med Wikidata-ID) i ${outputPath}.`, + ); +} + +await importToolModels(); diff --git a/backend/src/scripts/seedLarge.ts b/backend/src/scripts/seedLarge.ts new file mode 100644 index 0000000..02fd703 --- /dev/null +++ b/backend/src/scripts/seedLarge.ts @@ -0,0 +1,174 @@ +/** + * Fyller databasen med et stort, generert datasett for å teste skalering. + * + * npm run seed:large # 10 000 annonser + * npm run seed:large -- --count 50000 # annet antall + * npm run seed:large -- --replace # slett tidligere genererte først + * + * Skriptet er idempotent: annonser som finnes fra før (samme ID), hoppes over. + * De vanlige seed-annonsene (listing-001 til listing-010) og annonser brukere + * har opprettet, røres ikke. Kjør `npm run seed` først hvis de mangler. + */ + +import { parseArgs } from 'node:util'; + +import type { PoolClient } from 'pg'; + +import { pool } from '../db/pool.js'; +import { + DEFAULT_SEED, + generateListings, + type GeneratedListing, +} from '../seed/generateListings.js'; +import { loadToolModels } from '../seed/toolModels.js'; + +const DEFAULT_COUNT = 10_000; +/** + * Rader per INSERT. PostgreSQL tillater maks 65 535 parametre per spørring, + * og hver rad bruker 10, så 1 000 rader holder god avstand til grensen. + */ +const BATCH_SIZE = 1_000; +/** Alle genererte annonser har ID-er på formen gen-000001. */ +const GENERATED_ID_PATTERN = 'gen-%'; + +const COLUMNS = [ + 'id', + 'title', + 'category', + 'description', + 'area', + 'daily_price', + 'condition', + 'accessories', + 'image_url', + 'created_at', +] as const; + +function parseOptions(): { count: number; seed: number; replace: boolean } { + const { values } = parseArgs({ + options: { + count: { type: 'string' }, + seed: { type: 'string' }, + replace: { type: 'boolean', default: false }, + }, + }); + + const count = + values.count === undefined ? DEFAULT_COUNT : Number(values.count); + const seed = values.seed === undefined ? DEFAULT_SEED : Number(values.seed); + + if (!Number.isInteger(count) || count < 0) { + throw new Error( + `--count må være et heltall som er 0 eller større, fikk «${values.count}».`, + ); + } + if (!Number.isInteger(seed)) { + throw new Error(`--seed må være et heltall, fikk «${values.seed}».`); + } + return { count, seed, replace: values.replace }; +} + +/** Setter inn én bolk med en INSERT som har én VALUES-rad per annonse. */ +async function insertBatch( + client: PoolClient, + batch: readonly GeneratedListing[], +): Promise { + const params: unknown[] = []; + const rows = batch.map((listing) => { + const values = [ + listing.id, + listing.title, + listing.category, + listing.description, + listing.area, + listing.dailyPrice, + listing.condition, + listing.accessories, + listing.imageUrl, + listing.createdAt, + ]; + const placeholders = values.map((value) => { + params.push(value); + return `$${params.length}`; + }); + return `(${placeholders.join(', ')})`; + }); + + const result = await client.query( + `INSERT INTO listings (${COLUMNS.join(', ')}) + VALUES ${rows.join(',\n')} + ON CONFLICT (id) DO NOTHING`, + params, + ); + return result.rowCount ?? 0; +} + +async function seedLarge(): Promise { + const { count, seed, replace } = parseOptions(); + const started = performance.now(); + + const listings = generateListings({ + count, + seed, + toolModels: loadToolModels(), + }); + const generatedMs = performance.now() - started; + + const client = await pool.connect(); + let inserted = 0; + let deleted = 0; + + try { + // Én transaksjon: enten kommer hele datasettet inn, eller ingenting endres. + await client.query('BEGIN'); + + if (replace) { + const result = await client.query( + 'DELETE FROM listings WHERE id LIKE $1', + [GENERATED_ID_PATTERN], + ); + deleted = result.rowCount ?? 0; + } + + for (let start = 0; start < listings.length; start += BATCH_SIZE) { + inserted += await insertBatch( + client, + listings.slice(start, start + BATCH_SIZE), + ); + } + + await client.query('COMMIT'); + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } + + const totals = await pool.query<{ total: string; generated: string }>( + `SELECT COUNT(*)::text AS total, + COUNT(*) FILTER (WHERE id LIKE $1)::text AS generated + FROM listings`, + [GENERATED_ID_PATTERN], + ); + const totalMs = performance.now() - started; + const { total, generated } = totals.rows[0] ?? { total: '0', generated: '0' }; + + console.info( + [ + `Seed av stort datasett fullført (seed-verdi ${seed}).`, + replace ? `Slettet ${deleted} tidligere genererte annonser.` : null, + `${inserted} nye, ${count - inserted} hoppet over (finnes fra før).`, + `Totalt ${total} rader i listings, hvorav ${generated} genererte.`, + `Tid: ${(totalMs / 1000).toFixed(1)} s (generering ${Math.round(generatedMs)} ms).`, + ] + .filter(Boolean) + .join('\n'), + ); +} + +try { + await seedLarge(); +} finally { + await pool.end(); +} diff --git a/backend/src/seed/data/toolModels.json b/backend/src/seed/data/toolModels.json new file mode 100644 index 0000000..d20ad1d --- /dev/null +++ b/backend/src/seed/data/toolModels.json @@ -0,0 +1,792 @@ +{ + "source": { + "name": "Wikidata", + "url": "https://www.wikidata.org", + "license": "CC0 1.0", + "retrieved": "2026-10-14" + }, + "toolTypes": [ + { + "wikidataId": "Q1158863", + "name": "damprenser", + "category": "cleaning", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q1128099", + "name": "feiebrett", + "category": "cleaning", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q1545773", + "name": "høytrykkspyler", + "category": "cleaning", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q828361", + "name": "mopp", + "category": "cleaning", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q1143580", + "name": "nal", + "category": "cleaning", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q101674", + "name": "støvsuger", + "category": "cleaning", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q117207770", + "name": "tepperenser", + "category": "cleaning", + "nameSource": "manual" + }, + { + "wikidataId": "Q1540750", + "name": "greip", + "category": "garden", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q260521", + "name": "gressklipper", + "category": "garden", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q1340233", + "name": "gresstrimmer", + "category": "garden", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q2480927", + "name": "hageslange", + "category": "garden", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q1593024", + "name": "hekksaks", + "category": "garden", + "nameSource": "manual" + }, + { + "wikidataId": "Q1150400", + "name": "jordfreser", + "category": "garden", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q130912", + "name": "ljå", + "category": "garden", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q1450186", + "name": "løvblåser", + "category": "garden", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q208040", + "name": "motorsag", + "category": "garden", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q6503658", + "name": "plenlufter", + "category": "garden", + "nameSource": "manual" + }, + { + "wikidataId": "Q200822", + "name": "rive", + "category": "garden", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q144764", + "name": "rosesaks", + "category": "garden", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q4249364", + "name": "ryddesag", + "category": "garden", + "nameSource": "manual" + }, + { + "wikidataId": "Q1351693", + "name": "snøfreser", + "category": "garden", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q216642", + "name": "spade", + "category": "garden", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q1514872", + "name": "stolpehullsbor", + "category": "garden", + "nameSource": "manual" + }, + { + "wikidataId": "Q175334", + "name": "trillebår", + "category": "garden", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q2245442", + "name": "vannpumpe", + "category": "garden", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q1195804", + "name": "baufil", + "category": "handTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q13681", + "name": "brekkjern", + "category": "handTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q193142", + "name": "fil", + "category": "handTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q27103691", + "name": "flisekutter", + "category": "handTools", + "nameSource": "manual" + }, + { + "wikidataId": "Q25294", + "name": "hammer", + "category": "handTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q204260", + "name": "høvel", + "category": "handTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q2750929", + "name": "håndsag", + "category": "handTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q269359", + "name": "kombinasjonstang", + "category": "handTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q474188", + "name": "meisel", + "category": "handTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q1571051", + "name": "momentnøkkel", + "category": "handTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q1585781", + "name": "nebbtang", + "category": "handTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q947463", + "name": "pipenøkkel", + "category": "handTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q277885", + "name": "rasp", + "category": "handTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q2645890", + "name": "rørtang", + "category": "handTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q1421844", + "name": "sagkrakk", + "category": "handTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q16953", + "name": "sekskantnøkkel", + "category": "handTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q449109", + "name": "skiftenøkkel", + "category": "handTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q475638", + "name": "skrustikke", + "category": "handTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q161071", + "name": "skrutrekker", + "category": "handTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q846600", + "name": "skrutvinge", + "category": "handTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q1935441", + "name": "slegge", + "category": "handTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q13221843", + "name": "stemjern", + "category": "handTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q2801795", + "name": "stiftepistol", + "category": "handTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q168639", + "name": "stige", + "category": "handTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q39397", + "name": "øks", + "category": "handTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q1643652", + "name": "avstandsmåler", + "category": "measuring", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q135263727", + "name": "laservater", + "category": "measuring", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q1826705", + "name": "linjelaser", + "category": "measuring", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q1925365", + "name": "meterstokk", + "category": "measuring", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q189996", + "name": "multimeter", + "category": "measuring", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q214649", + "name": "målebånd", + "category": "measuring", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q12408", + "name": "skyvelære", + "category": "measuring", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q805083", + "name": "stenderfinner", + "category": "measuring", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q646", + "name": "termometer", + "category": "measuring", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q207988", + "name": "vaterpass", + "category": "measuring", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q1778014", + "name": "vinkelhake", + "category": "measuring", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q3057632", + "name": "gardintrapp", + "category": "painting", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q1396432", + "name": "malerrulle", + "category": "painting", + "nameSource": "manual" + }, + { + "wikidataId": "Q3389292", + "name": "malingssprøyte", + "category": "painting", + "nameSource": "manual" + }, + { + "wikidataId": "Q193897", + "name": "pensel", + "category": "painting", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q921044", + "name": "sparkelspade", + "category": "painting", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q7963205", + "name": "tapetdamper", + "category": "painting", + "nameSource": "manual" + }, + { + "wikidataId": "Q456101", + "name": "batteridrill", + "category": "powerTools", + "nameSource": "manual" + }, + { + "wikidataId": "Q831584", + "name": "betongblander", + "category": "powerTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q58964", + "name": "boremaskin", + "category": "powerTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q1932875", + "name": "borhammer", + "category": "powerTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q806378", + "name": "båndsag", + "category": "powerTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q806370", + "name": "båndsliper", + "category": "powerTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q1183768", + "name": "dekupørsag", + "category": "powerTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q2455671", + "name": "eksentersliper", + "category": "powerTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q642560", + "name": "håndoverfres", + "category": "powerTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q17530767", + "name": "håndsirkelsag", + "category": "powerTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q373255", + "name": "kapp- og gjærsag", + "category": "powerTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q1079988", + "name": "kompressor", + "category": "powerTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q1304613", + "name": "limpistol", + "category": "powerTools", + "nameSource": "manual" + }, + { + "wikidataId": "Q623402", + "name": "loddebolt", + "category": "powerTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q13575304", + "name": "muttertrekker", + "category": "powerTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q6934510", + "name": "oscillerende multiverktøy", + "category": "powerTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q1253445", + "name": "planhøvel", + "category": "powerTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q279497", + "name": "platevibrator", + "category": "powerTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q1141837", + "name": "sirkelsag", + "category": "powerTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q19599600", + "name": "slagboremaskin", + "category": "powerTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q368341", + "name": "slagskrutrekker", + "category": "powerTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q77257", + "name": "spikerpistol", + "category": "powerTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q1140903", + "name": "stikksag", + "category": "powerTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q1936800", + "name": "sveiseapparat", + "category": "powerTools", + "nameSource": "manual" + }, + { + "wikidataId": "Q19766", + "name": "trykkluftbor", + "category": "powerTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q1209958", + "name": "tykkelsehøvel", + "category": "powerTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q1057985", + "name": "varmepistol", + "category": "powerTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q827274", + "name": "vinkelsliper", + "category": "powerTools", + "nameSource": "wikidata" + } + ], + "brands": [ + { + "name": "Bahco", + "wikidataId": "Q2656321", + "wikidataLabel": "Bahco", + "categories": [ + "handTools", + "garden" + ] + }, + { + "name": "Biltema", + "wikidataId": "Q3355552", + "wikidataLabel": "Biltema", + "categories": [ + "powerTools", + "handTools", + "garden", + "painting", + "measuring", + "cleaning" + ] + }, + { + "name": "Black+Decker", + "wikidataId": "Q10860313", + "wikidataLabel": "Black+Decker", + "categories": [ + "powerTools", + "garden", + "cleaning" + ] + }, + { + "name": "Bosch", + "wikidataId": "Q234021", + "wikidataLabel": "Robert Bosch", + "categories": [ + "powerTools", + "measuring", + "garden", + "cleaning" + ] + }, + { + "name": "Cocraft", + "wikidataId": "Q3356220", + "wikidataLabel": "Clas Ohlson", + "categories": [ + "powerTools", + "handTools", + "measuring", + "painting" + ] + }, + { + "name": "DeWalt", + "wikidataId": null, + "wikidataLabel": null, + "categories": [ + "powerTools", + "measuring" + ] + }, + { + "name": "Einhell", + "wikidataId": "Q1307902", + "wikidataLabel": "Einhell", + "categories": [ + "powerTools", + "garden", + "cleaning" + ] + }, + { + "name": "Festool", + "wikidataId": "Q320091", + "wikidataLabel": "Festool", + "categories": [ + "powerTools", + "cleaning" + ] + }, + { + "name": "Fiskars", + "wikidataId": "Q1241369", + "wikidataLabel": "Fiskars", + "categories": [ + "handTools", + "garden" + ] + }, + { + "name": "Gardena", + "wikidataId": null, + "wikidataLabel": null, + "categories": [ + "garden" + ] + }, + { + "name": "HiKOKI", + "wikidataId": null, + "wikidataLabel": null, + "categories": [ + "powerTools" + ] + }, + { + "name": "Husqvarna", + "wikidataId": "Q899356", + "wikidataLabel": "Husqvarna Group", + "categories": [ + "garden" + ] + }, + { + "name": "Kärcher", + "wikidataId": "Q696798", + "wikidataLabel": "Kärcher", + "categories": [ + "cleaning" + ] + }, + { + "name": "Limit", + "wikidataId": null, + "wikidataLabel": null, + "categories": [ + "measuring", + "handTools" + ] + }, + { + "name": "Makita", + "wikidataId": "Q691508", + "wikidataLabel": "Makita", + "categories": [ + "powerTools", + "garden" + ] + }, + { + "name": "Meec Tools", + "wikidataId": "Q10541151", + "wikidataLabel": "Jula", + "categories": [ + "powerTools", + "handTools", + "garden", + "painting" + ] + }, + { + "name": "Metabo", + "wikidataId": "Q457140", + "wikidataLabel": "Metabo", + "categories": [ + "powerTools" + ] + }, + { + "name": "Milwaukee", + "wikidataId": null, + "wikidataLabel": null, + "categories": [ + "powerTools" + ] + }, + { + "name": "Nilfisk", + "wikidataId": "Q969760", + "wikidataLabel": "Nilfisk", + "categories": [ + "cleaning" + ] + }, + { + "name": "Ryobi", + "wikidataId": "Q1104912", + "wikidataLabel": "Ryobi", + "categories": [ + "powerTools", + "garden" + ] + }, + { + "name": "Stanley", + "wikidataId": "Q706094", + "wikidataLabel": "Stanley Black & Decker", + "categories": [ + "handTools", + "measuring" + ] + }, + { + "name": "Stihl", + "wikidataId": "Q239372", + "wikidataLabel": "Stihl", + "categories": [ + "garden" + ] + }, + { + "name": "Würth", + "wikidataId": "Q679750", + "wikidataLabel": "Würth", + "categories": [ + "handTools" + ] + } + ] +} diff --git a/backend/src/seed/generateListings.test.ts b/backend/src/seed/generateListings.test.ts new file mode 100644 index 0000000..48b6173 --- /dev/null +++ b/backend/src/seed/generateListings.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from 'vitest'; + +import { + DEFAULT_REFERENCE_DATE, + generateListings, + type GeneratedListing, +} from './generateListings.js'; +import { textsForTool } from './listingTexts.js'; +import { loadToolModels } from './toolModels.js'; + +const toolModels = loadToolModels(); +const DAY_MS = 24 * 60 * 60 * 1000; + +// Genereres én gang og deles av testene, siden det er det samme datasettet. +const listings = generateListings({ count: 10_000, toolModels }); + +function countBy(items: readonly T[], key: (item: T) => string | number) { + const counts = new Map(); + for (const item of items) { + counts.set(key(item), (counts.get(key(item)) ?? 0) + 1); + } + return counts; +} + +describe('generateListings', () => { + it('lager nøyaktig så mange annonser som bedt om, med unike ID-er', () => { + expect(listings).toHaveLength(10_000); + expect(new Set(listings.map((listing) => listing.id)).size).toBe(10_000); + expect(listings[0]?.id).toBe('gen-000001'); + expect(listings.at(-1)?.id).toBe('gen-010000'); + }); + + it('gir samme datasett med samme seed-verdi og et annet med en annen seed-verdi', () => { + const first = generateListings({ count: 200, toolModels, seed: 1 }); + const again = generateListings({ count: 200, toolModels, seed: 1 }); + const other = generateListings({ count: 200, toolModels, seed: 2 }); + + expect(again).toEqual(first); + expect(other).not.toEqual(first); + }); + + it('håndterer 0 annonser og avviser ugyldig antall', () => { + expect(generateListings({ count: 0, toolModels })).toEqual([]); + expect(() => generateListings({ count: -1, toolModels })).toThrow(); + expect(() => generateListings({ count: 1.5, toolModels })).toThrow(); + }); + + it('lager bare gyldige verdier som passer databasen', () => { + const categories = new Set( + toolModels.toolTypes.map((type) => type.category), + ); + const conditions = new Set(['likeNew', 'good', 'worn']); + + for (const listing of listings) { + expect(categories.has(listing.category)).toBe(true); + expect(conditions.has(listing.condition)).toBe(true); + expect(Number.isInteger(listing.dailyPrice)).toBe(true); + expect(listing.dailyPrice).toBeGreaterThanOrEqual(0); + expect(listing.title.trim()).not.toBe(''); + expect(listing.description.trim()).not.toBe(''); + expect(listing.area.trim()).not.toBe(''); + } + }); + + it('har 15–25 % gratis utlån', () => { + const freeShare = + listings.filter((listing) => listing.dailyPrice === 0).length / + listings.length; + + expect(freeShare).toBeGreaterThanOrEqual(0.15); + expect(freeShare).toBeLessThanOrEqual(0.25); + }); + + it('har skjev fordeling mellom kategorier og områder', () => { + const categoryCounts = [ + ...countBy(listings, (listing) => listing.category).values(), + ]; + const areaCounts = [ + ...countBy(listings, (listing) => listing.area).values(), + ]; + + expect(Math.max(...categoryCounts)).toBeGreaterThan( + 2 * Math.min(...categoryCounts), + ); + expect(Math.max(...areaCounts)).toBeGreaterThan( + 5 * Math.min(...areaCounts), + ); + }); + + it('har mange annonser med samme pris', () => { + const priceCounts = countBy(listings, (listing) => listing.dailyPrice); + + // Få, runde priser gjør at sortering på pris må håndtere like verdier. + expect(priceCounts.size).toBeLessThan(30); + expect(Math.max(...priceCounts.values())).toBeGreaterThan(500); + }); + + it('varierer lengden på titler og beskrivelser', () => { + const titleLengths = listings.map((listing) => listing.title.length); + const descriptionLengths = listings.map( + (listing) => listing.description.length, + ); + + expect(Math.min(...titleLengths)).toBeLessThan(15); + expect(Math.max(...titleLengths)).toBeGreaterThan(50); + expect(Math.min(...descriptionLengths)).toBeLessThan(60); + expect(Math.max(...descriptionLengths)).toBeGreaterThan(300); + }); + + it('har både annonser med og uten bilde og tilbehør', () => { + const withImage = listings.filter((listing) => listing.imageUrl !== null); + const withoutAccessories = listings.filter( + (listing) => listing.accessories.length === 0, + ); + + expect(withImage.length).toBeGreaterThan(0); + expect(withImage.length).toBeLessThan(listings.length); + expect(withoutAccessories.length).toBeGreaterThan(0); + expect(withoutAccessories.length).toBeLessThan(listings.length); + }); + + it('sprer opprettelsesdatoene over det siste året', () => { + const times = listings.map((listing: GeneratedListing) => + listing.createdAt.getTime(), + ); + const newest = DEFAULT_REFERENCE_DATE.getTime(); + const oldest = newest - 366 * DAY_MS; + + expect(Math.max(...times)).toBeLessThanOrEqual(newest); + expect(Math.min(...times)).toBeGreaterThanOrEqual(oldest); + // Datasettet skal dekke hele året, ikke bare de siste ukene. + expect(Math.min(...times)).toBeLessThan(newest - 300 * DAY_MS); + }); +}); + +describe('textsForTool', () => { + it('velger tekster som passer verktøyet, ikke bare kategorien', () => { + const sander = textsForTool('powerTools', 'båndsliper'); + const rake = textsForTool('garden', 'rive'); + + expect(sander.uses.join(' ')).not.toContain('hyller'); + expect(rake.accessories).not.toContain('Ekstra kjede'); + expect(rake.powered).toBe(false); + expect(textsForTool('garden', 'motorsag').powered).toBe(true); + }); +}); diff --git a/backend/src/seed/generateListings.ts b/backend/src/seed/generateListings.ts new file mode 100644 index 0000000..5384df2 --- /dev/null +++ b/backend/src/seed/generateListings.ts @@ -0,0 +1,286 @@ +/** + * Genererer et stort og variert sett med eksempelannonser. + * + * Verktøytyper og merker er ekte (hentet fra Wikidata). Selve annonsene, altså + * eier, pris, tilstand, område og tekst, genereres, fordi det ikke finnes en + * åpen datakilde for utlån av verktøy i Trondheim. + * + * Funksjonen er ren: den bruker ingen database, og samme seed-verdi gir alltid samme + * annonser. Det gjør den enkel å teste og gjør datasettet reproduserbart. + */ + +import type { ToolCategory } from '../types/listing.js'; +import { listingImageUrl } from './listingImageUrl.js'; +import { + areaWeights, + categoryWeights, + conditionPhrases, + conditionWeights, + extraPhrases, + FREE_SHARE, + historyPhrases, + imageByToolName, + manualOnlyBrands, + pickupPhrases, + poweredOnlyBrands, + priceWeights, + textsForTool, + type ToolTexts, +} from './listingTexts.js'; +import type { SeedListing } from './listings.js'; +import { createRandom, type Random } from './random.js'; +import type { Brand, ToolModelsFile, ToolType } from './toolModels.js'; + +export interface GeneratedListing extends SeedListing { + createdAt: Date; +} + +export interface GenerateOptions { + count: number; + toolModels: ToolModelsFile; + /** Seed-verdi for tilfeldighetene. Samme seed-verdi gir samme datasett. */ + seed?: number; + /** + * Seneste mulige opprettelsesdato. Fast som standard, slik at datasettet + * ikke endrer seg fra dag til dag. + */ + referenceDate?: Date; +} + +export const DEFAULT_SEED = 2810; +export const DEFAULT_REFERENCE_DATE = new Date('2026-10-01T12:00:00Z'); + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** Andel annonser som har et merke i tittelen. Resten er umerkede. */ +const BRAND_SHARE = 0.7; +/** Andel annonser uten tilbehør. */ +const NO_ACCESSORIES_SHARE = 0.3; +/** Andel annonser med passende bilde som faktisk har bilde. */ +const IMAGE_SHARE = 0.8; + +/** Antall setninger i beskrivelsen. De fleste er korte, noen er lange. */ +const sentenceCountWeights = [ + [1, 12], + [2, 22], + [3, 26], + [4, 20], + [5, 12], + [7, 8], +] as const; + +function capitalize(text: string): string { + return text.charAt(0).toLocaleUpperCase('nb-NO') + text.slice(1); +} + +/** «a», «a og b», «a, b og c». */ +function joinNorwegian(items: readonly string[]): string { + if (items.length <= 1) { + return items.join(''); + } + return `${items.slice(0, -1).join(', ')} og ${items.at(-1)}`; +} + +/** + * Gir verktøytypene i hver kategori ulik popularitet. Rekkefølgen stokkes med + * seed-verdien, og vekten faller med plasseringen, slik at noen verktøy er vanlige og + * mange er sjeldne, som i virkeligheten. + */ +function buildToolWeights( + toolTypes: readonly ToolType[], + random: Random, +): Map { + const byCategory = new Map(); + for (const type of toolTypes) { + byCategory.set(type.category, [ + ...(byCategory.get(type.category) ?? []), + type, + ]); + } + + const weights = new Map(); + for (const [category, types] of byCategory) { + const shuffled = random.sample(types, types.length); + weights.set( + category, + shuffled.map((type, rank) => [type, 1 / (rank + 1) ** 0.8] as const), + ); + } + return weights; +} + +function buildTitle( + random: Random, + texts: ToolTexts, + typeName: string, + brand: Brand | null, + isFree: boolean, +): string { + const type = typeName; + const Type = capitalize(typeName); + + const templates: [() => string, number][] = [ + [() => Type, 20], + [() => `${Type} ${random.pick(texts.specs)}`, 10], + [() => `${Type} – ${random.pick(texts.uses)}`, 8], + [() => `Lite brukt ${type}`, 5], + [() => `${Type} til utlån`, 5], + ]; + if (brand) { + templates.push( + [() => `${brand.name} ${type}`, 30], + [() => `${Type} fra ${brand.name}`, 10], + [() => `${brand.name} ${type} ${random.pick(texts.specs)}`, 15], + ); + } + if (isFree) { + templates.push([() => `Gratis utlån av ${type}`, 8]); + } + + return random.weighted(templates)(); +} + +function buildDescription( + random: Random, + listing: { + texts: ToolTexts; + typeName: string; + brand: Brand | null; + condition: GeneratedListing['condition']; + area: string; + accessories: string[]; + isFree: boolean; + }, +): string { + const { texts, typeName, brand, area, accessories, isFree } = listing; + const condition = random.pick(conditionPhrases[listing.condition]); + const brandAndType = brand ? `${brand.name} ${typeName}` : typeName; + + const intro = random.weighted<() => string>([ + [() => `${capitalize(brandAndType)} ${condition}.`, 3], + // «Leier ut» står uten tilstand, fordi frasene som «fungerer som den + // skal» ikke passer etter den. + [() => `Leier ut ${brandAndType}.`, 2], + [ + () => + brand + ? `${capitalize(typeName)} fra ${brand.name}, ${condition}.` + : `${capitalize(typeName)}, ${condition}.`, + 1, + ], + ])(); + + const candidates = [ + `${capitalize(random.pick(texts.uses))}.`, + random.pick(historyPhrases), + ...(accessories.length > 0 + ? [`Følger med: ${joinNorwegian(accessories)}.`] + : []), + random.pick(pickupPhrases)(area), + ...(isFree ? ['Gratis å låne, jeg vil bare at den kommer til nytte.'] : []), + ...random.sample(extraPhrases, 3), + ]; + + const sentenceCount = random.weighted(sentenceCountWeights); + const sentences = [intro, ...candidates.slice(0, sentenceCount - 1)]; + + // Lange beskrivelser deles i to avsnitt, slik folk ofte skriver. + if (sentences.length >= 6) { + const middle = Math.ceil(sentences.length / 2); + return `${sentences.slice(0, middle).join(' ')}\n\n${sentences.slice(middle).join(' ')}`; + } + return sentences.join(' '); +} + +/** + * Opprettelsesdato innen det siste året før referansedatoen. Nyere annonser er + * vanligere enn gamle, slik det er på en aktiv markedsplass. + */ +function buildCreatedAt(random: Random, referenceDate: Date): Date { + const daysAgo = Math.floor(365 * random.next() ** 2); + const msIntoDay = Math.floor(random.next() * DAY_MS); + return new Date(referenceDate.getTime() - daysAgo * DAY_MS - msIntoDay); +} + +export function generateListings(options: GenerateOptions): GeneratedListing[] { + const { + count, + toolModels, + seed = DEFAULT_SEED, + referenceDate = DEFAULT_REFERENCE_DATE, + } = options; + + if (!Number.isInteger(count) || count < 0) { + throw new Error( + `Antall annonser må være et heltall som er 0 eller større, fikk ${count}.`, + ); + } + + const random = createRandom(seed); + const toolWeights = buildToolWeights(toolModels.toolTypes, random); + const listings: GeneratedListing[] = []; + + for (let index = 0; index < count; index += 1) { + const category = random.weighted(categoryWeights); + const tools = toolWeights.get(category); + if (!tools) { + throw new Error(`Ingen verktøytyper i kategorien ${category}.`); + } + const tool = random.weighted(tools); + const texts = textsForTool(category, tool.name); + + // Merket må selges i kategorien og passe til verktøyet: rene motormerker + // brukes bare på motorisert verktøy, og rene håndverktøymerker bare på + // manuelt verktøy. + const brandsForCategory = toolModels.brands.filter( + (brand) => + brand.categories.includes(category) && + (texts.powered + ? !manualOnlyBrands.has(brand.name) + : !poweredOnlyBrands.has(brand.name)), + ); + const brand = + brandsForCategory.length > 0 && random.chance(BRAND_SHARE) + ? random.pick(brandsForCategory) + : null; + + const isFree = random.chance(FREE_SHARE); + const dailyPrice = isFree ? 0 : random.weighted(priceWeights[category]); + const condition = random.weighted(conditionWeights); + const area = random.weighted(areaWeights); + const accessories = random.chance(NO_ACCESSORIES_SHARE) + ? [] + : random.sample(texts.accessories, random.int(1, 4)); + + const imageFile = imageByToolName[tool.name]; + const imageUrl = + imageFile && random.chance(IMAGE_SHARE) + ? listingImageUrl(imageFile) + : null; + + listings.push({ + // Egen ID-serie, så de genererte annonsene aldri kolliderer med + // listing-001 til listing-010 eller annonser brukere oppretter. + id: `gen-${String(index + 1).padStart(6, '0')}`, + title: buildTitle(random, texts, tool.name, brand, isFree), + category, + description: buildDescription(random, { + texts, + typeName: tool.name, + brand, + condition, + area, + accessories, + isFree, + }), + area, + dailyPrice, + condition, + accessories, + imageUrl, + createdAt: buildCreatedAt(random, referenceDate), + }); + } + + return listings; +} diff --git a/backend/src/seed/listingTexts.ts b/backend/src/seed/listingTexts.ts new file mode 100644 index 0000000..4302656 --- /dev/null +++ b/backend/src/seed/listingTexts.ts @@ -0,0 +1,540 @@ +/** + * Tekstbiter og fordelinger for genererte annonser. + * + * Vektene er bevisst skjeve, slik at datasettet ligner ekte annonser: noen + * områder og kategorier har mange flere annonser enn andre, og mange annonser + * har samme pris. Da blir søk, filtrering og sortering testet på realistiske + * data og ikke på en jevn, kunstig fordeling. + */ + +import type { ToolCategory, ToolCondition } from '../types/listing.js'; + +type Weighted = readonly (readonly [T, number])[]; + +/** Bydeler og områder i Trondheim. Studentområdene har flest annonser. */ +export const areaWeights: Weighted = [ + ['Moholt', 14], + ['Gløshaugen', 10], + ['Lade', 9], + ['Singsaker', 8], + ['Sentrum', 8], + ['Møllenberg', 7], + ['Byåsen', 6], + ['Heimdal', 6], + ['Nardo', 5], + ['Lerkendal', 5], + ['Tiller', 5], + ['Øya', 4], + ['Rosenborg', 4], + ['Ila', 4], + ['Bakklandet', 3], + ['Tyholt', 3], + ['Ranheim', 3], + ['Kolstad', 2], + ['Saupstad', 2], + ['Charlottenlund', 2], + ['Strinda', 2], + ['Valentinlyst', 2], + ['Jakobsli', 1], + ['Vikåsen', 1], + ['Flatåsen', 1], + ['Sverresborg', 1], + ['Persaunet', 1], + ['Ugla', 1], + ['Stavset', 1], + ['Klæbu', 1], +]; + +/** Hvor stor andel av annonsene som havner i hver kategori. */ +export const categoryWeights: Weighted = [ + ['powerTools', 32], + ['handTools', 22], + ['garden', 20], + ['cleaning', 10], + ['painting', 8], + ['measuring', 8], +]; + +export const conditionWeights: Weighted = [ + ['likeNew', 25], + ['good', 55], + ['worn', 20], +]; + +/** Andel annonser med gratis utlån (pris 0). */ +export const FREE_SHARE = 0.2; + +/** + * Typiske døgnpriser per kategori. De lave prisene er vanligst, og alle er + * runde tall, slik at mange annonser får samme pris. + */ +export const priceWeights: Record> = { + powerTools: [ + [50, 10], + [75, 8], + [100, 12], + [120, 5], + [150, 8], + [200, 5], + [250, 3], + [300, 2], + [400, 1], + [500, 1], + ], + handTools: [ + [20, 10], + [25, 8], + [30, 8], + [40, 6], + [50, 6], + [75, 3], + [100, 2], + ], + garden: [ + [30, 6], + [40, 6], + [50, 8], + [75, 6], + [100, 7], + [150, 5], + [200, 4], + [300, 2], + [450, 1], + ], + painting: [ + [20, 8], + [30, 6], + [50, 6], + [75, 3], + [100, 3], + [150, 2], + ], + measuring: [ + [25, 6], + [30, 6], + [50, 6], + [75, 4], + [100, 4], + [150, 2], + [200, 1], + ], + cleaning: [ + [50, 6], + [75, 5], + [100, 7], + [150, 6], + [200, 4], + [250, 3], + [350, 1], + ], +}; + +/** Tekster som passer til et verktøy: bruksområder, tilbehør og tittelspesifikasjoner. */ +export interface ToolTexts { + uses: readonly string[]; + accessories: readonly string[]; + specs: readonly string[]; + /** Motorisert verktøy (strøm, batteri eller bensin). Styrer hvilke merker som passer. */ + powered: boolean; +} + +interface ToolTextRule extends Partial { + /** Treffer verktøynavnet, for eksempel /sag/ for stikksag og sirkelsag. */ + pattern: RegExp; +} + +interface CategoryTexts { + /** Reserve for verktøy som ikke treffer noen regel. */ + general: ToolTexts; + /** Første regel som treffer verktøynavnet, gjelder. */ + rules: readonly ToolTextRule[]; +} + +/** + * Tekstene velges per verktøygruppe, ikke bare per kategori. Ellers får en + * båndsliper teksten «passer til å skru opp hyller», og en rive får trimmertråd + * og ekstra kjede som tilbehør. + */ +export const categoryTexts: Record = { + powerTools: { + general: { + uses: [ + 'gjør jobben mye raskere enn for hånd', + 'perfekt til små og store prosjekter i leiligheten', + 'fin til oppussing av bad og kjøkken', + ], + accessories: [ + 'Koffert', + 'Skjøteledning', + 'Vernebriller', + 'Hørselvern', + 'Bruksanvisning', + ], + specs: ['lite brukt', 'profesjonell modell', 'nesten ny'], + powered: true, + }, + rules: [ + { + pattern: /bor|drill|skrutrekker|muttertrekker/, + uses: [ + 'passer til å skru opp hyller og montere møbler', + 'kraftig nok til betong og mur', + 'fin til å henge opp bilder, lamper og TV-braketter', + ], + accessories: [ + 'Lader', + '2 batterier', + 'Ekstra batteri', + 'Bitssett', + 'Borsett', + ], + specs: ['18 V', '12 V', 'med 2 batterier', 'børsteløs', 'med koffert'], + }, + { + pattern: /sag/, + uses: [ + 'kapper rett og presist', + 'fin til å sage kurver i treplater og laminat', + 'passer til terrassebord og listverk', + ], + accessories: ['Sagblader', 'Ekstra sagblad', 'Parallellanlegg'], + specs: ['1200 W', 'med ekstra blader', '18 V'], + }, + { + pattern: /sliper|høvel|fres/, + uses: [ + 'gir glatt overflate før maling og lakkering', + 'fin til å pusse opp gamle møbler', + 'passer til gulv, dører og listverk', + ], + accessories: ['Slipepapir', 'Støvpose', 'Ekstra høvelstål'], + specs: ['med støvpose', '1200 W'], + }, + { + pattern: /blander|vibrator|pigghammer/, + uses: [ + 'passer til støping og grunnarbeid', + 'tung, men gjør jobben på kort tid', + 'egnet til større byggeprosjekter', + ], + specs: ['tung modell', 'profesjonell modell'], + }, + ], + }, + handTools: { + general: { + uses: [ + 'passer til små reparasjoner hjemme', + 'god å ha i hytta eller på hybelen', + 'solid og ligger godt i hånda', + ], + accessories: ['Oppbevaringsboks', 'Verktøyveske', 'Arbeidshansker'], + specs: ['lite brukt', 'solid', 'profesjonell'], + powered: false, + }, + rules: [ + { + pattern: /nøkkel|tang|skrutrekker/, + uses: [ + 'grei å ha når noe skal skrus fra hverandre', + 'fin til sykkelreparasjoner og møbelmontering', + ], + accessories: ['Pipesett', 'Bitssett'], + specs: ['i sett', 'med etui'], + }, + { + pattern: /øks/, + uses: ['fin til vedhogging på hytta', 'skarp og godt balansert'], + }, + { + pattern: /slegge|brekkjern|meisel/, + uses: ['nyttig når noe skal rives', 'tåler hardt arbeid'], + }, + { + pattern: /sag|baufil|høvel|stemjern|fil|rasp/, + uses: [ + 'fin til trearbeid og små snekkerprosjekter', + 'skarp og klar til bruk', + ], + accessories: ['Ekstra blad'], + }, + ], + }, + garden: { + general: { + uses: [ + 'perfekt til vårrengjøring i hagen', + 'passer til både små og store hager', + 'gjør hagearbeidet mye lettere', + ], + accessories: ['Hansker'], + specs: ['til store hager', 'lite brukt'], + powered: false, + }, + rules: [ + { + pattern: /motorsag|ryddesag/, + uses: [ + 'brukt til å rydde kratt og busker', + 'fin til å kappe ved til vinteren', + ], + accessories: [ + 'Ekstra kjede', + 'Kjedeolje', + 'Bensinkanne', + 'Visir', + 'Hørselvern', + ], + specs: ['bensin', 'batteridrevet'], + powered: true, + }, + { + pattern: /trimmer|gressklipper|hekksaks|plenlufter|løvblåser/, + uses: [ + 'fin til å holde hekken og plenen pen gjennom sommeren', + 'gjør høstarbeidet i hagen mye lettere', + ], + accessories: [ + 'Trimmertråd', + 'Oppsamler', + 'Batteri og lader', + 'Hørselvern', + ], + specs: ['batteridrevet', 'elektrisk', 'bensin'], + powered: true, + }, + { + pattern: /snøfreser/, + uses: ['rydder oppkjørselen på minutter etter snøfall'], + accessories: ['Bensinkanne'], + specs: ['bensin', 'elektrisk'], + powered: true, + }, + { + pattern: /jordfreser|stolpehullsbor|vannpumpe/, + uses: [ + 'passer til større hageprosjekter', + 'sparer deg for mye graving', + ], + accessories: ['Bensinkanne', 'Slange'], + specs: ['bensin'], + powered: true, + }, + ], + }, + painting: { + general: { + uses: [ + 'passer til maling av vegger og tak', + 'fin til oppussing før innflytting', + 'brukt til å male hele leiligheten i fjor', + ], + accessories: ['Dekkplast', 'Maskeringstape'], + specs: ['med tilbehør', 'nesten ny'], + powered: false, + }, + rules: [ + { + pattern: /rulle/, + uses: ['gir jevnt resultat uten striper'], + accessories: ['Malerbrett', 'Teleskopskaft', 'Ekstra ruller'], + specs: ['med teleskopskaft'], + }, + { + pattern: /sprøyte/, + uses: [ + 'maler en hel vegg på noen minutter', + 'fin til gjerder og fasader', + ], + accessories: ['Ekstra dyse'], + powered: true, + }, + { + pattern: /tapetdamper/, + uses: ['gjør det enkelt å fjerne gammel tapet'], + accessories: ['Slange', 'Sparkel'], + powered: true, + }, + ], + }, + measuring: { + general: { + uses: [ + 'presis nok til snekkerarbeid', + 'fin til å planlegge oppussing og møblering', + 'gjør det enkelt å måle opp rom og vegger', + ], + accessories: ['Etui', 'Bruksanvisning'], + specs: ['presis', 'med etui'], + powered: false, + }, + rules: [ + { + pattern: /laser|vater|avstand/, + uses: ['nyttig når hyller og bilder skal henge rett'], + accessories: ['Batterier', 'Stativ', 'Mottaker'], + specs: ['digital', 'med stativ'], + powered: true, + }, + { + pattern: /multimeter|termometer|stender/, + uses: ['fin til å finne feil før du borer eller skrur'], + accessories: ['Batterier', 'Måleledninger'], + specs: ['digital'], + powered: true, + }, + ], + }, + cleaning: { + general: { + uses: [ + 'perfekt til utvask når du skal flytte', + 'gjør storrengjøringen enklere', + ], + accessories: ['Bøtte', 'Ekstra mopp'], + specs: ['lite brukt'], + powered: false, + }, + rules: [ + { + pattern: /høytrykkspyler/, + uses: [ + 'fin til terrasse, bil og fasade', + 'fjerner mose og skitt fra helleplater', + ], + accessories: ['Slange', 'Munnstykker', 'Terrassevasker'], + specs: ['150 bar', 'med tilbehør'], + powered: true, + }, + { + pattern: /tepperenser|damprenser|støvsuger/, + uses: [ + 'fjerner flekker fra tepper og sofaer', + 'gjør storrengjøringen mye raskere', + ], + accessories: [ + 'Munnstykker', + 'Rengjøringsmiddel', + 'Filter', + 'Skjøteledning', + ], + specs: ['til tepper og møbler', 'kraftig'], + powered: true, + }, + ], + }, +}; + +/** + * Finner tekstene for et verktøy. Bruksområder og spesifikasjoner kommer fra + * regelen som treffer, mens tilbehøret er regelens pluss det generelle for + * kategorien. + */ +export function textsForTool( + category: ToolCategory, + toolName: string, +): ToolTexts { + const { general, rules } = categoryTexts[category]; + const rule = rules.find((candidate) => candidate.pattern.test(toolName)); + return { + uses: rule?.uses ?? general.uses, + accessories: [...(rule?.accessories ?? []), ...general.accessories], + specs: rule?.specs ?? general.specs, + powered: rule?.powered ?? general.powered, + }; +} + +export const conditionPhrases: Record = { + likeNew: [ + 'som ny', + 'nesten ubrukt', + 'i strøken stand', + 'kjøpt i år og lite brukt', + ], + good: [ + 'i god stand', + 'godt vedlikeholdt', + 'fungerer som den skal', + 'i fin stand', + ], + worn: [ + 'litt slitt, men fungerer fint', + 'godt brukt, men gjør jobben', + 'med noen riper, men i full drift', + 'har sett noen prosjekter, men virker som den skal', + ], +}; + +export const historyPhrases: readonly string[] = [ + 'Brukt et par ganger i året.', + 'Kjøpt til et prosjekt som nå er ferdig.', + 'Har fulgt meg gjennom tre flyttinger.', + 'Arvet fra bestefar, men fortsatt i god form.', + 'Står mest i boden, så den kan like gjerne komme til nytte.', + 'Kjøpt i fjor og bare brukt én gang.', + 'Vi har to, så denne kan lånes ut.', +]; + +export const pickupPhrases: readonly ((area: string) => string)[] = [ + (area) => `Hentes på ${area}.`, + (area) => `Henting på ${area}, gjerne etter klokka 16.`, + (area) => `Kan hentes på ${area} etter avtale.`, + (area) => `Bor på ${area} og er hjemme de fleste kvelder.`, + (area) => `Henting på ${area}. Kan levere på Gløshaugen om det passer bedre.`, +]; + +export const extraPhrases: readonly string[] = [ + 'Husk vernebriller.', + 'Leveres rengjort, og jeg forventer det samme tilbake.', + 'Send gjerne melding om du lurer på noe.', + 'Ikke egnet for proffbruk, men perfekt til hjemmebruk.', + 'Depositum er ikke nødvendig, men ta godt vare på den.', + 'Kan lånes over helgen om du vil ha et lengre prosjekt.', + 'Bruksanvisning ligger i esken.', + 'Si fra hvis noe går i stykker, så finner vi en løsning.', +]; + +/** + * Merker som bare lager motorisert utstyr. De brukes ikke på manuelle verktøy, + * så vi unngår annonser som «Stihl spade» eller «Kärcher mopp». + */ +export const poweredOnlyBrands: ReadonlySet = new Set([ + 'Makita', + 'Bosch', + 'DeWalt', + 'Milwaukee', + 'Ryobi', + 'Einhell', + 'Metabo', + 'HiKOKI', + 'Black+Decker', + 'Festool', + 'Husqvarna', + 'Stihl', + 'Kärcher', + 'Nilfisk', +]); + +/** Merker som bare lager manuelt verktøy. De brukes ikke på motorisert utstyr. */ +export const manualOnlyBrands: ReadonlySet = new Set([ + 'Fiskars', + 'Bahco', + 'Würth', + 'Limit', +]); + +/** + * Bilder som finnes i frontend (src/assets/listings). De brukes bare på + * verktøytyper bildet faktisk viser, slik at en vinkelsliper ikke får bilde av + * en drill. Alle andre annonser vises med plassholder. + */ +export const imageByToolName: Record = { + boremaskin: 'drill.svg', + batteridrill: 'drill.svg', + slagboremaskin: 'drill.svg', + slagskrutrekker: 'drill.svg', + håndsag: 'handsaw.svg', + baufil: 'handsaw.svg', + malerrulle: 'paint-roller.svg', + spade: 'spade.svg', +}; diff --git a/backend/src/seed/random.ts b/backend/src/seed/random.ts new file mode 100644 index 0000000..72664eb --- /dev/null +++ b/backend/src/seed/random.ts @@ -0,0 +1,87 @@ +/** + * Tilfeldighetsgenerator med fast seed-verdi. Samme seed-verdi gir alltid samme tallrekke, + * slik at alle i gruppen får nøyaktig samme genererte datasett. + * + * Math.random kan ikke brukes fordi den ikke kan få en seed-verdi. + */ + +export interface Random { + /** Desimaltall i intervallet [0, 1). */ + next(): number; + /** Heltall fra og med min til og med max. */ + int(min: number, max: number): number; + /** Sann med gitt sannsynlighet, for eksempel 0.2 for 20 %. */ + chance(probability: number): boolean; + /** Tilfeldig element, der alle er like sannsynlige. */ + pick(items: readonly T[]): T; + /** Tilfeldig element der vekten bestemmer hvor ofte det velges. */ + weighted(items: readonly (readonly [T, number])[]): T; + /** Velger opptil count ulike elementer i tilfeldig rekkefølge. */ + sample(items: readonly T[], count: number): T[]; +} + +/** + * Mulberry32: en liten og rask generator som er god nok til testdata. + * Den er ikke egnet til kryptografi. + */ +function mulberry32(seed: number): () => number { + let state = seed >>> 0; + return () => { + state = (state + 0x6d2b79f5) >>> 0; + let t = state; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +export function createRandom(seed: number): Random { + const next = mulberry32(seed); + + const random: Random = { + next, + int(min, max) { + return min + Math.floor(next() * (max - min + 1)); + }, + chance(probability) { + return next() < probability; + }, + pick(items) { + if (items.length === 0) { + throw new Error('Kan ikke velge fra en tom liste.'); + } + return items[Math.floor(next() * items.length)] as (typeof items)[number]; + }, + weighted(items) { + const total = items.reduce((sum, [, weight]) => sum + weight, 0); + let target = next() * total; + for (const [item, weight] of items) { + target -= weight; + if (target < 0) { + return item; + } + } + // Avrunding kan gi en rest helt på slutten; da velges siste element. + const last = items.at(-1); + if (!last) { + throw new Error('Kan ikke velge fra en tom liste.'); + } + return last[0]; + }, + sample(items, count) { + const copy = [...items]; + // Fisher–Yates-stokking av de første count elementene. + const size = Math.min(count, copy.length); + for (let i = 0; i < size; i += 1) { + const j = random.int(i, copy.length - 1); + [copy[i], copy[j]] = [ + copy[j] as (typeof copy)[number], + copy[i] as (typeof copy)[number], + ]; + } + return copy.slice(0, size); + }, + }; + + return random; +} diff --git a/backend/src/seed/toolModels.ts b/backend/src/seed/toolModels.ts new file mode 100644 index 0000000..fdecd31 --- /dev/null +++ b/backend/src/seed/toolModels.ts @@ -0,0 +1,51 @@ +/** + * Typer og innlesing for data/toolModels.json, som lages av + * `npm run import:tools` (src/scripts/importToolModels.ts). + */ + +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import type { ToolCategory } from '../types/listing.js'; + +export const toolModelsPath = resolve( + dirname(fileURLToPath(import.meta.url)), + 'data/toolModels.json', +); + +/** + * Verktøytype som annonsene bygges på. Beskrivelser fra Wikidata tas ikke med: + * nesten alle er på engelsk og beskriver typen generelt. Annonsene får egne + * norske beskrivelser når datasettet genereres. + */ +export interface ToolType { + wikidataId: string; + name: string; + category: ToolCategory; + /** Hvor navnet kommer fra: Wikidata eller vår egen oversettelse. */ + nameSource: 'wikidata' | 'manual'; +} + +export interface Brand { + name: string; + wikidataId: string | null; + /** Navnet på elementet i Wikidata, slik at koblingen kan kontrolleres. */ + wikidataLabel: string | null; + categories: ToolCategory[]; +} + +export interface ToolModelsFile { + source: { + name: string; + url: string; + license: string; + retrieved: string; + }; + toolTypes: ToolType[]; + brands: Brand[]; +} + +export function loadToolModels(): ToolModelsFile { + return JSON.parse(readFileSync(toolModelsPath, 'utf8')) as ToolModelsFile; +} diff --git a/backend/src/seed/toolSources.ts b/backend/src/seed/toolSources.ts new file mode 100644 index 0000000..af45f07 --- /dev/null +++ b/backend/src/seed/toolSources.ts @@ -0,0 +1,267 @@ +/** + * Hvilke verktøytyper og merker som hentes fra Wikidata. + * + * Verktøytypene er valgt for hånd fordi kategoritrærne i Wikidata er svært + * støyete: under «håndverktøy» ligger blant annet bestikk og lysekroner, og + * under «måleinstrument» ligger hundrevis av militære radarer. En liste med + * faste Wikidata-ID-er gir oss ekte verktøytyper uten støy. + * + * Kjør `npm run import:tools` etter endringer her. Skriptet henter navn og + * beskrivelse fra Wikidata og skriver resultatet til data/toolModels.json. + */ + +import type { ToolCategory } from '../types/listing.js'; + +export interface ToolTypeSource { + /** Wikidata-ID, for eksempel «Q58964» for boremaskin. */ + wikidataId: string; + category: ToolCategory; + /** + * Norsk navn når Wikidata mangler norsk (bokmål) navn. Brukes bare som + * reserve; finnes et norsk navn i Wikidata, vinner det. + */ + fallbackName?: string; +} + +export interface BrandSource { + name: string; + /** Wikidata-ID for merket eller selskapet bak, eller null når det mangler. */ + wikidataId: string | null; + /** Kategoriene merket typisk selges innenfor i norske butikker. */ + categories: ToolCategory[]; +} + +export const toolTypeSources: ToolTypeSource[] = [ + // Elektroverktøy + { wikidataId: 'Q58964', category: 'powerTools' }, // boremaskin + { wikidataId: 'Q19599600', category: 'powerTools' }, // slagboremaskin + { + wikidataId: 'Q456101', + category: 'powerTools', + fallbackName: 'batteridrill', + }, + { wikidataId: 'Q1932875', category: 'powerTools', fallbackName: 'borhammer' }, + { wikidataId: 'Q368341', category: 'powerTools' }, // slagskrutrekker + { wikidataId: 'Q13575304', category: 'powerTools' }, // muttertrekker + { wikidataId: 'Q1140903', category: 'powerTools' }, // stikksag + { wikidataId: 'Q1141837', category: 'powerTools' }, // sirkelsag + { wikidataId: 'Q17530767', category: 'powerTools' }, // håndsirkelsag + { wikidataId: 'Q373255', category: 'powerTools' }, // kapp- og gjærsag + { wikidataId: 'Q1183768', category: 'powerTools' }, // dekupørsag + { wikidataId: 'Q806378', category: 'powerTools' }, // båndsag + { wikidataId: 'Q827274', category: 'powerTools' }, // vinkelsliper + { wikidataId: 'Q2455671', category: 'powerTools' }, // eksentersliper + { wikidataId: 'Q806370', category: 'powerTools' }, // båndsliper + { wikidataId: 'Q1253445', category: 'powerTools' }, // planhøvel + { wikidataId: 'Q1209958', category: 'powerTools' }, // tykkelsehøvel + { wikidataId: 'Q642560', category: 'powerTools' }, // håndoverfres + { wikidataId: 'Q77257', category: 'powerTools' }, // spikerpistol + { wikidataId: 'Q6934510', category: 'powerTools' }, // oscillerende multiverktøy + { wikidataId: 'Q1057985', category: 'powerTools' }, // varmepistol + { wikidataId: 'Q623402', category: 'powerTools' }, // loddebolt + { wikidataId: 'Q1079988', category: 'powerTools' }, // kompressor + { + wikidataId: 'Q1936800', + category: 'powerTools', + fallbackName: 'sveiseapparat', + }, + { wikidataId: 'Q1304613', category: 'powerTools', fallbackName: 'limpistol' }, + { wikidataId: 'Q19766', category: 'powerTools', fallbackName: 'pigghammer' }, + { + wikidataId: 'Q831584', + category: 'powerTools', + fallbackName: 'betongblander', + }, + { + wikidataId: 'Q279497', + category: 'powerTools', + fallbackName: 'markvibrator', + }, + + // Håndverktøy + { wikidataId: 'Q25294', category: 'handTools' }, // hammer + { wikidataId: 'Q1935441', category: 'handTools' }, // slegge + { wikidataId: 'Q161071', category: 'handTools' }, // skrutrekker + { wikidataId: 'Q2750929', category: 'handTools' }, // håndsag + { wikidataId: 'Q1195804', category: 'handTools' }, // baufil + { wikidataId: 'Q39397', category: 'handTools' }, // øks + { wikidataId: 'Q13681', category: 'handTools' }, // brekkjern + { wikidataId: 'Q269359', category: 'handTools' }, // kombinasjonstang + { wikidataId: 'Q1585781', category: 'handTools' }, // nebbtang + { wikidataId: 'Q2645890', category: 'handTools' }, // rørtang + { wikidataId: 'Q449109', category: 'handTools' }, // skiftenøkkel + { wikidataId: 'Q1571051', category: 'handTools' }, // momentnøkkel + { wikidataId: 'Q947463', category: 'handTools' }, // pipenøkkel + { wikidataId: 'Q16953', category: 'handTools' }, // sekskantnøkkel + { wikidataId: 'Q13221843', category: 'handTools' }, // stemjern + { wikidataId: 'Q204260', category: 'handTools' }, // høvel + { wikidataId: 'Q193142', category: 'handTools' }, // fil + { wikidataId: 'Q277885', category: 'handTools' }, // rasp + { wikidataId: 'Q474188', category: 'handTools' }, // meisel + { wikidataId: 'Q2801795', category: 'handTools' }, // stiftepistol + { + wikidataId: 'Q27103691', + category: 'handTools', + fallbackName: 'flisekutter', + }, + { wikidataId: 'Q846600', category: 'handTools', fallbackName: 'skrutvinge' }, + { wikidataId: 'Q475638', category: 'handTools', fallbackName: 'skrustikke' }, + { wikidataId: 'Q1421844', category: 'handTools', fallbackName: 'sagbukk' }, + { wikidataId: 'Q168639', category: 'handTools' }, // stige + + // Hage + { wikidataId: 'Q208040', category: 'garden' }, // motorsag + { wikidataId: 'Q1593024', category: 'garden', fallbackName: 'hekksaks' }, + { wikidataId: 'Q144764', category: 'garden' }, // rosesaks + { wikidataId: 'Q1150400', category: 'garden' }, // jordfreser + { wikidataId: 'Q175334', category: 'garden' }, // trillebår + { wikidataId: 'Q200822', category: 'garden' }, // rive + { wikidataId: 'Q1340233', category: 'garden' }, // gresstrimmer + { wikidataId: 'Q4249364', category: 'garden', fallbackName: 'ryddesag' }, + { wikidataId: 'Q260521', category: 'garden' }, // gressklipper + { wikidataId: 'Q1450186', category: 'garden' }, // løvblåser + { wikidataId: 'Q216642', category: 'garden' }, // spade + { wikidataId: 'Q1540750', category: 'garden' }, // greip + { wikidataId: 'Q130912', category: 'garden' }, // ljå + { wikidataId: 'Q1351693', category: 'garden' }, // snøfreser + { wikidataId: 'Q2480927', category: 'garden', fallbackName: 'hageslange' }, + { wikidataId: 'Q6503658', category: 'garden', fallbackName: 'plenlufter' }, + { + wikidataId: 'Q1514872', + category: 'garden', + fallbackName: 'stolpehullsbor', + }, + { wikidataId: 'Q2245442', category: 'garden' }, // vannpumpe + + // Maling og overflate + { wikidataId: 'Q193897', category: 'painting' }, // pensel + { wikidataId: 'Q1396432', category: 'painting', fallbackName: 'malerrulle' }, + { + wikidataId: 'Q3389292', + category: 'painting', + fallbackName: 'malingssprøyte', + }, + { wikidataId: 'Q921044', category: 'painting' }, // sparkelspade + { wikidataId: 'Q7963205', category: 'painting', fallbackName: 'tapetdamper' }, + { wikidataId: 'Q3057632', category: 'painting' }, // gardintrapp + + // Måleverktøy + { wikidataId: 'Q207988', category: 'measuring' }, // vaterpass + { wikidataId: 'Q1925365', category: 'measuring' }, // meterstokk + { wikidataId: 'Q214649', category: 'measuring' }, // målebånd + { wikidataId: 'Q12408', category: 'measuring' }, // skyvelære + { wikidataId: 'Q189996', category: 'measuring' }, // multimeter + { wikidataId: 'Q1643652', category: 'measuring' }, // avstandsmåler + { wikidataId: 'Q1826705', category: 'measuring', fallbackName: 'krysslaser' }, + { + wikidataId: 'Q135263727', + category: 'measuring', + fallbackName: 'laservater', + }, + { wikidataId: 'Q1778014', category: 'measuring' }, // vinkelhake + { wikidataId: 'Q646', category: 'measuring' }, // termometer + { + wikidataId: 'Q805083', + category: 'measuring', + fallbackName: 'stendersøker', + }, + + // Rengjøring + { wikidataId: 'Q1545773', category: 'cleaning' }, // høytrykkspyler + { wikidataId: 'Q1158863', category: 'cleaning' }, // damprenser + { wikidataId: 'Q101674', category: 'cleaning' }, // støvsuger + { wikidataId: 'Q828361', category: 'cleaning' }, // mopp + { wikidataId: 'Q1128099', category: 'cleaning' }, // feiebrett + { wikidataId: 'Q1143580', category: 'cleaning' }, // nal + { + wikidataId: 'Q117207770', + category: 'cleaning', + fallbackName: 'tepperenser', + }, +]; + +/** + * Merker som selges i norske butikker (kontrollert mot nettbutikkene til + * blant andre Biltema, Clas Ohlson og Maxbo). Butikkenes egne merker peker på + * butikkjeden i Wikidata. Merker som ikke finnes i Wikidata, har null. + */ +export const brandSources: BrandSource[] = [ + { + name: 'Makita', + wikidataId: 'Q691508', + categories: ['powerTools', 'garden'], + }, + { + name: 'Bosch', + wikidataId: 'Q234021', + categories: ['powerTools', 'measuring', 'garden', 'cleaning'], + }, + { name: 'DeWalt', wikidataId: null, categories: ['powerTools', 'measuring'] }, + { name: 'Milwaukee', wikidataId: null, categories: ['powerTools'] }, + { + name: 'Ryobi', + wikidataId: 'Q1104912', + categories: ['powerTools', 'garden'], + }, + { + name: 'Einhell', + wikidataId: 'Q1307902', + categories: ['powerTools', 'garden', 'cleaning'], + }, + { name: 'Metabo', wikidataId: 'Q457140', categories: ['powerTools'] }, + { name: 'HiKOKI', wikidataId: null, categories: ['powerTools'] }, + { + name: 'Black+Decker', + wikidataId: 'Q10860313', + categories: ['powerTools', 'garden', 'cleaning'], + }, + { + name: 'Festool', + wikidataId: 'Q320091', + categories: ['powerTools', 'cleaning'], + }, + { + name: 'Bahco', + wikidataId: 'Q2656321', + categories: ['handTools', 'garden'], + }, + { + name: 'Stanley', + wikidataId: 'Q706094', + categories: ['handTools', 'measuring'], + }, + { + name: 'Fiskars', + wikidataId: 'Q1241369', + categories: ['handTools', 'garden'], + }, + { name: 'Würth', wikidataId: 'Q679750', categories: ['handTools'] }, + { name: 'Husqvarna', wikidataId: 'Q899356', categories: ['garden'] }, + { name: 'Stihl', wikidataId: 'Q239372', categories: ['garden'] }, + { name: 'Gardena', wikidataId: null, categories: ['garden'] }, + { name: 'Limit', wikidataId: null, categories: ['measuring', 'handTools'] }, + { name: 'Kärcher', wikidataId: 'Q696798', categories: ['cleaning'] }, + { name: 'Nilfisk', wikidataId: 'Q969760', categories: ['cleaning'] }, + { + name: 'Biltema', + wikidataId: 'Q3355552', + categories: [ + 'powerTools', + 'handTools', + 'garden', + 'painting', + 'measuring', + 'cleaning', + ], + }, + { + name: 'Cocraft', + wikidataId: 'Q3356220', + categories: ['powerTools', 'handTools', 'measuring', 'painting'], + }, + { + name: 'Meec Tools', + wikidataId: 'Q10541151', + categories: ['powerTools', 'handTools', 'garden', 'painting'], + }, +]; diff --git a/docs/datasett.md b/docs/datasett.md new file mode 100644 index 0000000..e88de09 --- /dev/null +++ b/docs/datasett.md @@ -0,0 +1,118 @@ +# Datasett + +For å vise at søk, filtrering, sortering og visning av treff side for side +skalerer, fyller vi databasen med et stort og variert datasett. Verktøytypene og merkene er ekte og hentes +fra en åpen kilde. Selve annonsene genereres, fordi det ikke finnes en åpen +datakilde for utlån av verktøy mellom privatpersoner i Trondheim. + +## Kort fortalt + +| Del | Kilde | Antall | +| ------------------ | ------------------------------------------ | ----------------------- | +| Verktøytyper | [Wikidata](https://www.wikidata.org), CC0 | 95 | +| Merker | Håndplukket, koblet til Wikidata der mulig | 23 (18 med Wikidata-ID) | +| Eksempelannonser | Skrevet for hånd (`npm run seed`) | 10 | +| Genererte annonser | `npm run seed:large` | 10 000 (kan endres) | + +## Verktøytyper og merker fra Wikidata + +`npm run import:tools` henter navn på verktøytyper og merker fra Wikidata og +lagrer dem i `backend/src/seed/data/toolModels.json`. Filen committes, slik at +seeding ikke er avhengig av at Wikidata er tilgjengelig. Skriptet trenger bare +å kjøres på nytt når listene i `backend/src/seed/toolSources.ts` endres. + +**Hvorfor en fast liste med Wikidata-ID-er?** Vi prøvde først å hente alle +undertyper av kategorier som «håndverktøy» og «måleinstrument». Det ga mye +støy: under håndverktøy lå blant annet bestikk, lysekroner og stridsøkser, og +under måleinstrument lå hundrevis av militære radarer. Derfor velger vi +verktøytypene med faste ID-er, og navnene hentes fra Wikidata. + +- **Norske navn:** 83 av 95 verktøytyper har norsk (bokmål) navn i Wikidata. + De 12 som mangler, for eksempel malerrulle og hekksaks, har et norsk + reservenavn i `toolSources.ts`. Feltet `nameSource` viser hvor navnet kommer + fra. +- **Beskrivelser:** Beskrivelsene i Wikidata brukes ikke. Bare én av 95 var på + norsk, og de beskriver typen generelt, ikke et konkret eksemplar. +- **Merker:** Det finnes ingen åpen kilde for hvilke merker norske butikker + selger. Listen er derfor satt sammen for hånd og kontrollert mot nettbutikker + som Biltema, Clas Ohlson og Maxbo. Merker som finnes i Wikidata, er koblet til + sin ID. DeWalt, Milwaukee, Gardena, HiKOKI og Limit fant vi ikke. + Butikkenes egne merker (Cocraft og Meec Tools) peker på butikkjeden. +- **Kategorier:** Lagres som de samme engelske nøklene som resten av appen + (`powerTools`, `garden` osv.). Frontend viser de norske navnene. + +## Genererte annonser + +`backend/src/seed/generateListings.ts` lager annonser fra verktøytypene og +merkene. Generatoren bruker en fast seed-verdi (standard `2810`) og en fast +referansedato, slik at alle får nøyaktig samme datasett. ID-ene er +`gen-000001` og oppover, så de kolliderer ikke med `listing-001`–`listing-010` +eller med annonser brukere oppretter. + +Datasettet er bevisst ujevnt, slik ekte annonser er. En jevn, kunstig fordeling +ville fått søk og sortering til å se bedre ut enn de er. + +| Egenskap | Slik er det generert | +| ---------------- | ------------------------------------------------------------------------------------------------------------ | +| Kategori | Skjev: elektroverktøy 32 %, håndverktøy 22 %, hage 20 %, rengjøring 10 %, maling 8 %, måleverktøy 8 % | +| Verktøytype | Noen typer er vanlige og mange er sjeldne innen hver kategori | +| Område | 30 bydeler i Trondheim. Studentområdene har flest (Moholt ≈ 1 150, Flatåsen ≈ 70 per 10 000) | +| Pris | 20 % gratis. Resten får runde priser per kategori, så mange annonser har samme pris (17 ulike priser totalt) | +| Tilstand | Som ny 25 %, god stand 55 %, godt brukt 20 % | +| Merke | 70 % har merke. Motormerker brukes bare på motorisert verktøy, og håndverktøymerker bare på manuelt verktøy | +| Tittel | 3–77 tegn, fra «Øks» til «Sekskantnøkkel – grei å ha når noe skal skrus fra hverandre» | +| Beskrivelse | 1–7 setninger på norsk, 11–396 tegn. Lange beskrivelser deles i to avsnitt | +| Tilbehør | 0–4 ting som passer til verktøyet. 30 % har ingen | +| Bilde | Bare verktøy som har en passende illustrasjon (drill, håndsag, malerrulle, spade). Resten får plassholder | +| Opprettelsesdato | Spredt over året før 1. oktober 2026, med flest nylige annonser | + +Tekster, tilbehør og spesifikasjoner i titler velges per verktøygruppe i +`backend/src/seed/listingTexts.ts`. Uten det ville en båndsliper fått teksten +«passer til å skru opp hyller», og en rive ville fått trimmertråd som tilbehør. + +Generatoren testes i `backend/src/seed/generateListings.test.ts`, blant annet +for at samme seed-verdi gir samme datasett, andelen gratis annonser, skjev fordeling, +like priser, variasjon i tekstlengde og datoer. + +## Kjøre det + +Fra `backend/`, etter `npm run migrate` og `npm run seed`: + +```bash +npm run seed:large # 10 000 annonser +npm run seed:large -- --count 50000 # annet antall +npm run seed:large -- --replace # slett tidligere genererte først +npm run seed:large -- --seed 42 # et annet, men like reproduserbart datasett +``` + +Skriptet setter inn 1 000 rader per spørring i én transaksjon. Feiler noe, +rulles alt tilbake. Det er idempotent: rader som finnes fra før, hoppes over. +Med `--replace` slettes bare genererte annonser (`gen-…`), aldri +eksempelannonsene eller annonser brukere har opprettet. + +## Målinger + +Målt lokalt med PostgreSQL 18 på en bærbar PC, uten indekser utover +primærnøkkelen. Spørretidene er median av sju kjøringer med `EXPLAIN ANALYZE`. + +| Måling | 10 000 annonser | 50 000 annonser | +| ---------------------------------------------------------------- | --------------- | --------------- | +| `npm run seed:large` | 0,3 s | 1,2 s | +| Søk på «drill» i tittel og beskrivelse, 24 treff sortert på pris | 14 ms | 68 ms | +| Kategori og gratis, 24 nyeste | 0,9 ms | 4,0 ms | +| Alle annonser (dagens `listings`-spørring) | 21 ms | 150 ms | + +Tidene vokser omtrent i takt med datamengden, fordi PostgreSQL må lese hele +tabellen. Det viser hvorfor neste steg er serverbasert paginering og indekser, +for eksempel på `category`, `daily_price` og `created_at`, og en trigram-indeks +(`pg_trgm`) for søk i tekst. Med indekser bør tidene holde seg omtrent like +uansett datamengde. + +## Begrensninger + +- Annonsene er generert, så eiere, bookinger og vurderinger finnes ikke ennå. +- Tekstene bygges av et begrenset sett med setninger. Mange titler går igjen + (rundt 2 400 unike titler per 10 000 annonser), slik de også gjør på ekte + markedsplasser. +- Kategoriene maling og rengjøring har få verktøytyper (6 og 7), fordi + Wikidata har få relevante typer der. diff --git a/docs/ki-bruk.md b/docs/ki-bruk.md index 7d226d1..579faa2 100644 --- a/docs/ki-bruk.md +++ b/docs/ki-bruk.md @@ -30,15 +30,16 @@ ser hva vi bør være oppmerksomme på. ## Logg -| Issue | Hva KI gjorde | Kontroll og funn | -| ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| – | Gikk gjennom repoet mot arbeidsplanen og foreslo de resterende issuene til innlevering 1 | Issuene ble lest og opprettet av gruppen | -| #18 | Laget detaljvisning, `ListingImage`, `RentalRequestForm` og hjelpefunksjoner for leieperiode | Kontroller og nettleser på desktop og 360 px. Første forsøk på datolesing feilet typekontrollen og ble skrevet om | -| #19 | Laget utleieskjema, `SelectField`, `TextAreaField` og validering | Kontroller og nettleser. Fant at prismerket for betalt leie ble usynlig i forhåndsvisningen, og rettet det før commit | -| #20 | Skrev avsnittene om idé, visninger, mockup og planer i README | Teksten ble sjekket mot appen. Sortering ble utelatt til #16 var merget | -| #7 | Satte opp Vitest og skrev 79 tester, og kontrollerte ren installasjon, bredder og tastatur | En test sjekket påkrevde felt med `toBeInvalid()` og ville bestått uansett. Byttet til `aria-invalid`. Nettleserkontrollene var automatiserte, se [testing.md](testing.md) | -| #3 | Skrev prosjektstandard, maler for issues og pull requests og dette dokumentet | Standarden ble sjekket mot commit-historikken og reglene i repoet | -| #21 | Kjørte sluttkontrollen før innlevering 1 og rettet det den fant | Fant at `engines` tillot Node-versjoner som `jsdom` ikke støtter, og at favicon manglet. Begge er rettet. PR-er, review og merge til `main` gjøres av gruppen | -| #31 | Kjørte en kontroll på backend oppsettet | Sluttkontroll fant Prettier-feil og at seed-bilder ikke følger av Vite-build; dokumentasjon oppdatert. Migrate/seed og typecheck verifisert. PR og merge gjøres av gruppen | -| #33 | Kontroll på oppsettet av backend i VM | Maler er ikke aktivert på VM. GraphQL og Vite `base` gjenstår. SSH til VM ble kjørt manuelt. PR og merge gjøres av gruppen | -| #35 | Undersøkte eksisterende backend, utleieskjema og `listings`-tabell; foreslo og kontrollerte Apollo Server 5, GraphQL-schema, resolvere, PostgreSQL-repository med parameterisert SQL, servervalidering som frontend, og Vitest-integrasjonstester mot egen testdatabase | Typecheck og `npm test` i `backend/` mot lokal database med `_test`-suffiks. README, testing.md og deploy-maler oppdatert. Frontend uendret. PR og merge gjøres av gruppen | +| Issue | Hva KI gjorde | Kontroll og funn | +| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| – | Gikk gjennom repoet mot arbeidsplanen og foreslo de resterende issuene til innlevering 1 | Issuene ble lest og opprettet av gruppen | +| #18 | Laget detaljvisning, `ListingImage`, `RentalRequestForm` og hjelpefunksjoner for leieperiode | Kontroller og nettleser på desktop og 360 px. Første forsøk på datolesing feilet typekontrollen og ble skrevet om | +| #19 | Laget utleieskjema, `SelectField`, `TextAreaField` og validering | Kontroller og nettleser. Fant at prismerket for betalt leie ble usynlig i forhåndsvisningen, og rettet det før commit | +| #20 | Skrev avsnittene om idé, visninger, mockup og planer i README | Teksten ble sjekket mot appen. Sortering ble utelatt til #16 var merget | +| #7 | Satte opp Vitest og skrev 79 tester, og kontrollerte ren installasjon, bredder og tastatur | En test sjekket påkrevde felt med `toBeInvalid()` og ville bestått uansett. Byttet til `aria-invalid`. Nettleserkontrollene var automatiserte, se [testing.md](testing.md) | +| #3 | Skrev prosjektstandard, maler for issues og pull requests og dette dokumentet | Standarden ble sjekket mot commit-historikken og reglene i repoet | +| #21 | Kjørte sluttkontrollen før innlevering 1 og rettet det den fant | Fant at `engines` tillot Node-versjoner som `jsdom` ikke støtter, og at favicon manglet. Begge er rettet. PR-er, review og merge til `main` gjøres av gruppen | +| #31 | Kjørte en kontroll på backend oppsettet | Sluttkontroll fant Prettier-feil og at seed-bilder ikke følger av Vite-build; dokumentasjon oppdatert. Migrate/seed og typecheck verifisert. PR og merge gjøres av gruppen | +| #33 | Kontroll på oppsettet av backend i VM | Maler er ikke aktivert på VM. GraphQL og Vite `base` gjenstår. SSH til VM ble kjørt manuelt. PR og merge gjøres av gruppen | +| #35 | Undersøkte eksisterende backend, utleieskjema og `listings`-tabell; foreslo og kontrollerte Apollo Server 5, GraphQL-schema, resolvere, PostgreSQL-repository med parameterisert SQL, servervalidering som frontend, og Vitest-integrasjonstester mot egen testdatabase | Typecheck og `npm test` i `backend/` mot lokal database med `_test`-suffiks. README, testing.md og deploy-maler oppdatert. Frontend uendret. PR og merge gjøres av gruppen | +| #39 | Søkte etter åpne datasett om verktøy på nettet (Wikidata, Open Products Facts, Geonorge) og foreslo Wikidata for verktøytyper og merker. Veiledet oppsett av lokal PostgreSQL-database. Skrev skript som henter verktøymodeller fra Wikidata (`import:tools`), migrasjon for `created_at`, generator for varierte annonser med tester og `seed:large` som setter inn datasettet | Wikidata-kategoriene ga mye støy, så verktøytypene velges med faste ID-er. Stikkprøver av genererte annonser viste tekster, tilbehør og merker som ikke passet verktøyet (for eksempel «båndsliper» som «passer til å skru opp hyller»), og dette ble rettet før commit. Migrasjon testet med up/down mot lokal database. | diff --git a/docs/vm-backend.md b/docs/vm-backend.md index f7d2f10..f07e0fa 100644 --- a/docs/vm-backend.md +++ b/docs/vm-backend.md @@ -96,6 +96,22 @@ npm run seed - Første seed på tom database: forvent **10 nye** annonser. - Senere kjøringer: **0 nye**, 10 hoppet over (idempotent). +### Stort datasett + +Etter migrasjon og seed, fra **`~/T36-Project-2/backend`**: + +```bash +npm run seed:large +``` + +- Første kjøring: forvent **10 000 nye** og totalt **10 010** rader i `listings`. +- Senere kjøringer: **0 nye** (idempotent). +- `npm run migrate` må være kjørt først, fordi datasettet bruker `created_at`. +- GraphQL ser de nye radene med en gang. Tjenesten trenger bare restart når + koden er oppdatert med `git pull` (se §7.3). + +Se [docs/datasett.md](datasett.md) for kilde, fordelinger og målinger. + ## 3. Sikker håndtering av `DATABASE_URL` - **`DATABASE_URL` skal aldri committes.** Kun `backend/.env.example` med