Files
Evandro Fonseca Junior bfb58ef16e feat(search): implement paginated API fetching for large page sizes
- Removed upper limit on page_size validation to support requests >100
- Added automatic pagination logic to fetch from Open Food Facts API in chunks of 1000
- Accumulates products across multiple API pages until desired count is reached
- Updated error messages and product data fields for consistency
2025-10-09 07:16:26 -03:00

385 lines
11 KiB
TypeScript

import { NextRequest, NextResponse } from "next/server";
import { jsonCacheManager } from "@/lib/jsonCacheManager";
import { Product } from "@/lib/openFoodFactsApi";
import { sortByRelevance } from "@/lib/searchUtils";
const USER_AGENT = "NutriScan/1.0 (https://nutriscan.com.br)";
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const query = searchParams.get("q");
const country = searchParams.get("country");
const page = searchParams.get("page") || "1";
const pageSize = searchParams.get("page_size") || "20";
const cacheOnly = searchParams.get("cache_only") === "true";
if (!query) {
return NextResponse.json(
{ error: "Query parameter is required" },
{ status: 400 }
);
}
// Validar parâmetros
const pageNum = parseInt(page);
const pageSizeNum = parseInt(pageSize);
if (isNaN(pageNum) || pageNum < 1) {
return NextResponse.json(
{ error: "Invalid page parameter" },
{ status: 400 }
);
}
if (isNaN(pageSizeNum) || pageSizeNum < 1) {
return NextResponse.json(
{ error: "Invalid page_size parameter" },
{ status: 400 }
);
}
// Usar o máximo permitido pelo Open Food Facts (1000) para consultas na API
const apiPageSize = Math.max(pageSizeNum, 1000);
const params = new URLSearchParams({
search_terms: query,
page_size: apiPageSize.toString(),
page: page,
json: "true",
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(","),
});
// Filtros básicos
if (country) {
params.append("countries_tags_en", country);
}
// Filtros de classificação nutricional
const nutritionGrades = searchParams.get("nutrition_grades");
if (nutritionGrades) {
params.append("nutrition_grades_tags", nutritionGrades);
}
const novaGroups = searchParams.get("nova_groups");
if (novaGroups) {
params.append("nova_groups", novaGroups);
}
// Filtros de categoria e marca
const categories = searchParams.get("categories");
if (categories) {
params.append("categories_tags", categories);
}
const brands = searchParams.get("brands");
if (brands) {
params.append("brands", brands);
}
// Filtros de alérgenos
const allergens = searchParams.get("allergens");
if (allergens) {
params.append("allergens_tags", allergens);
}
const excludeAllergens = searchParams.get("exclude_allergens");
if (excludeAllergens) {
params.append("exclude_allergens_tags", excludeAllergens);
}
// Filtros de aditivos
const additives = searchParams.get("additives");
if (additives) {
params.append("additives_tags", additives);
}
const excludeAdditives = searchParams.get("exclude_additives");
if (excludeAdditives) {
params.append("exclude_additives_tags", excludeAdditives);
}
const noAdditives = searchParams.get("no_additives");
if (noAdditives === "true") {
params.append("additives_tags", "without-additives");
}
// Filtros de países
const countries = searchParams.get("countries");
if (countries) {
params.append("countries_tags", countries);
}
// Filtros nutricionais (valores por 100g)
const nutritionFilters = [
"energy_min",
"energy_max",
"fat_min",
"fat_max",
"carbohydrates_min",
"carbohydrates_max",
"proteins_min",
"proteins_max",
"sugars_min",
"sugars_max",
"fiber_min",
"fiber_max",
"sodium_min",
"sodium_max",
];
nutritionFilters.forEach((filter) => {
const value = searchParams.get(filter);
if (value) {
params.append(filter, value);
}
});
// Ordenação
const sortBy = searchParams.get("sort_by");
const sortOrder = searchParams.get("sort_order") || "asc";
if (sortBy) {
let sortField = "";
switch (sortBy) {
case "nutrition_grade":
sortField = "nutrition_grades";
break;
case "name":
sortField = "product_name";
break;
case "brand":
sortField = "brands";
break;
case "energy":
sortField = "nutriments.energy_100g";
break;
case "fat":
sortField = "nutriments.fat_100g";
break;
case "sugars":
sortField = "nutriments.sugars_100g";
break;
default:
sortField = "popularity";
}
if (sortField) {
params.append("sort_by", sortField);
params.append("sort_order", sortOrder);
}
}
try {
console.log(
`[API] Iniciando busca: "${query}" (país: ${
country || "todos"
}, page_size: ${pageSizeNum})`
);
// Primeiro, buscar no cache JSON
console.log(`[API] Buscando "${query}" no cache JSON...`);
const localResults = await jsonCacheManager.searchProducts(
query,
country || undefined,
pageSizeNum
);
// Se modo cache-only estiver ativo, retornar apenas resultados locais
if (cacheOnly || process.env.NEXT_PUBLIC_CACHE_ONLY_MODE === "true") {
console.log(
`[Cache-Only Mode] Retornando apenas ${localResults.products.length} produtos do cache`
);
return NextResponse.json({
products: localResults.products,
count: localResults.products.length,
page: pageNum,
page_size: pageSizeNum,
fromCache: true,
fromAPI: false,
cacheOnly: true,
});
}
let fromAPI = false;
let apiProducts: Product[] = [];
// Se não encontrou resultados suficientes no cache JSON, buscar na API externa
if (localResults.products.length < pageSizeNum) {
console.log(
`Cache JSON retornou ${localResults.products.length} produtos, buscando na API externa...`
);
// Implementar busca paginada automática para page_size > 1000
const allApiProducts: Product[] = [];
const maxPerPage = 1000;
let currentApiPage = 1;
const totalNeeded = pageSizeNum - localResults.products.length;
while (allApiProducts.length < totalNeeded) {
// Atualizar parâmetros para a página atual
const currentParams = new URLSearchParams(params);
currentParams.set("page", currentApiPage.toString());
currentParams.set("page_size", maxPerPage.toString());
console.log(
`Buscando página ${currentApiPage} da API (${allApiProducts.length}/${totalNeeded} produtos obtidos)...`
);
const response = await fetch(
`https://world.openfoodfacts.org/cgi/search.pl?${currentParams.toString()}`,
{
headers: {
"User-Agent": USER_AGENT,
Accept: "application/json",
},
}
);
if (response.ok) {
const apiData = await response.json();
const pageProducts = apiData.products || [];
if (pageProducts.length === 0) {
console.log("Não há mais produtos na API");
break; // Não há mais produtos
}
allApiProducts.push(...pageProducts);
fromAPI = true;
console.log(
`Página ${currentApiPage}: ${pageProducts.length} produtos (Total: ${allApiProducts.length})`
);
// Se retornou menos que o máximo, acabaram os produtos
if (pageProducts.length < maxPerPage) {
console.log("Última página de produtos obtida");
break;
}
// Se já temos o suficiente, parar
if (allApiProducts.length >= totalNeeded) {
console.log(
`Quantidade suficiente obtida: ${allApiProducts.length}/${totalNeeded}`
);
break;
}
// Incrementar página para próxima iteração
currentApiPage++;
// Rate limiting: aguardar 200ms entre requisições
if (allApiProducts.length < totalNeeded) {
console.log("Aguardando 200ms antes da próxima requisição...");
await new Promise((resolve) => setTimeout(resolve, 200));
}
} else {
console.error(
`Erro na página ${currentApiPage}: ${response.status} ${response.statusText}`
);
break;
}
}
apiProducts = allApiProducts;
// Ordenar produtos da API por relevância
if (apiProducts.length > 0) {
console.log(
`Ordenando ${apiProducts.length} produtos da API por relevância...`
);
apiProducts = sortByRelevance(apiProducts, query);
console.log(
`Salvando ${apiProducts.length} produtos da API no cache JSON...`
);
const saveResult = await jsonCacheManager.saveProducts(apiProducts);
console.log(
`Cache JSON atualizado: ${saveResult.saved} salvos, ${saveResult.failed} falharam`
);
}
}
// Combinar resultados JSON e da API
const allProducts: Product[] = [...localResults.products];
const localProductCodes = new Set(localResults.products.map((p) => p.code));
// Adicionar apenas produtos da API que não estão no cache JSON
const newApiProducts = apiProducts.filter(
(p) => !localProductCodes.has(p.code)
);
allProducts.push(...newApiProducts);
// Reordenar a lista combinada por relevância
console.log(
`Reordenando ${allProducts.length} produtos combinados por relevância...`
);
const sortedProducts = sortByRelevance(allProducts, query);
// Limitar ao tamanho da página solicitada
const paginatedProducts = sortedProducts.slice(0, pageSizeNum);
const responseData = {
products: paginatedProducts,
count: allProducts.length,
page: pageNum,
page_size: pageSizeNum,
skip: (pageNum - 1) * pageSizeNum,
fromCache: localResults.fromCache,
fromAPI: fromAPI,
cacheStats: {
localProducts: localResults.products.length,
apiProducts: apiProducts.length,
newProducts: newApiProducts.length,
totalReturned: paginatedProducts.length,
},
};
console.log(
`Busca finalizada: ${paginatedProducts.length} produtos (${localResults.products.length} do cache JSON, ${newApiProducts.length} novos da API)`
);
return NextResponse.json(responseData);
} catch (error) {
console.error("[API] Erro ao buscar produtos:", error);
// Retornar resposta de erro estruturada em vez de quebrar
return NextResponse.json(
{
products: [],
count: 0,
page: pageNum,
page_size: pageSizeNum,
skip: (pageNum - 1) * pageSizeNum,
fromCache: false,
fromAPI: false,
error: "Erro interno do servidor",
cacheStats: {
localProducts: 0,
apiProducts: 0,
newProducts: 0,
totalReturned: 0,
},
},
{ status: 500 }
);
}
}