Files
nutriscan/lib/offSearch.ts
evandro 30d7ef701f fix: cache JSON concorrente, validação do sync e timeout nas chamadas ao Open Food Facts
- JsonCacheManager: a leitura inicial concorrente carregava o arquivo duas
  vezes e a segunda descartava as alterações da primeira; gravações
  simultâneas escreviam no mesmo arquivo. Agora a leitura é compartilhada
  e a gravação é enfileirada, via arquivo temporário + rename.
- POST /api/cache/sync: aceitava qualquer objeto e usava code como chave
  do cache (inclusive ausente ou não numérico); agora só grava produtos
  com code numérico e conta os demais em failed. JSON malformado responde
  400 em vez de 500.
- GET /api/products/[barcode]: resposta da API sem product virava 200
  com corpo vazio; agora responde 404.
- Chamadas ao Open Food Facts (busca e produto) passam a ter timeout de
  15 s e o User-Agent vem de um único lugar (lib/offSearch).
2026-10-10 04:18:59 -03:00

179 lines
5.7 KiB
TypeScript

/**
* 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<readonly [string, string]> = [
["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<string, string> = 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<void>;
}
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<Product[] | null> {
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<Product[]> {
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;
}