Files
nutriscan/lib/offSearch.ts
T
evandro bf688257ef refactor(api): extrair montagem e paginação da busca para lib/offSearch
GET /api/search passa de CC 41 para <= 10. Testes de caracterização
(sem rede: fetch e cache JSON temporário) cobrem validação, filtros,
ordenação, paginação, modo cache-only e erros.

Também: parameter property de NutriScanError virou campo explícito e
imports só de tipo usam import type, para o type stripping nativo do
Node; o script de teste não usa mais --experimental-transform-types
(removida no Node 26).
2026-10-10 04:14:48 -03:00

176 lines
5.5 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;
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" },
});
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;
}