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).
This commit is contained in:
1 parent
fc7243e5f8
commit
bf688257ef
9 files changed
+752
-357
No files matched your search
@@ -6,8 +6,8 @@
|
||||
import { promises as fs } from "fs";
|
||||
import fsSync from "fs";
|
||||
import path from "path";
|
||||
import { Product } from "./openFoodFactsApi";
|
||||
import { CachedProduct, StorageStats } from "./types";
|
||||
import type { Product } from "./openFoodFactsApi";
|
||||
import type { CachedProduct, StorageStats } from "./types";
|
||||
|
||||
interface JsonCacheData {
|
||||
products: { [key: string]: CachedProduct };
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -56,9 +56,14 @@ const CONFIG = {
|
||||
* Sistema de Tratamento de Erros por Classe
|
||||
*/
|
||||
export class NutriScanError extends Error {
|
||||
constructor(public message: string, public status?: number, public code?: string) {
|
||||
status?: number;
|
||||
code?: string;
|
||||
|
||||
constructor(message: string, status?: number, code?: string) {
|
||||
super(message);
|
||||
this.name = "NutriScanError";
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { Product } from "./openFoodFactsApi";
|
||||
import type { Product } from "./openFoodFactsApi";
|
||||
|
||||
/**
|
||||
* Calcula score de relevância de um produto em relação à query
|
||||
|
||||
Reference in new issue
Block a user