Files
nutriscan/lib/jsonCacheManager.ts
T
evandro 3d59903114 perf: remover polling de storage-stats e trabalho repetido na busca do cache
- useStorageMonitor: o efeito com setInterval de 10 s chamava
  /api/storage-stats em toda aba aberta só para escrever no console
  (nenhum estado é atualizado); o servidor faz fs.stat e conta as
  chaves do cache a cada chamada. A busca inicial de estatísticas
  continua.
- JsonCacheManager.searchProducts: convertToAPIProduct rodava duas vezes
  por produto (filtro e map) e a relevância era recalculada, com
  toLowerCase, a cada comparação do sort; agora é uma vez por produto.
2026-10-10 04:16:34 -03:00

460 lines
12 KiB
TypeScript

/**
* Gerenciador de Cache de Produtos baseado em JSON
* Gerencia armazenamento, busca e merge inteligente de dados no servidor
*/
import { promises as fs } from "fs";
import fsSync from "fs";
import path from "path";
import type { Product } from "./openFoodFactsApi";
import type { CachedProduct, StorageStats } from "./types";
interface JsonCacheData {
products: { [key: string]: CachedProduct };
lastUpdated: number;
version: string;
}
export class JsonCacheManager {
private cacheFilePath: string;
private cacheData: JsonCacheData | null = null;
private static instance: JsonCacheManager;
constructor() {
// Definir caminho do arquivo de cache JSON
this.cacheFilePath = path.join(
process.cwd(),
"data",
"products-cache.json"
);
// Criar diretório se não existir
const dataDir = path.dirname(this.cacheFilePath);
try {
if (!fsSync.existsSync(dataDir)) {
fsSync.mkdirSync(dataDir, { recursive: true });
console.log(`[JSON Cache] Diretório criado: ${dataDir}`);
}
} catch (error) {
console.error("[JSON Cache] Erro ao criar diretório:", error);
}
}
/**
* Singleton pattern para garantir uma única instância
*/
static getInstance(): JsonCacheManager {
if (!JsonCacheManager.instance) {
JsonCacheManager.instance = new JsonCacheManager();
}
return JsonCacheManager.instance;
}
/**
* Carrega dados do cache do arquivo JSON
*/
private async loadCache(): Promise<void> {
if (this.cacheData) return;
try {
if (fsSync.existsSync(this.cacheFilePath)) {
const fileContent = await fs.readFile(this.cacheFilePath, "utf-8");
this.cacheData = JSON.parse(fileContent) as JsonCacheData;
console.log(
`[JSON Cache] Cache carregado: ${
Object.keys(this.cacheData.products).length
} produtos`
);
} else {
this.cacheData = {
products: {},
lastUpdated: 0,
version: "1.0.0",
};
console.log("[JSON Cache] Cache inicializado vazio");
}
} catch (error) {
console.error("[JSON Cache] Erro ao carregar cache:", error);
this.cacheData = {
products: {},
lastUpdated: 0,
version: "1.0.0",
};
}
}
/**
* Salva dados do cache no arquivo JSON
*/
private async saveCache(): Promise<void> {
if (!this.cacheData) return;
try {
await fs.writeFile(
this.cacheFilePath,
JSON.stringify(this.cacheData, null, 2),
"utf-8"
);
console.log(
`[JSON Cache] Cache salvo: ${
Object.keys(this.cacheData.products).length
} produtos`
);
} catch (error) {
console.error("[JSON Cache] Erro ao salvar cache:", error);
}
}
/**
* Converte Product para CachedProduct
*/
private convertToCachedProduct(product: Product): CachedProduct {
return {
...product,
_metadata: {
firstSeen: Date.now(),
lastUpdated: Date.now(),
updateCount: 1,
},
};
}
/**
* Converte CachedProduct para Product
*/
private convertToAPIProduct(cachedProduct: CachedProduct): Product {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { _metadata, ...product } = cachedProduct;
return product as Product;
}
/**
* Merge inteligente de dados de produto
*/
private mergeProductData(
existing: CachedProduct,
newData: Product
): CachedProduct {
return {
...existing,
...newData,
_metadata: {
...existing._metadata,
lastUpdated: Date.now(),
updateCount: existing._metadata.updateCount + 1,
},
};
}
/**
* Calcula score de relevância de um produto em relação à query
*/
private calculateRelevanceScore(
product: Product,
searchTerm: string
): number {
const productName = product.product_name?.toLowerCase() || "";
const lowerSearchTerm = searchTerm.toLowerCase();
// Nome exato = maior prioridade
if (productName === lowerSearchTerm) {
return 3;
}
// Nome começa com a query = alta prioridade
if (productName.startsWith(lowerSearchTerm)) {
return 2;
}
// Nome contém a query = média prioridade
if (productName.includes(lowerSearchTerm)) {
return 1;
}
// Correspondência em marca ou categoria = baixa prioridade
if (
product.brands?.toLowerCase().includes(lowerSearchTerm) ||
product.categories?.toLowerCase().includes(lowerSearchTerm)
) {
return 0.5;
}
return 0;
}
/**
* Busca produto por código de barras
*/
async getProductByCode(code: string): Promise<Product | null> {
try {
await this.loadCache();
if (!this.cacheData) return null;
const cachedProduct = this.cacheData.products[code];
if (cachedProduct) {
console.log(`[JSON Cache] Produto encontrado: ${code}`);
return this.convertToAPIProduct(cachedProduct);
}
console.log(`[JSON Cache] Produto não encontrado: ${code}`);
return null;
} catch (error) {
console.error("[JSON Cache] Erro ao buscar produto por código:", error);
return null;
}
}
/**
* Busca produtos por query (nome, marca, categoria)
*/
async searchProducts(
query: string,
country?: string,
limit: number = 50
): Promise<{
products: Product[];
totalCount: number;
fromCache: boolean;
fromAPI: boolean;
}> {
try {
await this.loadCache();
if (!this.cacheData) {
return {
products: [],
totalCount: 0,
fromCache: false,
fromAPI: false,
};
}
const searchTerm = query.toLowerCase();
console.log(
`[JSON Cache] Buscando: "${searchTerm}" (país: ${country || "todos"})`
);
// Filtrar produtos; a relevância é calculada uma vez por produto
const filteredProducts = Object.values(this.cacheData.products)
.filter((cachedProduct) => {
// Verificar se corresponde à query
const matchesQuery =
cachedProduct.product_name?.toLowerCase().includes(searchTerm) ||
cachedProduct.brands?.toLowerCase().includes(searchTerm) ||
cachedProduct.categories?.toLowerCase().includes(searchTerm);
// Verificar país se especificado
const matchesCountry =
!country ||
cachedProduct.countries_tags?.includes(country) ||
cachedProduct.countries?.toLowerCase().includes(country.toLowerCase());
return matchesQuery && matchesCountry;
})
.map((cachedProduct) => {
const product = this.convertToAPIProduct(cachedProduct);
return { product, score: this.calculateRelevanceScore(product, searchTerm) };
})
// Ordenar por relevância
.sort((a, b) => b.score - a.score)
.slice(0, limit)
.map(({ product }) => product);
console.log(
`[JSON Cache] Encontrados ${filteredProducts.length} produtos`
);
return {
products: filteredProducts,
totalCount: filteredProducts.length,
fromCache: true,
fromAPI: false,
};
} catch (error) {
console.error("[JSON Cache] Erro ao buscar produtos:", error);
return {
products: [],
totalCount: 0,
fromCache: false,
fromAPI: false,
};
}
}
/**
* Salva ou atualiza produto no cache
*/
async saveProduct(product: Product): Promise<boolean> {
try {
await this.loadCache();
if (!this.cacheData) {
this.cacheData = {
products: {},
lastUpdated: 0,
version: "1.0.0",
};
}
const code = product.code;
const existingProduct = this.cacheData.products[code];
if (existingProduct) {
this.cacheData.products[code] = this.mergeProductData(
existingProduct,
product
);
console.log(`[JSON Cache] Produto atualizado: ${code}`);
} else {
this.cacheData.products[code] = this.convertToCachedProduct(product);
console.log(`[JSON Cache] Produto adicionado: ${code}`);
}
this.cacheData.lastUpdated = Date.now();
await this.saveCache();
return true;
} catch (error) {
console.error("[JSON Cache] Erro ao salvar produto:", error);
return false;
}
}
/**
* Salva múltiplos produtos no cache
*/
async saveProducts(products: Product[]): Promise<{
saved: number;
failed: number;
}> {
let saved = 0;
let failed = 0;
try {
await this.loadCache();
if (!this.cacheData) {
this.cacheData = {
products: {},
lastUpdated: 0,
version: "1.0.0",
};
}
// Adiciona todos os produtos ao objeto em memória primeiro
for (const product of products) {
try {
const code = product.code;
const existingProduct = this.cacheData.products[code];
if (existingProduct) {
this.cacheData.products[code] = this.mergeProductData(
existingProduct,
product
);
console.log(`[JSON Cache] Produto atualizado na memória: ${code}`);
} else {
this.cacheData.products[code] = this.convertToCachedProduct(product);
console.log(`[JSON Cache] Produto adicionado na memória: ${code}`);
}
saved++;
} catch {
failed++;
}
}
// Salva no disco uma única vez após processar todo o lote
if (saved > 0) {
this.cacheData.lastUpdated = Date.now();
await this.saveCache();
console.log(`[JSON Cache] Lote salvo no disco! Total processado: ${saved} produtos.`);
}
} catch (error) {
console.error("[JSON Cache] Erro ao salvar lote de produtos:", error);
}
return { saved, failed };
}
/**
* Obtém estatísticas do cache
*/
async getStorageStats(): Promise<StorageStats> {
try {
await this.loadCache();
if (!this.cacheData) {
return {
totalProducts: 0,
storageSizeMB: 0,
lastUpdated: 0,
averageProductSize: 0,
};
}
const productCount = Object.keys(this.cacheData.products).length;
// Calcular tamanho do arquivo
let fileSize = 0;
try {
const stats = await fs.stat(this.cacheFilePath);
fileSize = stats.size;
} catch (error) {
console.error("[JSON Cache] Erro ao obter tamanho do arquivo:", error);
}
const sizeInMB = fileSize / (1024 * 1024);
const averageSize = productCount > 0 ? fileSize / productCount : 0;
return {
totalProducts: productCount,
storageSizeMB: parseFloat(sizeInMB.toFixed(2)),
lastUpdated: this.cacheData.lastUpdated,
averageProductSize: parseFloat(averageSize.toFixed(0)),
};
} catch (error) {
console.error("[JSON Cache] Erro ao obter estatísticas:", error);
return {
totalProducts: 0,
storageSizeMB: 0,
lastUpdated: 0,
averageProductSize: 0,
};
}
}
/**
* Limpa todo o cache
*/
async clearCache(): Promise<boolean> {
try {
this.cacheData = {
products: {},
lastUpdated: 0,
version: "1.0.0",
};
await this.saveCache();
console.log("[JSON Cache] Cache limpo");
return true;
} catch (error) {
console.error("[JSON Cache] Erro ao limpar cache:", error);
return false;
}
}
/**
* Verifica se o cache está inicializado
*/
async isInitialized(): Promise<boolean> {
try {
await this.loadCache();
return this.cacheData !== null;
} catch {
return false;
}
}
}
// Exportar instância singleton
export const jsonCacheManager = JsonCacheManager.getInstance();