156 lines
5.0 KiB
TypeScript
156 lines
5.0 KiB
TypeScript
import { z } from "zod";
|
||
import { BRAND } from "@app/shared-types";
|
||
import type { Connector, ConnectorContext, ConnectorHealth, ImportResult } from "./types.js";
|
||
|
||
/**
|
||
* Open Food Facts – öppen produktdatabas (ODbL-licens) för streckkodsuppslag
|
||
* (spec §11 steg 3: "kontrollera tillåten extern datakälla").
|
||
* Attribution krävs enligt licensen och visas i appen.
|
||
*/
|
||
|
||
const offProductSchema = z.object({
|
||
code: z.string(),
|
||
product: z
|
||
.object({
|
||
product_name: z.string().optional(),
|
||
brands: z.string().optional(),
|
||
quantity: z.string().optional(),
|
||
ingredients_text: z.string().optional(),
|
||
allergens_tags: z.array(z.string()).optional(),
|
||
nutriments: z.record(z.string(), z.unknown()).optional(),
|
||
image_url: z.string().optional(),
|
||
lang: z.string().optional(),
|
||
})
|
||
.optional(),
|
||
status: z.number().optional(),
|
||
});
|
||
|
||
export interface OffProduct {
|
||
gtin: string;
|
||
name?: string | undefined;
|
||
brand?: string | undefined;
|
||
quantityText?: string | undefined;
|
||
ingredientsText?: string | undefined;
|
||
allergenTags: string[];
|
||
nutrimentsPer100g: Partial<{
|
||
kcal: number;
|
||
proteinG: number;
|
||
carbsG: number;
|
||
fatG: number;
|
||
saturatedFatG: number;
|
||
fiberG: number;
|
||
sugarG: number;
|
||
saltG: number;
|
||
}>;
|
||
imageUrl?: string | undefined;
|
||
}
|
||
|
||
export class OpenFoodFactsConnector implements Connector<OffProduct> {
|
||
readonly id = "open-food-facts" as const;
|
||
readonly nameSv = "Open Food Facts";
|
||
readonly legalBasis = "open_data" as const;
|
||
readonly attributionSv = "Produktdata: Open Food Facts (ODbL)";
|
||
|
||
constructor(
|
||
private readonly baseUrl: string = process.env.OFF_API_URL ?? "https://world.openfoodfacts.org",
|
||
private readonly fetchImpl: typeof fetch = fetch,
|
||
) {}
|
||
|
||
async connect(): Promise<void> {}
|
||
async authenticate(): Promise<boolean> {
|
||
return true;
|
||
}
|
||
|
||
/** Slå upp en produkt via GTIN/EAN. */
|
||
async lookupBarcode(gtin: string): Promise<OffProduct | null> {
|
||
const res = await this.fetchImpl(`${this.baseUrl}/api/v2/product/${gtin}.json`, {
|
||
headers: { "user-agent": `${BRAND.name}/0.1 (kontakt: ${BRAND.supportEmail})` },
|
||
signal: AbortSignal.timeout(10_000),
|
||
});
|
||
if (res.status === 404) return null;
|
||
if (!res.ok) throw new Error(`Open Food Facts svarade ${res.status}`);
|
||
const parsed = offProductSchema.safeParse(await res.json());
|
||
if (!parsed.success || !parsed.data.product) return null;
|
||
const p = parsed.data.product;
|
||
const n = (p.nutriments ?? {}) as Record<string, unknown>;
|
||
const num = (key: string): number | undefined => {
|
||
const v = n[key];
|
||
return typeof v === "number" && Number.isFinite(v) ? v : undefined;
|
||
};
|
||
const per100: OffProduct["nutrimentsPer100g"] = {};
|
||
const kcal = num("energy-kcal_100g");
|
||
if (kcal !== undefined) per100.kcal = kcal;
|
||
const protein = num("proteins_100g");
|
||
if (protein !== undefined) per100.proteinG = protein;
|
||
const carbs = num("carbohydrates_100g");
|
||
if (carbs !== undefined) per100.carbsG = carbs;
|
||
const fat = num("fat_100g");
|
||
if (fat !== undefined) per100.fatG = fat;
|
||
const satFat = num("saturated-fat_100g");
|
||
if (satFat !== undefined) per100.saturatedFatG = satFat;
|
||
const fiber = num("fiber_100g");
|
||
if (fiber !== undefined) per100.fiberG = fiber;
|
||
const sugar = num("sugars_100g");
|
||
if (sugar !== undefined) per100.sugarG = sugar;
|
||
const salt = num("salt_100g");
|
||
if (salt !== undefined) per100.saltG = salt;
|
||
|
||
return {
|
||
gtin: parsed.data.code,
|
||
name: p.product_name,
|
||
brand: p.brands,
|
||
quantityText: p.quantity,
|
||
ingredientsText: p.ingredients_text,
|
||
allergenTags: p.allergens_tags ?? [],
|
||
nutrimentsPer100g: per100,
|
||
imageUrl: p.image_url,
|
||
};
|
||
}
|
||
|
||
async importData(
|
||
_ctx: ConnectorContext,
|
||
params?: Record<string, unknown>,
|
||
): Promise<ImportResult<OffProduct>> {
|
||
const gtin = typeof params?.gtin === "string" ? params.gtin : null;
|
||
if (!gtin)
|
||
return {
|
||
items: [],
|
||
source: this.id,
|
||
importedAt: new Date().toISOString(),
|
||
warnings: ["gtin saknas"],
|
||
};
|
||
const product = await this.lookupBarcode(gtin);
|
||
return {
|
||
items: product ? [product] : [],
|
||
source: this.id,
|
||
importedAt: new Date().toISOString(),
|
||
warnings: [],
|
||
};
|
||
}
|
||
|
||
async sync(ctx: ConnectorContext): Promise<ImportResult<OffProduct>> {
|
||
return this.importData(ctx);
|
||
}
|
||
async disconnect(): Promise<void> {}
|
||
|
||
async healthCheck(): Promise<ConnectorHealth> {
|
||
try {
|
||
const res = await this.fetchImpl(`${this.baseUrl}/api/v2/product/7310865004703.json`, {
|
||
headers: { "user-agent": `${BRAND.name}/0.1` },
|
||
signal: AbortSignal.timeout(8_000),
|
||
});
|
||
return {
|
||
ok: res.ok || res.status === 404,
|
||
detail: `status ${res.status}`,
|
||
checkedAt: new Date().toISOString(),
|
||
};
|
||
} catch (err) {
|
||
return {
|
||
ok: false,
|
||
detail: err instanceof Error ? err.message : String(err),
|
||
checkedAt: new Date().toISOString(),
|
||
};
|
||
}
|
||
}
|
||
}
|