Files
nutriscan/lib/openFoodFactsApi.ts
T
Evandro Fonseca Junior f98c648311 feat(footer, header, landing): enhance UI components and improve navigation
- Updated Footer component with new links and dynamic year display.
- Refactored Header component to improve mobile navigation and theme toggling.
- Enhanced DonationSection and Features components with improved layout and animations.
- Updated HowItWorks and Impact sections for better user engagement and clarity.
- Improved product page layout and error handling in API interactions.
- General UI refinements across various components for a cohesive user experience.
2026-01-12 19:55:50 -03:00

256 lines
6.8 KiB
TypeScript

/**
* NutriScan API Wrapper v2.0
* Melhorias: Memoização de Scores, Rate Limiting Robusto e Tipagem Estrita.
*/
export interface Product {
code: string;
product_name: string;
brands?: string;
categories?: string;
ingredients_text?: string;
nutriments?: {
energy_100g?: number;
fat_100g?: number;
carbohydrates_100g?: number;
proteins_100g?: number;
sugars_100g?: number;
fiber_100g?: number;
sodium_100g?: number;
[key: string]: any; // Flexibilidade para outros nutrientes
};
nutrition_grades?: string;
nova_group?: number;
image_url?: string;
image_small_url?: string;
image_front_url?: string;
image_front_small_url?: string;
countries?: string;
countries_tags?: string[];
allergens_tags?: string[];
additives_tags?: string[];
}
export interface SearchResult {
products: Product[];
count: number;
page: number;
page_size: number;
skip: number;
}
// Configurações e Constantes de Engenharia
const CONFIG = {
BASE_URL: "", // API Routes locais
OFF_URL: "https://world.openfoodfacts.org",
USER_AGENT: "NutriScan/2.0 (Garuva-SC; https://nutriscan.com.br)",
RATE_LIMIT_MS: 200,
MAX_PAGE_SIZE: 1000,
};
/**
* Sistema de Tratamento de Erros por Classe
*/
export class NutriScanError extends Error {
constructor(public message: string, public status?: number, public code?: string) {
super(message);
this.name = "NutriScanError";
}
}
/**
* Requisição Unificada com AbortController e Timeout
*/
async function makeApiRequest<T>(endpoint: string, base: string = CONFIG.BASE_URL): Promise<T> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 8000); // 8s timeout
try {
const response = await fetch(`${base}${endpoint}`, {
headers: {
"Accept": "application/json",
"User-Agent": CONFIG.USER_AGENT,
},
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new NutriScanError(
errorData.error || `Erro de Sistema: ${response.status}`,
response.status
);
}
return await response.json();
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
throw new NutriScanError("Tempo de resposta excedido (Timeout)", 408);
}
throw error;
}
}
/**
* Busca por Código de Barras (EAN/UPC)
*/
export async function searchByBarcode(barcode: string): Promise<Product | null> {
const cleanBarcode = barcode.replace(/\D/g, "");
if (cleanBarcode.length < 8 || cleanBarcode.length > 13) {
console.warn(`[NutriScan] Código inválido rejeitado: ${cleanBarcode}`);
return null;
}
try {
return await makeApiRequest<Product>(`/api/products/${cleanBarcode}`);
} catch (error) {
console.error(`[API Error] Barcode ${cleanBarcode}:`, error);
return null;
}
}
/**
* Algoritmo de Busca Paginada com Controle de Fluxo
*/
export async function searchByName(
query: string,
country?: string,
page: number = 1,
pageSize: number = 20
): Promise<SearchResult> {
const params = new URLSearchParams({
q: query.trim(),
page: page.toString(),
page_size: pageSize.toString(),
});
if (country) params.append("country", country);
return makeApiRequest<SearchResult>(`/api/search?${params.toString()}`);
}
/**
* Otimização de Busca Completa (Pipeline de Dados)
*/
export async function fetchAllResults(
query: string,
country?: string,
maxPages: number = 5, // Reduzido para prevenir overhead
onProgress?: (current: number, total: number, count: number) => void
) {
const allProducts: Product[] = [];
let currentPage = 1;
let hasMore = true;
while (hasMore && currentPage <= maxPages) {
if (onProgress) onProgress(currentPage, maxPages, allProducts.length);
const result = await searchByName(query, country, currentPage, CONFIG.MAX_PAGE_SIZE);
if (result.products?.length > 0) {
allProducts.push(...result.products);
hasMore = result.products.length === CONFIG.MAX_PAGE_SIZE;
currentPage++;
// Rate Limiting Industrial
await new Promise(r => setTimeout(r, CONFIG.RATE_LIMIT_MS));
} else {
hasMore = false;
}
}
return {
products: allProducts,
totalCount: allProducts.length,
actualPages: currentPage - 1
};
}
/**
* Função de Ordenação de Alta Performance (Decorate-Sort-Undecorate)
*/
export function sortProducts(
products: Product[],
sortBy: string,
sortOrder: "asc" | "desc" = "asc"
): Product[] {
if (!products.length) return [];
// 1. Decorate: Pré-calcula scores caros uma única vez (O(n))
const mapped = products.map((p, index) => ({
index,
value: p,
score: calculateCompletenessScore(p),
nutritionValue: getGradeValue(p.nutrition_grades),
}));
// 2. Sort: Ordenação sobre valores em memória (O(n log n))
mapped.sort((a, b) => {
let diff = 0;
switch (sortBy) {
case "completeness":
case "relevance":
diff = b.score - a.score;
break;
case "nutrition_grade":
diff = a.nutritionValue - b.nutritionValue;
break;
case "energy":
diff = (a.value.nutriments?.energy_100g || 9999) - (b.value.nutriments?.energy_100g || 9999);
break;
case "name":
diff = (a.value.product_name || "").localeCompare(b.value.product_name || "");
break;
default:
diff = 0;
}
return sortOrder === "asc" ? diff : -diff;
});
// 3. Undecorate: Retorna ao formato original (O(n))
return mapped.map(item => item.value);
}
// Funções Auxiliares de Baixo Nível
function calculateCompletenessScore(p: Product): number {
let s = 0;
if (p.product_name) s += 10;
if (p.brands) s += 10;
if (p.image_url) s += 15;
if (p.nutriments?.energy_100g !== undefined) s += 20;
if (p.nutrition_grades) s += 15;
if (p.nova_group) s += 10;
if (p.ingredients_text) s += 10;
if (p.categories) s += 10;
return s;
}
function getGradeValue(g?: string): number {
const order: Record<string, number> = { a: 1, b: 2, c: 3, d: 4, e: 5 };
return order[g?.toLowerCase() || ""] || 6;
}
/**
* Utilitário de Formatação de Dados (Proteção contra Nulls)
*/
export function formatNutritionData(p: Product) {
const n = p.nutriments;
if (!n) return null;
const fmt = (v: any, d: number = 1) => {
const num = parseFloat(v);
return isNaN(num) ? "N/A" : num.toFixed(d);
};
return {
energy: n.energy_100g ? `${Math.round(n.energy_100g)} kcal` : "N/A",
fat: n.fat_100g ? `${fmt(n.fat_100g)}g` : "N/A",
carbs: n.carbohydrates_100g ? `${fmt(n.carbohydrates_100g)}g` : "N/A",
proteins: n.proteins_100g ? `${fmt(n.proteins_100g)}g` : "N/A",
sugars: n.sugars_100g ? `${fmt(n.sugars_100g)}g` : "N/A",
sodium: n.sodium_100g ? `${fmt(n.sodium_100g, 2)}g` : "N/A",
};
}