Files
food-tracer/server/src/calorieProvider.ts
T

107 lines
4.3 KiB
TypeScript

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<ProviderResult>;
}
/** 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<ProviderResult> {
// 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<string, unknown> };
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<ProviderResult> {
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();
}