107 lines
4.3 KiB
TypeScript
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();
|
|
}
|