/** * Montagem das requisições de busca e paginação na API do Open Food Facts * (endpoint legado cgi/search.pl). Sem dependências do Next.js, para ser testável. */ import type { Product } from "./openFoodFactsApi.ts"; export const OFF_SEARCH_URL = "https://world.openfoodfacts.org/cgi/search.pl"; export const OFF_USER_AGENT = "NutriScan/1.0 (https://nutriscan.com.br)"; /** Máximo de produtos por página aceito pelo Open Food Facts. */ export const OFF_MAX_PAGE_SIZE = 1000; /** Pausa entre páginas consecutivas, para respeitar o limite de uso da API. */ export const OFF_RATE_LIMIT_MS = 200; /** Tempo máximo de espera por página da API. */ export const OFF_TIMEOUT_MS = 15000; const FIELDS = [ "code", "product_name", "brands", "categories", "ingredients_text", "nutriments", "nutrition_grades", "nova_group", "image_url", "image_small_url", "image_front_url", "image_front_small_url", "countries", "countries_tags", "allergens_tags", "additives_tags", ].join(","); /** Parâmetro desta API -> parâmetro do Open Food Facts, repassados quando presentes. */ const PASSTHROUGH_FILTERS: ReadonlyArray = [ ["nutrition_grades", "nutrition_grades_tags"], ["nova_groups", "nova_groups"], ["categories", "categories_tags"], ["brands", "brands"], ["allergens", "allergens_tags"], ["exclude_allergens", "exclude_allergens_tags"], ["additives", "additives_tags"], ["exclude_additives", "exclude_additives_tags"], ["countries", "countries_tags"], ]; /** Faixas nutricionais (por 100 g), repassadas com o mesmo nome. */ const NUTRITION_RANGE_FILTERS = ["energy", "fat", "carbohydrates", "proteins", "sugars", "fiber", "sodium"].flatMap( (nutrient) => [`${nutrient}_min`, `${nutrient}_max`] ); const SORT_FIELDS: ReadonlyMap = new Map([ ["nutrition_grade", "nutrition_grades"], ["name", "product_name"], ["brand", "brands"], ["energy", "nutriments.energy_100g"], ["fat", "nutriments.fat_100g"], ["sugars", "nutriments.sugars_100g"], ]); const DEFAULT_SORT_FIELD = "popularity"; export interface Pagination { page: number; pageSize: number; /** Valor textual de `page`, como recebido (padrão "1"). */ pageParam: string; } /** Lê e valida `page` e `page_size`. Devolve a mensagem de erro quando inválidos. */ export function parsePagination(searchParams: URLSearchParams): Pagination | { error: string } { const pageParam = searchParams.get("page") || "1"; const page = parseInt(pageParam); const pageSize = parseInt(searchParams.get("page_size") || "20"); if (isNaN(page) || page < 1) return { error: "Invalid page parameter" }; if (isNaN(pageSize) || pageSize < 1) return { error: "Invalid page_size parameter" }; return { page, pageSize, pageParam }; } function appendSort(params: URLSearchParams, searchParams: URLSearchParams): void { const sortBy = searchParams.get("sort_by"); if (!sortBy) return; params.append("sort_by", SORT_FIELDS.get(sortBy) ?? DEFAULT_SORT_FIELD); params.append("sort_order", searchParams.get("sort_order") || "asc"); } /** Constrói os parâmetros da busca no Open Food Facts a partir da query string recebida. */ export function buildOffSearchParams( searchParams: URLSearchParams, query: string, country: string | null, pageParam: string ): URLSearchParams { const params = new URLSearchParams({ search_terms: query, page_size: OFF_MAX_PAGE_SIZE.toString(), page: pageParam, json: "true", fields: FIELDS, }); if (country) params.append("countries_tags_en", country); for (const [from, to] of PASSTHROUGH_FILTERS) { const value = searchParams.get(from); if (value) params.append(to, value); } if (searchParams.get("no_additives") === "true") params.append("additives_tags", "without-additives"); for (const name of NUTRITION_RANGE_FILTERS) { const value = searchParams.get(name); if (value) params.append(name, value); } appendSort(params, searchParams); return params; } export interface OffFetchDeps { fetchFn: typeof fetch; sleep: (ms: number) => Promise; } const defaultDeps: OffFetchDeps = { fetchFn: (...args) => fetch(...args), sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), }; /** Busca uma página; devolve null quando a API responde com erro HTTP. */ async function fetchOffPage( params: URLSearchParams, apiPage: number, deps: OffFetchDeps ): Promise { const pageParams = new URLSearchParams(params); pageParams.set("page", apiPage.toString()); pageParams.set("page_size", OFF_MAX_PAGE_SIZE.toString()); const response = await deps.fetchFn(`${OFF_SEARCH_URL}?${pageParams.toString()}`, { headers: { "User-Agent": OFF_USER_AGENT, Accept: "application/json" }, signal: AbortSignal.timeout(OFF_TIMEOUT_MS), }); if (!response.ok) { console.error(`Erro na página ${apiPage}: ${response.status} ${response.statusText}`); return null; } const data = await response.json(); return data.products || []; } /** * Busca páginas de até 1000 produtos até reunir `totalNeeded`, esgotar os * resultados ou receber erro da API (nesse caso devolve o que já foi obtido). */ export async function fetchOffProducts( params: URLSearchParams, totalNeeded: number, deps: OffFetchDeps = defaultDeps ): Promise { const products: Product[] = []; let apiPage = 1; while (products.length < totalNeeded) { const pageProducts = await fetchOffPage(params, apiPage, deps); if (!pageProducts || pageProducts.length === 0) break; products.push(...pageProducts); if (pageProducts.length < OFF_MAX_PAGE_SIZE || products.length >= totalNeeded) break; apiPage++; await deps.sleep(OFF_RATE_LIMIT_MS); } return products; }