- 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
385 lines
11 KiB
TypeScript
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 }
|
|
);
|
|
}
|
|
}
|