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
- Kom i gang
- Autentisering
- Oppsett med Axios
- TypeScript-grensesnitt
- Produktsøk
- Filtrering og sortering
- Strekkodeskanning
- Butikklokasjoner
- Feilhåndtering
- Rate limiting
- Express.js API Wrapper
- Praktiske eksempler
Kom i gang
1. Registrer deg for API-nøkkel
Først må du registrere deg for å få en API-nøkkel:
- Gå til https://kassal.app/api
- Opprett en konto eller logg inn
- Generer en ny API-nøkkel
- Lagre nøkkelen trygt - du får ikke se den igjen!
2. Installer nødvendige pakker
# 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:
# 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:
// 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
// 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
// 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
// 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<T> {
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<string, string[]>;
status: number;
}
export class KassalappApiError extends Error {
constructor(
public status: number,
public errors?: Record<string, string[]>,
message?: string
) {
super(message || 'API Error');
this.name = 'KassalappApiError';
}
}
TypeScript API-klient
// 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<ApiResponse<Product>> {
const response = await this.client.get('/products', { params });
return response.data;
}
// Hent produkt via ID
async getProductById(id: number): Promise<Product> {
const response = await this.client.get(`/products/id/${id}`);
return response.data.data;
}
// Hent produkt via EAN/strekkode
async getProductByEan(ean: string): Promise<Product[]> {
const response = await this.client.get(`/products/ean/${ean}`);
return response.data.data;
}
// Hent produkt via URL
async getProductByUrl(url: string): Promise<Product> {
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<Product[]> {
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<ApiResponse<PhysicalStore>> {
const response = await this.client.get('/physical-stores', { params });
return response.data;
}
// Hent kategorier
async getCategories(): Promise<Category[]> {
const response = await this.client.get('/categories');
return response.data.data;
}
// Hent merker/labels
async getLabels(): Promise<Label[]> {
const response = await this.client.get('/labels');
return response.data.data;
}
}
Produktsøk
Enkelt søk med async/await
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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: `
<h2>Godt nytt! Prisen har falt!</h2>
<p><strong>${product.name}</strong></p>
<p>Ny pris: <strong>${product.current_price} kr</strong></p>
<p>Butikk: ${product.store.name}</p>
<p>Din målpris: ${config.targetPrice} kr</p>
<p><a href="${product.url}">Se produkt</a></p>
`,
};
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
// 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
// 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:
- Sikkerhet først: Bruk alltid HTTPS, valider input, og håndter API-nøkler sikkert
- Robust feilhåndtering: Implementer retry-logikk og detaljert logging
- Respekter rate limits: Implementer kø-basert behandling for bulk-operasjoner
- TypeScript: Bruk typer for bedre kodekvalitet og færre feil
- Miljøkonfigurasjon: Hold all konfigurasjon i miljøvariabler
- Testing: Test alle API-integrasjoner grundig
For mer informasjon og oppdatert dokumentasjon, besøk https://kassal.app/api.
Nyttige ressurser
- Kassalapp API Dokumentasjon
- GitHub Repository med eksempler (hvis tilgjengelig)
- Postman Collection (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! 🚀