import { CalorieSuggestion } from "../../types/gen/types.gen"; /** * Externí zdroj energetických hodnot. * * Záměrně je to úzké rozhraní o jedné metodě — poskytovatelé bývají nespolehliví * (viz {@link OpenFoodFactsProvider}) a je potřeba je umět vyměnit beze změny * zbytku aplikace. Implementace nesmí vyhazovat výjimky: při nedostupnosti * vrací `unavailable`, aby hledání jen přišlo o návrhy a nespadlo celé. */ export interface CalorieProvider { /** Název poskytovatele, propisuje se do odpovědi API. */ readonly name: string; /** * Najde potraviny odpovídající názvu. * * @param query hledaný název * @param limit maximální počet návrhů */ search(query: string, limit: number): Promise; } /** Výsledek dotazu na poskytovatele. `unavailable` značí, že zdroj neodpověděl. */ export type ProviderResult = | { unavailable: false, suggestions: CalorieSuggestion[] } | { unavailable: true }; /** Timeout dotazu na poskytovatele. Hledání kalorií nesmí blokovat zadávání jídla. */ const REQUEST_TIMEOUT_MS = 6000; /** * Open Food Facts jako zdroj energetických hodnot. * * Používá `search.openfoodfacts.org`, ne hlavní `world.openfoodfacts.org` — * tamní `/cgi/search.pl` i `/api/v2/search` vrací kvůli zátěži 503, zatímco * samostatná vyhledávací služba odpovídá. Data jsou CC, klíč není potřeba. */ export class OpenFoodFactsProvider implements CalorieProvider { readonly name = 'openfoodfacts'; private readonly baseUrl = 'https://search.openfoodfacts.org/search'; async search(query: string, limit: number): Promise { // Poskytovatel vrací i produkty bez energetické hodnoty, tak si jich vyžádáme víc const url = `${this.baseUrl}?q=${encodeURIComponent(query)}&page_size=${limit * 4}`; try { const response = await fetch(url, { headers: { 'User-Agent': 'FoodTracer (osobni projekt)' }, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }); if (!response.ok) { console.warn(`Open Food Facts odpověděl ${response.status}`); return { unavailable: true }; } const body = await response.json() as { hits?: unknown[] }; return { unavailable: false, suggestions: this.toSuggestions(body.hits ?? [], limit) }; } catch (error) { console.warn('Open Food Facts není dostupný:', (error as Error).message); return { unavailable: true }; } } /** Vybere z odpovědi produkty s použitelnou energetickou hodnotou. */ private toSuggestions(hits: unknown[], limit: number): CalorieSuggestion[] { const suggestions: CalorieSuggestion[] = []; for (const hit of hits) { const product = hit as { product_name?: unknown, brands?: unknown, nutriments?: Record }; const kcal = product.nutriments?.['energy-kcal_100g']; const name = typeof product.product_name === 'string' ? product.product_name.trim() : ''; if (!name.length || typeof kcal !== 'number' || !Number.isFinite(kcal) || kcal <= 0) { continue; } suggestions.push({ name, caloriesPer100g: Math.round(kcal), origin: this.name, ...(Array.isArray(product.brands) && typeof product.brands[0] === 'string' ? { brand: product.brands[0] } : {}), }); if (suggestions.length >= limit) break; } return suggestions; } } /** Poskytovatel, který nic nenajde. Používá se, když je externí vyhledávání vypnuté. */ export class NoopProvider implements CalorieProvider { readonly name = 'none'; search(): Promise { return Promise.resolve({ unavailable: false, suggestions: [] }); } } /** * Vrátí poskytovatele dle konfigurace. * `CALORIE_PROVIDER=none` vypne externí dotazy úplně (offline provoz, testy). */ export function getCalorieProvider(): CalorieProvider { return process.env.CALORIE_PROVIDER?.toLowerCase() === 'none' ? new NoopProvider() : new OpenFoodFactsProvider(); }