# Kassalapp API med Node.js - Komplett Guide ## Innledning Kassalapp API gir deg tilgang til prisinformasjon fra norske dagligvarebutikker. Denne guiden viser deg hvordan du integrerer API-et i dine Node.js-prosjekter med moderne JavaScript og TypeScript. ## Innhold 1. [Kom i gang](#kom-i-gang) 2. [Autentisering](#autentisering) 3. [Oppsett med Axios](#oppsett-med-axios) 4. [TypeScript-grensesnitt](#typescript-grensesnitt) 5. [Produktsøk](#produktsøk) 6. [Filtrering og sortering](#filtrering-og-sortering) 7. [Strekkodeskanning](#strekkodeskanning) 8. [Butikklokasjoner](#butikklokasjoner) 9. [Feilhåndtering](#feilhåndtering) 10. [Rate limiting](#rate-limiting) 11. [Express.js API Wrapper](#expressjs-api-wrapper) 12. [Praktiske eksempler](#praktiske-eksempler) ## Kom i gang ### 1. Registrer deg for API-nøkkel Først må du registrere deg for å få en API-nøkkel: 1. Gå til [https://kassal.app/api](https://kassal.app/api) 2. Opprett en konto eller logg inn 3. Generer en ny API-nøkkel 4. Lagre nøkkelen trygt - du får ikke se den igjen! ### 2. Installer nødvendige pakker ```bash # Med npm npm install axios@^1.6.0 dotenv@^16.3.0 helmet@^7.1.0 cors@^2.8.5 express@^4.18.0 express-validator@^7.0.0 winston@^3.11.0 # Med yarn yarn add axios@^1.6.0 dotenv@^16.3.0 helmet@^7.1.0 cors@^2.8.5 express@^4.18.0 express-validator@^7.0.0 winston@^3.11.0 # For TypeScript-støtte (anbefalt versioner) npm install --save-dev @types/node@^20.0.0 @types/express@^4.17.0 @types/cors@^2.8.0 typescript@^5.2.0 ts-node@^10.9.0 ``` ### 3. Miljøvariabler Opprett en `.env` fil i rotmappen: ```env # Kassalapp API konfigurasjon KASSALAPP_API_KEY=din_api_nøkkel_her KASSALAPP_API_URL=https://kassal.app/api/v1 # Server konfigurasjon NODE_ENV=development PORT=3000 # Sikkerhet ALLOWED_ORIGINS=http://localhost:3000,https://yourdomain.com # Rate limiting (forespørsler per minutt) RATE_LIMIT_WINDOW_MS=60000 RATE_LIMIT_MAX_REQUESTS=60 ``` ## Autentisering All API-kommunikasjon krever autentisering via Bearer token i Authorization-headeren: ```javascript // config.js import dotenv from 'dotenv'; dotenv.config(); export const config = { apiKey: process.env.KASSALAPP_API_KEY, apiUrl: process.env.KASSALAPP_API_URL || 'https://kassal.app/api/v1', rateLimit: { windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 60000, maxRequests: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS) || 60, }, server: { port: parseInt(process.env.PORT) || 3000, allowedOrigins: process.env.ALLOWED_ORIGINS?.split(',') || ['http://localhost:3000'], }, }; // Valider nødvendige miljøvariabler if (!config.apiKey) { throw new Error('KASSALAPP_API_KEY er ikke satt i miljøvariabler'); } ``` ## Oppsett med Axios ### Grunnleggende Axios-konfigurasjon ```javascript // api-client.js import axios from 'axios'; import { config } from './config.js'; const apiClient = axios.create({ baseURL: config.apiUrl, headers: { 'Authorization': `Bearer ${config.apiKey}`, 'Accept': 'application/json', 'Content-Type': 'application/json', 'User-Agent': 'KassalappNodeClient/1.0.0', }, timeout: 10000, // 10 sekunder timeout // Forbedret retry-konfigurasjon validateStatus: (status) => status >= 200 && status < 300, }); // Request interceptor for logging apiClient.interceptors.request.use( (request) => { console.log(`🚀 ${request.method?.toUpperCase()} ${request.url}`); return request; }, (error) => { console.error('❌ Request error:', error); return Promise.reject(error); } ); // Response interceptor for feilhåndtering apiClient.interceptors.response.use( (response) => { console.log(`✅ ${response.status} ${response.config.method?.toUpperCase()} ${response.config.url}`); return response; }, (error) => { if (error.response?.status === 429) { const retryAfter = error.response.headers['retry-after']; const waitTime = retryAfter ? parseInt(retryAfter) : 60; console.error(`⚠️ Rate limit nådd. Vent ${waitTime} sekunder før neste forespørsel.`); } else if (error.response?.status === 401) { console.error('🔒 API-nøkkel ugyldig eller utløpt'); } else if (error.response?.status >= 500) { console.error('🚨 Server feil - prøv igjen senere'); } else if (error.code === 'ECONNABORTED') { console.error('⏰ Forespørsel timed out'); } return Promise.reject(error); } ); export default apiClient; ``` ### Alternativ med moderne fetch API ```javascript // api-client-fetch.js (Node.js 18+) import { config } from './config.js'; // Moderne fetch-basert klient (krever Node.js 18+) export async function apiRequest(endpoint, params = {}, options = {}) { const url = new URL(`${config.apiUrl}${endpoint}`); // Legg til query parameters Object.keys(params).forEach(key => { if (params[key] !== undefined && params[key] !== null) { url.searchParams.append(key, String(params[key])); } }); const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), options.timeout || 10000); try { const response = await fetch(url.toString(), { headers: { 'Authorization': `Bearer ${config.apiKey}`, 'Accept': 'application/json', 'User-Agent': 'KassalappNodeClient/1.0.0', ...options.headers }, method: options.method || 'GET', body: options.body ? JSON.stringify(options.body) : undefined, signal: controller.signal, }); clearTimeout(timeoutId); if (!response.ok) { const errorBody = await response.text(); throw new Error(`HTTP ${response.status}: ${response.statusText}\n${errorBody}`); } return await response.json(); } catch (error) { clearTimeout(timeoutId); if (error.name === 'AbortError') { throw new Error('Request timed out'); } throw error; } } ``` ## TypeScript-grensesnitt ### Type-definisjoner for API-responser ```typescript // types/kassalapp.ts // Enums for better type safety export enum WeightUnit { KILOGRAM = 'kg', GRAM = 'g', LITER = 'l', MILLILITER = 'ml', PIECE = 'stk', } export enum SortOption { PRICE_ASC = 'price_asc', PRICE_DESC = 'price_desc', NAME_ASC = 'name_asc', NAME_DESC = 'name_desc', DISCOUNT_ASC = 'discount_asc', DISCOUNT_DESC = 'discount_desc', } export interface Store { readonly id: number; readonly name: string; readonly url: string; readonly logo: string; } export interface Product { id: number; name: string; brand: string | null; vendor: string | null; description: string | null; ingredients: string | null; url: string; image: string | null; store: Store; current_price: number | null; current_unit_price: number | null; weight: number | null; weight_unit: string | null; price_history: PriceHistory[]; barcode: Barcode | null; category: Category | null; allergens: Allergen[]; nutrition: Nutrition | null; labels: Label[]; created_at: string; updated_at: string; } export interface PriceHistory { price: number; date: string; } export interface Barcode { ean: string; products: Product[]; } export interface Category { id: number; name: string; depth: number; } export interface Allergen { code: string; display_name: string; contains: 'YES' | 'NO' | 'MAY_CONTAIN' | 'UNKNOWN'; } export interface Nutrition { display_name: string; amount: number; unit: string; code: string; } export interface Label { name: string; display_name: string; description: string; organization: string; } export interface PhysicalStore { id: string; group: string; name: string; address: string; phone: string | null; email: string | null; fax: string | null; position: { lat: number; lng: number; }; opening_hours: OpeningHours; logo: string; website: string; detail_url: string | null; store: Store; } export interface OpeningHours { [day: string]: { open: string; close: string; closed: boolean; }; } export interface ApiResponse { data: T[]; links?: { first: string; last: string; prev: string | null; next: string | null; }; meta?: { current_page: number; from: number; last_page: number; per_page: number; to: number; total: number; }; } export interface ApiError { message: string; errors?: Record; status: number; } export class KassalappApiError extends Error { constructor( public status: number, public errors?: Record, message?: string ) { super(message || 'API Error'); this.name = 'KassalappApiError'; } } ``` ### TypeScript API-klient ```typescript // kassalapp-client.ts import axios, { AxiosInstance } from 'axios'; import { Product, PhysicalStore, ApiResponse, Category, Label } from './types/kassalapp'; export class KassalappClient { private client: AxiosInstance; constructor(apiKey: string) { this.client = axios.create({ baseURL: 'https://kassal.app/api/v1', headers: { 'Authorization': `Bearer ${apiKey}`, 'Accept': 'application/json', }, }); } // Søk etter produkter async searchProducts(params: { search?: string; category?: string; category_id?: number; brand?: string; vendor?: string; price_min?: number; price_max?: number; sort?: 'price_asc' | 'price_desc' | 'name_asc' | 'name_desc' | 'date_asc' | 'date_desc'; page?: number; size?: number; unique?: boolean; exclude_without_ean?: boolean; } = {}): Promise> { const response = await this.client.get('/products', { params }); return response.data; } // Hent produkt via ID async getProductById(id: number): Promise { const response = await this.client.get(`/products/id/${id}`); return response.data.data; } // Hent produkt via EAN/strekkode async getProductByEan(ean: string): Promise { const response = await this.client.get(`/products/ean/${ean}`); return response.data.data; } // Hent produkt via URL async getProductByUrl(url: string): Promise { const response = await this.client.get('/products/find-by-url/single', { params: { url } }); return response.data.data; } // Sammenlign priser via URL async comparePricesByUrl(url: string): Promise { const response = await this.client.get('/products/find-by-url/compare', { params: { url } }); return response.data.data; } // Hent butikklokasjoner async getPhysicalStores(params: { search?: string; lat?: number; lng?: number; km?: number; group?: string; page?: number; size?: number; } = {}): Promise> { const response = await this.client.get('/physical-stores', { params }); return response.data; } // Hent kategorier async getCategories(): Promise { const response = await this.client.get('/categories'); return response.data.data; } // Hent merker/labels async getLabels(): Promise { const response = await this.client.get('/labels'); return response.data.data; } } ``` ## Produktsøk ### Enkelt søk med async/await ```javascript // search-products.js import apiClient from './api-client.js'; async function searchProducts(searchTerm) { try { const response = await apiClient.get('/products', { params: { search: searchTerm, size: 20, // Maks 100 } }); const { data, meta } = response.data; console.log(`Fant ${meta.total} produkter for "${searchTerm}"`); data.forEach(product => { console.log(` 📦 ${product.name} 💰 ${product.current_price ? `${product.current_price} kr` : 'Pris ikke tilgjengelig'} 🏪 ${product.store.name} 🔗 ${product.url} `); }); return data; } catch (error) { console.error('Feil ved søk:', error.message); throw error; } } // Eksempel bruk searchProducts('grandiosa').then(products => { console.log(`Hentet ${products.length} produkter`); }); ``` ### Avansert søk med filtrering ```javascript // advanced-search.js import apiClient from './api-client.js'; class ProductSearch { constructor() { this.results = []; this.currentPage = 1; this.totalPages = 1; } async search(options = {}) { const defaultOptions = { page: 1, size: 20, sort: 'price_asc', unique: false, exclude_without_ean: false, }; const params = { ...defaultOptions, ...options }; try { const response = await apiClient.get('/products', { params }); const { data, meta } = response.data; this.results = data; this.currentPage = meta.current_page; this.totalPages = meta.last_page; return { products: data, hasMore: this.currentPage < this.totalPages, total: meta.total, page: this.currentPage, pages: this.totalPages, }; } catch (error) { console.error('Søkefeil:', error); throw error; } } async nextPage(options = {}) { if (this.currentPage >= this.totalPages) { console.log('Ingen flere sider'); return null; } return this.search({ ...options, page: this.currentPage + 1, }); } // Finn billigste produkt async findCheapest(searchTerm) { const result = await this.search({ search: searchTerm, sort: 'price_asc', size: 10, // Hent flere for å finne faktisk billigste }); // Filter ut produkter uten pris og finn billigste const productsWithPrice = result.products.filter(p => p.current_price !== null && p.current_price > 0); if (productsWithPrice.length === 0) { throw new Error(`Ingen produkter med pris funnet for: ${searchTerm}`); } return productsWithPrice.reduce((cheapest, current) => current.current_price < cheapest.current_price ? current : cheapest ); } // Finn produkter innenfor prisområde async findInPriceRange(searchTerm, minPrice, maxPrice) { return this.search({ search: searchTerm, price_min: minPrice, price_max: maxPrice, sort: 'price_asc', }); } } // Eksempel bruk const searcher = new ProductSearch(); // Finn billigste melk searcher.findCheapest('melk').then(product => { if (product) { console.log(`Billigste melk: ${product.name} - ${product.current_price} kr`); } }); // Finn produkter mellom 10 og 50 kr searcher.findInPriceRange('pizza', 10, 50).then(result => { console.log(`Fant ${result.total} pizzaer mellom 10-50 kr`); }); ``` ## Filtrering og sortering ### Kategorifiltrering ```javascript // category-filter.js import apiClient from './api-client.js'; class CategoryFilter { constructor() { this.categories = null; } // Hent alle kategorier async fetchCategories() { try { const response = await apiClient.get('/categories'); this.categories = response.data.data; return this.categories; } catch (error) { console.error('Kunne ikke hente kategorier:', error); throw error; } } // Finn kategori basert på navn findCategoryByName(name) { if (!this.categories) { throw new Error('Kategorier ikke lastet. Kjør fetchCategories() først.'); } return this.categories.find(cat => cat.name.toLowerCase().includes(name.toLowerCase()) ); } // Hent produkter i kategori async getProductsInCategory(categoryId, options = {}) { try { const response = await apiClient.get('/products', { params: { category_id: categoryId, size: options.size || 20, page: options.page || 1, sort: options.sort || 'price_asc', } }); return response.data; } catch (error) { console.error('Feil ved henting av kategoriprodukter:', error); throw error; } } // Hent produkter via kategorinavn async searchByCategory(categoryName, searchTerm = '') { try { const response = await apiClient.get('/products', { params: { category: categoryName, search: searchTerm, size: 50, } }); return response.data; } catch (error) { console.error('Kategorisøk feilet:', error); throw error; } } } // Eksempel bruk async function eksempelKategoriSøk() { const filter = new CategoryFilter(); // Hent alle kategorier const categories = await filter.fetchCategories(); console.log(`Fant ${categories.length} kategorier`); // Finn meieriprodukter const meieri = filter.findCategoryByName('meieri'); if (meieri) { console.log(`Meieri-kategori ID: ${meieri.id}`); // Hent produkter i meierikategorien const result = await filter.getProductsInCategory(meieri.id, { sort: 'price_asc', size: 10, }); console.log(`\nBilligste meieriprodukter:`); result.data.forEach((product, index) => { console.log(`${index + 1}. ${product.name} - ${product.current_price} kr`); }); } // Søk direkte med kategorinavn const brødProdukter = await filter.searchByCategory('brød', 'grovbrød'); console.log(`\nFant ${brødProdukter.meta.total} grovbrød`); } eksempelKategoriSøk(); ``` ### Merke- og leverandørfiltrering ```javascript // brand-vendor-filter.js import apiClient from './api-client.js'; class BrandVendorFilter { // Søk produkter fra spesifikk merkevare async searchByBrand(brand, options = {}) { try { const response = await apiClient.get('/products', { params: { brand: brand, search: options.search || '', size: options.size || 20, sort: options.sort || 'name_asc', } }); return response.data; } catch (error) { console.error(`Feil ved søk etter ${brand} produkter:`, error); throw error; } } // Søk produkter fra spesifikk leverandør async searchByVendor(vendor, options = {}) { try { const response = await apiClient.get('/products', { params: { vendor: vendor, search: options.search || '', size: options.size || 20, sort: options.sort || 'price_asc', } }); return response.data; } catch (error) { console.error(`Feil ved søk etter ${vendor} produkter:`, error); throw error; } } // Sammenlign priser for samme merkevare på tvers av butikker async compareBrandPrices(brand, productSearch) { try { const response = await apiClient.get('/products', { params: { brand: brand, search: productSearch, size: 100, sort: 'price_asc', } }); const products = response.data.data; // Grupper etter EAN for å finne samme produkt i ulike butikker const grouped = {}; products.forEach(product => { const ean = product.barcode?.ean || 'no-ean'; if (!grouped[ean]) { grouped[ean] = { name: product.name, products: [], }; } grouped[ean].products.push({ store: product.store.name, price: product.current_price, url: product.url, }); }); return grouped; } catch (error) { console.error('Feil ved prissammenligning:', error); throw error; } } } // Eksempel bruk async function merkevareEksempel() { const filter = new BrandVendorFilter(); // Finn alle Tine-produkter const tineProdukter = await filter.searchByBrand('Tine', { sort: 'price_asc', size: 10, }); console.log('Billigste Tine-produkter:'); tineProdukter.data.forEach(product => { console.log(`- ${product.name}: ${product.current_price} kr (${product.store.name})`); }); // Sammenlign priser for Tine melk const prissammenligning = await filter.compareBrandPrices('Tine', 'melk'); console.log('\nPrissammenligning Tine melk:'); Object.values(prissammenligning).forEach(group => { console.log(`\n${group.name}:`); group.products .sort((a, b) => (a.price || 999) - (b.price || 999)) .forEach(p => { console.log(` ${p.store}: ${p.price ? p.price + ' kr' : 'Ikke tilgjengelig'}`); }); }); } merkevareEksempel(); ``` ## Strekkodeskanning ### EAN/Barcode oppslag ```javascript // barcode-scanner.js import apiClient from './api-client.js'; class BarcodeScanner { // Slå opp produkt via strekkode async scanBarcode(ean) { // Valider EAN-format (8 eller 13 siffer) if (!/^(\d{8}|\d{13})$/.test(ean)) { throw new Error('Ugyldig EAN-format. Må være 8 eller 13 siffer.'); } try { const response = await apiClient.get(`/products/ean/${ean}`); const products = response.data.data; if (products.length === 0) { return { found: false, message: `Ingen produkter funnet for EAN: ${ean}`, }; } // Finn billigste alternativ const cheapest = products.reduce((min, p) => (p.current_price && (!min.current_price || p.current_price < min.current_price)) ? p : min , products[0]); // Finn alle butikker som selger produktet const stores = products.map(p => ({ name: p.store.name, price: p.current_price, url: p.url, inStock: p.current_price !== null, })); return { found: true, product: products[0], // Første produkt for generell info cheapest: cheapest, stores: stores, totalStores: stores.length, }; } catch (error) { console.error('Feil ved strekkodeskanning:', error); throw error; } } // Batch-skanning av flere strekkoder async scanMultiple(eanList) { const results = []; for (const ean of eanList) { try { const result = await this.scanBarcode(ean); results.push({ ean, ...result }); // Vent litt mellom forespørsler for å unngå rate limiting await this.delay(100); } catch (error) { results.push({ ean, found: false, error: error.message, }); } } return results; } // Hjelpefunksjon for forsinkelse delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } // Generer handleliste med priser async generateShoppingList(eanList, preferredStore = null) { const items = []; let totalPrice = 0; for (const ean of eanList) { const result = await this.scanBarcode(ean); if (result.found) { let selectedProduct; if (preferredStore) { // Finn produkt fra foretrukket butikk selectedProduct = result.stores.find(s => s.name.toLowerCase().includes(preferredStore.toLowerCase()) ); } // Fallback til billigste hvis ikke funnet i foretrukket butikk if (!selectedProduct) { selectedProduct = result.cheapest; } items.push({ name: result.product.name, ean: ean, price: selectedProduct.current_price, store: selectedProduct.store?.name || 'Ukjent', url: selectedProduct.url, }); if (selectedProduct.current_price) { totalPrice += selectedProduct.current_price; } } await this.delay(100); } return { items, totalPrice, itemCount: items.length, }; } } // Eksempel bruk async function strekkodeEksempel() { const scanner = new BarcodeScanner(); // Skann enkelt produkt console.log('Skanner strekkode 7039010576963 (Grandiosa)...'); const result = await scanner.scanBarcode('7039010576963'); if (result.found) { console.log(`\n✅ Produkt funnet: ${result.product.name}`); console.log(`Billigste pris: ${result.cheapest.current_price} kr hos ${result.cheapest.store.name}`); console.log(`Tilgjengelig i ${result.totalStores} butikker:`); result.stores .sort((a, b) => (a.price || 999) - (b.price || 999)) .forEach(store => { const status = store.inStock ? '✅' : '❌'; const price = store.price ? `${store.price} kr` : 'Utsolgt'; console.log(` ${status} ${store.name}: ${price}`); }); } // Generer handleliste console.log('\n📝 Genererer handleliste...'); const handleliste = await scanner.generateShoppingList([ '7039010576963', // Grandiosa '7032069715253', // Tine Lettmelk '7037203778930', // Norvegia ], 'Rema'); console.log('\nHandleliste:'); handleliste.items.forEach((item, index) => { console.log(`${index + 1}. ${item.name} - ${item.price} kr (${item.store})`); }); console.log(`\nTotalpris: ${handleliste.totalPrice.toFixed(2)} kr`); } strekkodeEksempel(); ``` ## Butikklokasjoner ### Finn nærmeste butikker ```javascript // store-locator.js import apiClient from './api-client.js'; class StoreLocator { // Finn butikker nær en posisjon async findNearbyStores(lat, lng, radius = 5) { try { const response = await apiClient.get('/physical-stores', { params: { lat: lat, lng: lng, km: radius, size: 50, } }); const stores = response.data.data; // Beregn avstand (grov tilnærming) const storesWithDistance = stores.map(store => { const distance = this.calculateDistance( lat, lng, store.position.lat, store.position.lng ); return { ...store, distance: distance, }; }); // Sorter etter avstand storesWithDistance.sort((a, b) => a.distance - b.distance); return storesWithDistance; } catch (error) { console.error('Feil ved søk etter butikker:', error); throw error; } } // Finn butikker fra spesifikk kjede async findChainStores(chain, options = {}) { try { const response = await apiClient.get('/physical-stores', { params: { group: chain, search: options.search || '', size: options.size || 20, page: options.page || 1, } }); return response.data; } catch (error) { console.error(`Feil ved søk etter ${chain} butikker:`, error); throw error; } } // Søk butikker etter navn eller adresse async searchStores(searchTerm) { try { const response = await apiClient.get('/physical-stores', { params: { search: searchTerm, size: 20, } }); return response.data.data; } catch (error) { console.error('Feil ved butikksøk:', error); throw error; } } // Hent detaljer for spesifikk butikk async getStoreDetails(storeId) { try { const response = await apiClient.get(`/physical-stores/${storeId}`); return response.data.data; } catch (error) { console.error('Feil ved henting av butikkdetaljer:', error); throw error; } } // Sjekk om butikk er åpen nå isStoreOpen(store) { const now = new Date(); const dayNames = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday']; const today = dayNames[now.getDay()]; const todayHours = store.opening_hours[today]; if (!todayHours || todayHours.closed) { return false; } const currentTime = now.getHours() * 60 + now.getMinutes(); const [openHour, openMin] = todayHours.open.split(':').map(Number); const [closeHour, closeMin] = todayHours.close.split(':').map(Number); const openTime = openHour * 60 + openMin; const closeTime = closeHour * 60 + closeMin; return currentTime >= openTime && currentTime <= closeTime; } // Beregn avstand mellom to punkter (Haversine formel) calculateDistance(lat1, lon1, lat2, lon2) { const R = 6371; // Jordens radius i kilometer const dLat = this.toRad(lat2 - lat1); const dLon = this.toRad(lon2 - lon1); const a = Math.sin(dLat/2) * Math.sin(dLat/2) + Math.cos(this.toRad(lat1)) * Math.cos(this.toRad(lat2)) * Math.sin(dLon/2) * Math.sin(dLon/2); const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); return R * c; } toRad(deg) { return deg * (Math.PI/180); } // Finn beste handletur (butikker med flest av ønskede produkter) async findBestShoppingRoute(position, productEans) { const nearbyStores = await this.findNearbyStores(position.lat, position.lng, 10); const storeAvailability = {}; // Sjekk hvilke produkter hver butikk har for (const ean of productEans) { const response = await apiClient.get(`/products/ean/${ean}`); const products = response.data.data; products.forEach(product => { const storeGroup = this.getStoreGroup(product.store.name); if (!storeAvailability[storeGroup]) { storeAvailability[storeGroup] = { products: [], totalPrice: 0, }; } storeAvailability[storeGroup].products.push({ name: product.name, price: product.current_price, ean: ean, }); if (product.current_price) { storeAvailability[storeGroup].totalPrice += product.current_price; } }); await this.delay(100); } // Match med nærliggende butikker const recommendations = nearbyStores .filter(store => storeAvailability[store.group]) .map(store => ({ store: store, availability: storeAvailability[store.group], distance: store.distance, isOpen: this.isStoreOpen(store), })) .sort((a, b) => { // Prioriter butikker med flest produkter, deretter avstand const productDiff = b.availability.products.length - a.availability.products.length; if (productDiff !== 0) return productDiff; return a.distance - b.distance; }); return recommendations; } getStoreGroup(storeName) { // Map butikknavn til gruppe const mappings = { 'rema': 'rema_1000', 'kiwi': 'kiwi', 'meny': 'meny', 'spar': 'spar', 'joker': 'joker', 'coop': 'coop', 'extra': 'coop_extra', 'prix': 'coop_prix', 'mega': 'coop_mega', }; const lower = storeName.toLowerCase(); for (const [key, value] of Object.entries(mappings)) { if (lower.includes(key)) return value; } return storeName.toLowerCase().replace(/\s+/g, '_'); } delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } } // Eksempel bruk async function butikkEksempel() { const locator = new StoreLocator(); // Oslo sentrum koordinater const osloPosition = { lat: 59.9139, lng: 10.7522, }; console.log('🗺️ Finner butikker nær Oslo sentrum...\n'); const nearbyStores = await locator.findNearbyStores( osloPosition.lat, osloPosition.lng, 3 // 3 km radius ); console.log('Nærmeste butikker:'); nearbyStores.slice(0, 5).forEach((store, index) => { const openStatus = locator.isStoreOpen(store) ? '🟢 Åpen' : '🔴 Stengt'; console.log(`${index + 1}. ${store.name} (${store.group})`); console.log(` 📍 ${store.address}`); console.log(` 📏 ${store.distance.toFixed(2)} km unna`); console.log(` ${openStatus}`); console.log(''); }); // Søk etter REMA 1000 butikker console.log('🔍 Søker etter REMA 1000 butikker...\n'); const remaStores = await locator.findChainStores('rema_1000', { size: 5 }); console.log(`Fant ${remaStores.meta.total} REMA 1000 butikker totalt`); console.log('Første 5:'); remaStores.data.forEach(store => { console.log(`- ${store.name}, ${store.address}`); }); } butikkEksempel(); ``` ## Feilhåndtering ### Robust feilhåndtering med retry-logikk ```javascript // error-handler.js import apiClient from './api-client.js'; class ApiErrorHandler { constructor(maxRetries = 3, retryDelay = 1000) { this.maxRetries = maxRetries; this.retryDelay = retryDelay; } // Wrapper-funksjon med retry-logikk async executeWithRetry(fn, retries = 0) { try { return await fn(); } catch (error) { const shouldRetry = this.shouldRetry(error, retries); if (shouldRetry) { const delay = this.calculateDelay(retries); console.log(`⏳ Forsøker på nytt om ${delay/1000} sekunder... (forsøk ${retries + 1}/${this.maxRetries})`); await this.delay(delay); return this.executeWithRetry(fn, retries + 1); } throw this.enhanceError(error); } } // Avgjør om vi skal prøve på nytt shouldRetry(error, retries) { if (retries >= this.maxRetries) return false; const status = error.response?.status; // Retry på følgende statuskoder const retryableStatuses = [ 408, // Request Timeout 429, // Too Many Requests 500, // Internal Server Error 502, // Bad Gateway 503, // Service Unavailable 504, // Gateway Timeout ]; // Retry også på nettverksfeil if (!status && error.code === 'ECONNABORTED') return true; if (!status && error.message?.includes('Network Error')) return true; return retryableStatuses.includes(status); } // Beregn forsinkelse med eksponentiell backoff calculateDelay(retries) { return Math.min(this.retryDelay * Math.pow(2, retries), 30000); // Maks 30 sekunder } // Forbedre feilmeldinger enhanceError(error) { const status = error.response?.status; const message = error.response?.data?.message || error.message; const errorMessages = { 400: 'Ugyldig forespørsel. Sjekk parameterne dine.', 401: 'Autentisering feilet. Sjekk API-nøkkelen din.', 403: 'Ingen tilgang. Du har ikke tillatelse til denne ressursen.', 404: 'Ressurs ikke funnet.', 429: 'For mange forespørsler. Du har nådd rate limit.', 500: 'Serverfeil. Prøv igjen senere.', 502: 'Gateway-feil. API-et er midlertidig utilgjengelig.', 503: 'Tjenesten er utilgjengelig. Prøv igjen senere.', }; const enhancedError = new Error(errorMessages[status] || message); enhancedError.status = status; enhancedError.originalError = error; return enhancedError; } delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } // Logg feil til fil eller ekstern tjeneste async logError(error, context = {}) { const errorLog = { timestamp: new Date().toISOString(), message: error.message, status: error.status, context: context, stack: error.stack, }; // Her kan du sende til logging-tjeneste som Sentry, LogRocket, etc. console.error('📝 Error logged:', errorLog); // Lagre til lokal fil (valgfritt) try { const fs = await import('fs/promises'); await fs.appendFile( 'api-errors.log', JSON.stringify(errorLog) + '\n' ); } catch (writeError) { console.error('Kunne ikke skrive til loggfil:', writeError); } } } // Wrapper-klasse for API-klient med feilhåndtering class RobustApiClient { constructor() { this.errorHandler = new ApiErrorHandler(); } async searchProducts(params) { return this.errorHandler.executeWithRetry(async () => { const response = await apiClient.get('/products', { params }); return response.data; }); } async getProductByEan(ean) { return this.errorHandler.executeWithRetry(async () => { const response = await apiClient.get(`/products/ean/${ean}`); if (response.data.data.length === 0) { throw new Error(`Ingen produkter funnet for EAN: ${ean}`); } return response.data.data; }); } async getStores(params) { return this.errorHandler.executeWithRetry(async () => { const response = await apiClient.get('/physical-stores', { params }); return response.data; }); } } // Eksempel bruk med feilhåndtering async function robustEksempel() { const client = new RobustApiClient(); try { // Dette vil automatisk prøve på nytt ved feil const products = await client.searchProducts({ search: 'melk', size: 10, }); console.log(`Fant ${products.meta.total} produkter`); } catch (error) { console.error('Kritisk feil:', error.message); // Logg feilen for senere analyse await client.errorHandler.logError(error, { action: 'searchProducts', params: { search: 'melk', size: 10 }, }); } // Håndter manglende produkt try { const products = await client.getProductByEan('9999999999999'); // Ugyldig EAN } catch (error) { console.log('⚠️ Produkt ikke funnet:', error.message); } } robustEksempel(); ``` ## Rate Limiting ### Implementer rate limiting og køhåndtering ```javascript // rate-limiter.js import apiClient from './api-client.js'; class RateLimiter { constructor(maxRequests = 100, windowMs = 60000) { this.maxRequests = maxRequests; this.windowMs = windowMs; this.requests = []; this.queue = []; this.processing = false; } // Sjekk om vi kan gjøre en forespørsel nå canMakeRequest() { const now = Date.now(); // Fjern gamle forespørsler utenfor tidsvinduet this.requests = this.requests.filter(time => now - time < this.windowMs); return this.requests.length < this.maxRequests; } // Vent til vi kan gjøre neste forespørsel async waitForSlot() { while (!this.canMakeRequest()) { const oldestRequest = this.requests[0]; const waitTime = this.windowMs - (Date.now() - oldestRequest) + 100; console.log(`⏳ Rate limit nådd. Venter ${(waitTime/1000).toFixed(1)} sekunder...`); await this.delay(waitTime); } } // Utfør forespørsel med rate limiting async execute(fn) { await this.waitForSlot(); this.requests.push(Date.now()); try { return await fn(); } catch (error) { // Hvis vi får 429, vent ekstra lenge if (error.response?.status === 429) { const retryAfter = error.response.headers['retry-after']; const waitTime = retryAfter ? parseInt(retryAfter) * 1000 : 60000; console.log(`🛑 Rate limit truffet. Venter ${waitTime/1000} sekunder...`); await this.delay(waitTime); // Prøv på nytt return this.execute(fn); } throw error; } } // Købasert behandling av forespørsler async addToQueue(fn, priority = 0) { return new Promise((resolve, reject) => { this.queue.push({ fn, priority, resolve, reject }); this.queue.sort((a, b) => b.priority - a.priority); // Høyere prioritet først if (!this.processing) { this.processQueue(); } }); } async processQueue() { this.processing = true; while (this.queue.length > 0) { const { fn, resolve, reject } = this.queue.shift(); try { const result = await this.execute(fn); resolve(result); } catch (error) { reject(error); } } this.processing = false; } delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } // Hent gjenstående forespørsler getRemainingRequests() { const now = Date.now(); this.requests = this.requests.filter(time => now - time < this.windowMs); return this.maxRequests - this.requests.length; } // Reset rate limiter reset() { this.requests = []; this.queue = []; } } // Batch-prosessering med rate limiting class BatchProcessor { constructor(rateLimiter) { this.rateLimiter = rateLimiter; } // Prosesser mange EAN-er i batch async processBatch(eans, batchSize = 10) { const results = []; const batches = this.createBatches(eans, batchSize); console.log(`📦 Prosesserer ${eans.length} produkter i ${batches.length} batch...`); for (let i = 0; i < batches.length; i++) { const batch = batches[i]; console.log(`\nBatch ${i + 1}/${batches.length}:`); const batchResults = await Promise.all( batch.map(ean => this.rateLimiter.addToQueue(async () => { const response = await apiClient.get(`/products/ean/${ean}`); console.log(` ✅ ${ean} hentet`); return { ean, products: response.data.data }; }) ) ); results.push(...batchResults); // Vis fremgang const remaining = this.rateLimiter.getRemainingRequests(); console.log(`Gjenstående forespørsler denne perioden: ${remaining}`); } return results; } createBatches(items, batchSize) { const batches = []; for (let i = 0; i < items.length; i += batchSize) { batches.push(items.slice(i, i + batchSize)); } return batches; } // Prosesser med parallelle forespørsler men respekter rate limit async processParallel(tasks, concurrency = 5) { const results = []; const executing = []; for (const task of tasks) { const promise = this.rateLimiter.execute(task).then(result => { executing.splice(executing.indexOf(promise), 1); return result; }); results.push(promise); executing.push(promise); if (executing.length >= concurrency) { await Promise.race(executing); } } return Promise.all(results); } } // Eksempel bruk async function rateLimitEksempel() { const limiter = new RateLimiter(100, 60000); // 100 req per minutt const processor = new BatchProcessor(limiter); // Prosesser mange produkter const eans = [ '7039010576963', '7032069715253', '7037203778930', '7038010005458', '7311041019139', '7035620030123', '7090026220349', '7038010000737', '7032069715277', '7310865001801', '7311041013755', '7037203532117', ]; const results = await processor.processBatch(eans, 3); console.log('\n📊 Resultater:'); results.forEach(({ ean, products }) => { if (products.length > 0) { const cheapest = products.reduce((min, p) => (p.current_price && (!min.current_price || p.current_price < min.current_price)) ? p : min ); console.log(`${ean}: ${products[0].name} - Billigst: ${cheapest.current_price} kr`); } }); } rateLimitEksempel(); ``` ## Express.js API Wrapper ### Komplett Express API wrapper for Kassalapp ```javascript // server.js import express from 'express'; import cors from 'cors'; import helmet from 'helmet'; import rateLimit from 'express-rate-limit'; import { body, param, query, validationResult } from 'express-validator'; import winston from 'winston'; import { KassalappClient } from './kassalapp-client.js'; import { RateLimiter } from './rate-limiter.js'; import dotenv from 'dotenv'; dotenv.config(); const app = express(); const port = process.env.PORT || 3000; // Logger setup const logger = winston.createLogger({ level: 'info', format: winston.format.json(), transports: [ new winston.transports.File({ filename: 'error.log', level: 'error' }), new winston.transports.File({ filename: 'combined.log' }), ], }); if (process.env.NODE_ENV !== 'production') { logger.add(new winston.transports.Console({ format: winston.format.simple(), })); } // Security middleware - Enhanced configuration app.use(helmet({ contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], styleSrc: ["'self'", "'unsafe-inline'", 'https:'], scriptSrc: ["'self'", "'unsafe-inline'"], imgSrc: ["'self'", 'data:', 'https:'], connectSrc: ["'self'", 'https://kassal.app'], fontSrc: ["'self'", 'https:', 'data:'], objectSrc: ["'none'"], mediaSrc: ["'self'"], frameSrc: ["'none'"], }, }, crossOriginEmbedderPolicy: false, // For API-kall })); // CORS configuration app.use(cors({ origin: process.env.ALLOWED_ORIGINS?.split(',') || '*', credentials: true, optionsSuccessStatus: 200, })); app.use(express.json({ limit: '10mb' })); app.use(express.urlencoded({ extended: true, limit: '10mb' })); // Validation middleware const handleValidationErrors = (req, res, next) => { const errors = validationResult(req); if (!errors.isEmpty()) { return res.status(400).json({ errors: errors.array() }); } next(); }; // Rate limiting for vårt eget API - Enhanced configuration const limiter = rateLimit({ windowMs: config.rateLimit.windowMs, max: config.rateLimit.maxRequests, message: { error: 'For mange forespørsler, prøv igjen senere.', retryAfter: Math.ceil(config.rateLimit.windowMs / 1000) }, standardHeaders: true, // Return rate limit info in headers legacyHeaders: false, // Disable X-RateLimit-* headers skipSuccessfulRequests: false, // Count successful requests skipFailedRequests: false, // Count failed requests keyGenerator: (req) => req.ip, // Use IP for rate limit key handler: (req, res) => { logger.warn(`Rate limit exceeded for IP: ${req.ip} - Path: ${req.path}`); res.status(429).json({ error: 'For mange forespørsler', retryAfter: Math.ceil(config.rateLimit.windowMs / 1000), limit: config.rateLimit.maxRequests, window: config.rateLimit.windowMs / 1000 }); }, }); app.use('/api/', limiter); // Initialiser Kassalapp-klient const kassalapp = new KassalappClient(process.env.KASSALAPP_API_KEY); const rateLimiter = new RateLimiter(100, 60000); // Cache for ofte brukte data const cache = new Map(); const CACHE_TTL = 5 * 60 * 1000; // 5 minutter // Hjelpefunksjon for caching function getCached(key) { const cached = cache.get(key); if (cached && Date.now() - cached.timestamp < CACHE_TTL) { return cached.data; } return null; } function setCache(key, data) { cache.set(key, { data, timestamp: Date.now() }); } // API Endpoints // Helsejekk app.get('/health', (req, res) => { res.json({ status: 'OK', service: 'Kassalapp API Wrapper' }); }); // Søk produkter app.get('/api/products/search', async (req, res) => { try { const { q, category, brand, minPrice, maxPrice, sort, page = 1, size = 20 } = req.query; if (!q) { return res.status(400).json({ error: 'Søkeord (q) er påkrevd' }); } const cacheKey = `search:${JSON.stringify(req.query)}`; const cached = getCached(cacheKey); if (cached) { return res.json({ ...cached, cached: true }); } const result = await rateLimiter.execute(async () => { return kassalapp.searchProducts({ search: q, category, brand, price_min: minPrice ? parseFloat(minPrice) : undefined, price_max: maxPrice ? parseFloat(maxPrice) : undefined, sort, page: parseInt(page), size: Math.min(parseInt(size), 100), }); }); setCache(cacheKey, result); res.json(result); } catch (error) { console.error('Søkefeil:', error); res.status(500).json({ error: 'Kunne ikke utføre søk' }); } }); // Hent produkt via EAN app.get('/api/products/barcode/:ean', async (req, res) => { try { const { ean } = req.params; if (!/^(\d{8}|\d{13})$/.test(ean)) { return res.status(400).json({ error: 'Ugyldig EAN-format' }); } const cacheKey = `ean:${ean}`; const cached = getCached(cacheKey); if (cached) { return res.json({ ...cached, cached: true }); } const products = await rateLimiter.execute(async () => { return kassalapp.getProductByEan(ean); }); // Finn billigste og organiser data const result = { ean, found: products.length > 0, products: products, cheapest: products.reduce((min, p) => (p.current_price && (!min.current_price || p.current_price < min.current_price)) ? p : min , products[0]), stores: products.map(p => ({ name: p.store.name, price: p.current_price, url: p.url, })), }; setCache(cacheKey, result); res.json(result); } catch (error) { console.error('EAN-feil:', error); res.status(500).json({ error: 'Kunne ikke hente produkt' }); } }); // Sammenlign priser app.post('/api/products/compare', async (req, res) => { try { const { eans } = req.body; if (!Array.isArray(eans) || eans.length === 0) { return res.status(400).json({ error: 'EAN-liste er påkrevd' }); } const results = await Promise.all( eans.map(async (ean) => { try { const products = await rateLimiter.execute(async () => { return kassalapp.getProductByEan(ean); }); return { ean, name: products[0]?.name, prices: products.map(p => ({ store: p.store.name, price: p.current_price, unitPrice: p.current_unit_price, })).sort((a, b) => (a.price || 999) - (b.price || 999)), }; } catch (error) { return { ean, error: 'Ikke funnet' }; } }) ); res.json({ results }); } catch (error) { console.error('Sammenligningsfeil:', error); res.status(500).json({ error: 'Kunne ikke sammenligne priser' }); } }); // Finn nærmeste butikker app.get('/api/stores/nearby', async (req, res) => { try { const { lat, lng, radius = 5 } = req.query; if (!lat || !lng) { return res.status(400).json({ error: 'Latitude og longitude er påkrevd' }); } const stores = await rateLimiter.execute(async () => { return kassalapp.getPhysicalStores({ lat: parseFloat(lat), lng: parseFloat(lng), km: parseFloat(radius), size: 50, }); }); // Legg til åpningstider-status const now = new Date(); const dayNames = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday']; const today = dayNames[now.getDay()]; const enrichedStores = stores.data.map(store => { const todayHours = store.opening_hours[today]; const isOpen = todayHours && !todayHours.closed; return { ...store, isOpen, todayHours, }; }); res.json({ stores: enrichedStores, total: stores.meta.total, }); } catch (error) { console.error('Butikksøkfeil:', error); res.status(500).json({ error: 'Kunne ikke hente butikker' }); } }); // Handleliste-optimalisering app.post('/api/shopping-list/optimize', async (req, res) => { try { const { items, location, preferredStore } = req.body; if (!Array.isArray(items) || items.length === 0) { return res.status(400).json({ error: 'Handleliste er påkrevd' }); } const optimized = []; let totalPrice = 0; for (const item of items) { const ean = item.ean || item; const products = await rateLimiter.execute(async () => { return kassalapp.getProductByEan(ean); }); if (products.length > 0) { let selected; if (preferredStore) { selected = products.find(p => p.store.name.toLowerCase().includes(preferredStore.toLowerCase()) ); } if (!selected) { selected = products.reduce((min, p) => (p.current_price && (!min.current_price || p.current_price < min.current_price)) ? p : min ); } optimized.push({ ean, name: selected.name, store: selected.store.name, price: selected.current_price, url: selected.url, }); if (selected.current_price) { totalPrice += selected.current_price; } } } res.json({ items: optimized, totalPrice, savings: null, // Kan beregnes hvis vi sammenligner med dyreste alternativ }); } catch (error) { console.error('Handleliste-feil:', error); res.status(500).json({ error: 'Kunne ikke optimalisere handleliste' }); } }); // Prisvarsler webhook endpoint app.post('/api/alerts/webhook', async (req, res) => { try { const { ean, targetPrice, webhookUrl } = req.body; // Her ville du normalt sette opp en scheduled job eller lignende // For dette eksempelet returnerer vi bare en bekreftelse res.json({ message: 'Prisvarsel opprettet', ean, targetPrice, webhookUrl, id: Date.now().toString(), }); } catch (error) { res.status(500).json({ error: 'Kunne ikke opprette prisvarsel' }); } }); // WebSocket for sanntidsoppdateringer (med Socket.io) import { createServer } from 'http'; import { Server } from 'socket.io'; const server = createServer(app); const io = new Server(server, { cors: { origin: '*', methods: ['GET', 'POST'], }, }); // Track intervals per socket to prevent memory leaks const socketIntervals = new Map(); io.on('connection', (socket) => { console.log('Klient tilkoblet:', socket.id); socket.on('track-price', async (ean) => { // Clear any existing interval for this socket if (socketIntervals.has(socket.id)) { clearInterval(socketIntervals.get(socket.id)); } // Simuler prissjekking hvert 30. sekund const interval = setInterval(async () => { try { const products = await kassalapp.getProductByEan(ean); const cheapest = products.reduce((min, p) => (p.current_price && (!min.current_price || p.current_price < min.current_price)) ? p : min ); socket.emit('price-update', { ean, product: cheapest.name, price: cheapest.current_price, store: cheapest.store.name, timestamp: new Date().toISOString(), }); } catch (error) { socket.emit('error', { message: 'Kunne ikke hente pris' }); } }, 30000); // Store interval reference socketIntervals.set(socket.id, interval); }); socket.on('disconnect', () => { // Clean up interval on disconnect if (socketIntervals.has(socket.id)) { clearInterval(socketIntervals.get(socket.id)); socketIntervals.delete(socket.id); } console.log('Klient frakoblet:', socket.id); }); }); // Start server server.listen(port, () => { console.log(`🚀 Kassalapp API Wrapper kjører på http://localhost:${port}`); console.log(`📚 API dokumentasjon: http://localhost:${port}/api-docs`); }); // Graceful shutdown process.on('SIGTERM', () => { console.log('SIGTERM mottatt. Avslutter...'); server.close(() => { console.log('Server avsluttet'); process.exit(0); }); }); ``` ## Praktiske eksempler ### 1. Prissporing-tjeneste ```javascript // price-tracker.js import { KassalappClient } from './kassalapp-client.js'; import fs from 'fs/promises'; import nodemailer from 'nodemailer'; class PriceTracker { constructor(apiKey) { this.client = new KassalappClient(apiKey); this.trackedProducts = new Map(); this.priceHistory = new Map(); } // Legg til produkt for sporing addProduct(ean, targetPrice, email) { this.trackedProducts.set(ean, { targetPrice, email, lastChecked: null, lowestPrice: null, alerts: [], }); } // Sjekk priser for alle sporede produkter async checkPrices() { console.log(`🔍 Sjekker priser for ${this.trackedProducts.size} produkter...`); for (const [ean, config] of this.trackedProducts.entries()) { try { const products = await this.client.getProductByEan(ean); if (products.length === 0) continue; const cheapest = products.reduce((min, p) => (p.current_price && (!min.current_price || p.current_price < min.current_price)) ? p : min ); const currentPrice = cheapest.current_price; // Oppdater historikk if (!this.priceHistory.has(ean)) { this.priceHistory.set(ean, []); } this.priceHistory.get(ean).push({ price: currentPrice, store: cheapest.store.name, timestamp: new Date().toISOString(), }); // Sjekk om vi skal sende varsel if (currentPrice && currentPrice <= config.targetPrice) { if (!config.lowestPrice || currentPrice < config.lowestPrice) { await this.sendAlert(ean, cheapest, config); config.lowestPrice = currentPrice; } } config.lastChecked = new Date().toISOString(); // Vent litt mellom hver sjekk await this.delay(1000); } catch (error) { console.error(`Feil ved sjekk av ${ean}:`, error.message); } } await this.saveHistory(); } // Send e-postvarsel async sendAlert(ean, product, config) { console.log(`📧 Sender prisvarsel for ${product.name}`); const transporter = nodemailer.createTransport({ service: 'gmail', auth: { user: process.env.EMAIL_USER, pass: process.env.EMAIL_PASS, }, }); const mailOptions = { from: process.env.EMAIL_USER, to: config.email, subject: `Prisvarsel: ${product.name}`, html: `

Godt nytt! Prisen har falt!

${product.name}

Ny pris: ${product.current_price} kr

Butikk: ${product.store.name}

Din målpris: ${config.targetPrice} kr

Se produkt

`, }; try { await transporter.sendMail(mailOptions); config.alerts.push({ price: product.current_price, timestamp: new Date().toISOString(), }); } catch (error) { console.error('Kunne ikke sende e-post:', error); } } // Lagre prishistorikk til fil async saveHistory() { const data = { tracked: Array.from(this.trackedProducts.entries()), history: Array.from(this.priceHistory.entries()), }; await fs.writeFile( 'price-history.json', JSON.stringify(data, null, 2) ); } // Last inn tidligere data async loadHistory() { try { const data = await fs.readFile('price-history.json', 'utf-8'); const parsed = JSON.parse(data); this.trackedProducts = new Map(parsed.tracked); this.priceHistory = new Map(parsed.history); } catch (error) { console.log('Ingen tidligere historikk funnet'); } } // Generer rapport generateReport() { const report = []; for (const [ean, history] of this.priceHistory.entries()) { const config = this.trackedProducts.get(ean); const prices = history.map(h => h.price).filter(p => p); if (prices.length === 0) continue; const min = Math.min(...prices); const max = Math.max(...prices); const avg = prices.reduce((a, b) => a + b, 0) / prices.length; const latest = history[history.length - 1]; report.push({ ean, lastPrice: latest.price, lastStore: latest.store, lowestPrice: min, highestPrice: max, averagePrice: avg.toFixed(2), priceDrops: config?.alerts.length || 0, dataPoints: history.length, }); } return report; } delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } // Start automatisk sjekking startTracking(intervalMinutes = 60) { console.log(`🚀 Starter prissporing (sjekker hver ${intervalMinutes}. minutt)`); // Første sjekk umiddelbart this.checkPrices(); // Deretter med intervall setInterval(() => { this.checkPrices(); }, intervalMinutes * 60 * 1000); } } // Eksempel bruk async function startPriceTracking() { const tracker = new PriceTracker(process.env.KASSALAPP_API_KEY); // Last inn tidligere data await tracker.loadHistory(); // Legg til produkter for sporing tracker.addProduct('7039010576963', 50, 'din@epost.no'); // Grandiosa tracker.addProduct('7032069715253', 15, 'din@epost.no'); // Melk // Start automatisk sporing tracker.startTracking(30); // Sjekk hver 30. minutt // Generer rapport hver dag setInterval(() => { const report = tracker.generateReport(); console.log('📊 Daglig rapport:', report); }, 24 * 60 * 60 * 1000); } startPriceTracking(); ``` ### 2. Strekkode-skanning backend ```javascript // barcode-api.js import express from 'express'; import multer from 'multer'; import Quagga from 'quagga'; import { KassalappClient } from './kassalapp-client.js'; const app = express(); const upload = multer({ memory: true }); const kassalapp = new KassalappClient(process.env.KASSALAPP_API_KEY); // Dekod strekkode fra bilde async function decodeBarcode(buffer) { return new Promise((resolve, reject) => { Quagga.decodeSingle({ src: buffer, numOfWorkers: 0, inputStream: { size: 800, }, decoder: { readers: ['ean_reader', 'ean_8_reader'], }, }, (result) => { if (result && result.codeResult) { resolve(result.codeResult.code); } else { reject(new Error('Kunne ikke lese strekkode')); } }); }); } // Endpoint for bildeopplasting og skanning app.post('/api/scan', upload.single('image'), async (req, res) => { try { if (!req.file) { return res.status(400).json({ error: 'Bilde mangler' }); } // Dekod strekkode fra bilde const ean = await decodeBarcode(req.file.buffer); // Hent produktinformasjon const products = await kassalapp.getProductByEan(ean); if (products.length === 0) { return res.json({ ean, found: false, message: 'Produktet ble ikke funnet i databasen', }); } // Finn billigste og dyreste const sorted = [...products].sort((a, b) => (a.current_price || 999) - (b.current_price || 999) ); const cheapest = sorted[0]; const mostExpensive = sorted[sorted.length - 1]; const savings = mostExpensive.current_price - cheapest.current_price; res.json({ ean, found: true, product: { name: products[0].name, brand: products[0].brand, image: products[0].image, ingredients: products[0].ingredients, allergens: products[0].allergens, nutrition: products[0].nutrition, }, pricing: { cheapest: { store: cheapest.store.name, price: cheapest.current_price, unitPrice: cheapest.current_unit_price, url: cheapest.url, }, mostExpensive: { store: mostExpensive.store.name, price: mostExpensive.current_price, }, potentialSavings: savings, }, availability: products.map(p => ({ store: p.store.name, price: p.current_price, inStock: p.current_price !== null, })), }); } catch (error) { console.error('Skanningsfeil:', error); res.status(500).json({ error: 'Kunne ikke behandle bilde' }); } }); // Manuell EAN-inntasting app.get('/api/scan/:ean', async (req, res) => { try { const { ean } = req.params; const products = await kassalapp.getProductByEan(ean); if (products.length === 0) { return res.status(404).json({ error: 'Produkt ikke funnet', ean, }); } // Samme logikk som bildeskanningen res.json({ ean, product: products[0], stores: products.map(p => ({ name: p.store.name, price: p.current_price, url: p.url, })), }); } catch (error) { res.status(500).json({ error: 'Serverfeil' }); } }); app.listen(3000, () => { console.log('🔍 Strekkode-API kjører på http://localhost:3000'); }); ``` ### 3. Webhook-integrasjon for prisvarsler ```javascript // webhook-service.js import express from 'express'; import axios from 'axios'; import crypto from 'crypto'; import { KassalappClient } from './kassalapp-client.js'; class WebhookService { constructor(apiKey) { this.client = new KassalappClient(apiKey); this.webhooks = new Map(); } // Registrer webhook registerWebhook(id, config) { this.webhooks.set(id, { url: config.url, secret: config.secret || crypto.randomBytes(32).toString('hex'), events: config.events || ['price_drop', 'back_in_stock'], filters: config.filters || {}, active: true, created: new Date().toISOString(), lastTriggered: null, errorCount: 0, }); return this.webhooks.get(id); } // Send webhook async sendWebhook(webhookId, event, data) { const webhook = this.webhooks.get(webhookId); if (!webhook || !webhook.active) return; const payload = { event, data, timestamp: new Date().toISOString(), webhookId, }; // Generer signatur for verifisering const signature = crypto .createHmac('sha256', webhook.secret) .update(JSON.stringify(payload)) .digest('hex'); try { const response = await axios.post(webhook.url, payload, { headers: { 'Content-Type': 'application/json', 'X-Webhook-Signature': signature, 'X-Webhook-Event': event, }, timeout: 10000, }); webhook.lastTriggered = new Date().toISOString(); webhook.errorCount = 0; console.log(`✅ Webhook sendt: ${event} til ${webhook.url}`); return { success: true, status: response.status }; } catch (error) { webhook.errorCount++; // Deaktiver webhook etter 5 feil if (webhook.errorCount >= 5) { webhook.active = false; console.log(`❌ Webhook deaktivert etter 5 feil: ${webhookId}`); } console.error(`Webhook-feil: ${error.message}`); return { success: false, error: error.message }; } } // Overvåk produkter for hendelser async monitorProducts() { for (const [webhookId, webhook] of this.webhooks.entries()) { if (!webhook.active) continue; const { ean, targetPrice } = webhook.filters; if (ean) { try { const products = await this.client.getProductByEan(ean); if (products.length > 0) { const cheapest = products.reduce((min, p) => (p.current_price && (!min.current_price || p.current_price < min.current_price)) ? p : min ); // Sjekk for prisfall if (webhook.events.includes('price_drop') && targetPrice) { if (cheapest.current_price && cheapest.current_price <= targetPrice) { await this.sendWebhook(webhookId, 'price_drop', { product: cheapest, targetPrice, currentPrice: cheapest.current_price, }); } } // Sjekk for tilbake på lager if (webhook.events.includes('back_in_stock')) { const inStock = products.filter(p => p.current_price !== null); if (inStock.length > 0 && !webhook.lastStockStatus) { await this.sendWebhook(webhookId, 'back_in_stock', { product: inStock[0], stores: inStock.map(p => p.store.name), }); } webhook.lastStockStatus = inStock.length > 0; } } } catch (error) { console.error(`Overvåkingsfeil for ${webhookId}:`, error); } } // Vent mellom hver webhook-sjekk await this.delay(500); } } delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } // Start overvåking startMonitoring(intervalMinutes = 15) { console.log(`🔔 Starter webhook-overvåking (hver ${intervalMinutes}. minutt)`); setInterval(() => { this.monitorProducts(); }, intervalMinutes * 60 * 1000); // Første sjekk umiddelbart this.monitorProducts(); } } // Express server for webhook-administrasjon const app = express(); app.use(express.json()); const webhookService = new WebhookService(process.env.KASSALAPP_API_KEY); // Registrer ny webhook app.post('/webhooks', (req, res) => { const webhookId = crypto.randomBytes(16).toString('hex'); const webhook = webhookService.registerWebhook(webhookId, req.body); res.json({ id: webhookId, secret: webhook.secret, message: 'Webhook registrert', }); }); // List webhooks app.get('/webhooks', (req, res) => { const webhooks = Array.from(webhookService.webhooks.entries()).map(([id, config]) => ({ id, url: config.url, events: config.events, active: config.active, errorCount: config.errorCount, lastTriggered: config.lastTriggered, })); res.json({ webhooks }); }); // Slett webhook app.delete('/webhooks/:id', (req, res) => { const { id } = req.params; if (webhookService.webhooks.delete(id)) { res.json({ message: 'Webhook slettet' }); } else { res.status(404).json({ error: 'Webhook ikke funnet' }); } }); // Test webhook app.post('/webhooks/:id/test', async (req, res) => { const { id } = req.params; const result = await webhookService.sendWebhook(id, 'test', { message: 'Dette er en test', timestamp: new Date().toISOString(), }); res.json(result); }); // Start server app.listen(3001, () => { console.log('🔔 Webhook-tjeneste kjører på http://localhost:3001'); webhookService.startMonitoring(15); }); // Eksempel webhook-mottaker const receiverApp = express(); receiverApp.use(express.json()); receiverApp.post('/webhook-receiver', (req, res) => { const signature = req.headers['x-webhook-signature']; const event = req.headers['x-webhook-event']; console.log(`📨 Mottatt webhook: ${event}`); console.log('Data:', req.body); // Verifiser signatur her hvis ønskelig res.status(200).json({ received: true }); }); receiverApp.listen(3002, () => { console.log('📬 Webhook-mottaker kjører på http://localhost:3002/webhook-receiver'); }); ``` ## Avslutning Denne guiden har vist deg hvordan du kan bruke Kassalapp API med Node.js for å bygge kraftige applikasjoner for prissammenligning og handleplanlegging. Med eksemplene og kodesnuttene kan du nå: - ✅ Søke etter produkter og sammenligne priser på tvers av butikker - 📱 Skanne strekkoder og få detaljert produktinformasjon - 🗺️ Finne nærmeste butikker basert på lokasjon - 🔔 Bygge prisvarsler og webhook-integrasjoner for automatisk overvåking - 🚀 Lage en komplett Express.js API wrapper med modern sikkerhet - 📊 Implementere avansert feilhåndtering og logging - ⚡ Håndtere rate limiting og optimalisere ytelse ### Beste praksis sammenfattet: 1. **Sikkerhet først**: Bruk alltid HTTPS, valider input, og håndter API-nøkler sikkert 2. **Robust feilhåndtering**: Implementer retry-logikk og detaljert logging 3. **Respekter rate limits**: Implementer kø-basert behandling for bulk-operasjoner 4. **TypeScript**: Bruk typer for bedre kodekvalitet og færre feil 5. **Miljøkonfigurasjon**: Hold all konfigurasjon i miljøvariabler 6. **Testing**: Test alle API-integrasjoner grundig For mer informasjon og oppdatert dokumentasjon, besøk [https://kassal.app/api](https://kassal.app/api). ### Nyttige ressurser - [Kassalapp API Dokumentasjon](https://kassal.app/api) - [GitHub Repository med eksempler](https://github.com/kassalapp/nodejs-examples) (hvis tilgjengelig) - [Postman Collection](https://kassal.app/api/postman) (hvis tilgjengelig) ### Lisens og bruksvilkår Husk å følge Kassalapp sine bruksvilkår og respekter rate limits. For kommersiell bruk eller høyere rate limits, kontakt Kassalapp for enterprise-løsninger. Happy coding! 🚀