- 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).
179 lines
5.7 KiB
TypeScript
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;
|
|
}
|