Files
Cibello-app/packages/connectors/src/openFoodFacts.ts
T
2026-08-05 19:21:11 +07:00

156 lines
5.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(),
};
}
}
}