import axios from 'axios'; import redisClient from './redis-client.js'; // OFF API v3 (recommended) with fallback to v2 for search const OFF_API_BASE = 'https://world.openfoodfacts.org'; const CACHE_TTL = 3600; const MAX_RETRIES = 2; const RETRY_DELAY_MS = 1000; // User-Agent is REQUIRED per OFF docs: AppName/Version (ContactEmail) const OFF_USER_AGENT = process.env.OFF_USER_AGENT || 'FarmaFinder/1.0 (https://github.com/farmafinder)'; // Open Food Facts authentication (optional, for write operations) const OFF_USERNAME = process.env.OFF_USERNAME; const OFF_PASSWORD = process.env.OFF_PASSWORD; function getOffHeaders() { return { 'User-Agent': OFF_USER_AGENT, }; } function getOffAuth() { if (OFF_USERNAME && OFF_PASSWORD) { return { username: OFF_USERNAME, password: OFF_PASSWORD, }; } return null; } function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } export function transformOFFProduct(offProduct) { if (!offProduct || !offProduct._id || !offProduct.product_name) { return null; } const categoriesTags = offProduct.categories_tags || []; let category = 'baby_food'; for (const tag of categoriesTags) { const lower = tag.toLowerCase(); if (lower.includes('baby-milk') || lower.includes('infant-milk')) { category = 'baby_milk'; break; } if (lower.includes('baby-cereal') || lower.includes('infant-cereal')) { category = 'baby_cereal'; break; } } return { id: offProduct._id, source: 'openfoodfacts', name: offProduct.product_name, brand: offProduct.brands || '', category, image_url: offProduct.image_url || null, nutriscore: offProduct.nutriscore_grade || null, ingredients: offProduct.ingredients_text || null, nova_group: offProduct.nova_group || null, eco_score: offProduct.ecoscore_grade || null, }; } export async function searchBabyProducts(query) { if (!query || query.trim().length < 2) { return []; } const searchTerm = query.trim().toLowerCase(); const cacheKey = `off:baby:${searchTerm}`; try { const cachedData = await redisClient.get(cacheKey); if (cachedData) { console.log(`Cache hit for OFF search: ${searchTerm}`); return JSON.parse(cachedData); } console.log(`Fetching from OFF API: ${searchTerm}`); const headers = getOffHeaders(); console.log(`[OFF] User-Agent: ${headers['User-Agent']}`); console.log(`[OFF] URL: ${OFF_API_BASE}/api/v2/search?brands_tags=${searchTerm}`); let lastError; for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { try { // Use v2 structured search with brand filter (more reliable than /cgi/search.pl) // READ operations don't require auth per OFF docs, only User-Agent const response = await axios.get(`${OFF_API_BASE}/api/v2/search`, { params: { brands_tags: searchTerm, page_size: 20, }, headers, timeout: 10000, validateStatus: (status) => status === 200, }); // Check if response is actually JSON (OFF sometimes returns HTML on error) if (typeof response.data === 'string' || !response.data.products) { console.warn(`[OFF] Invalid response for "${searchTerm}" (attempt ${attempt + 1}): API may be down`); if (attempt < MAX_RETRIES) { await sleep(RETRY_DELAY_MS * (attempt + 1)); continue; } return []; } const products = response.data.products .map(transformOFFProduct) .filter(Boolean); // Only cache non-empty results to avoid caching API failures if (products.length > 0) { try { await redisClient.setEx(cacheKey, CACHE_TTL, JSON.stringify(products)); console.log(`Cached ${products.length} OFF products for: ${searchTerm}`); } catch (cacheErr) { // cache write failed, still return the data } } else { console.log(`[OFF] No products found for "${searchTerm}", not caching empty result`); } return products; } catch (err) { lastError = err; console.warn(`[OFF] Request failed for "${searchTerm}" (attempt ${attempt + 1}): ${err.message}`); if (attempt < MAX_RETRIES) { await sleep(RETRY_DELAY_MS * (attempt + 1)); } } } console.error(`[OFF] All retries failed for "${searchTerm}": ${lastError?.message}`); return []; } catch (error) { console.error('Error searching baby products from OFF:', error.message); return []; } } export async function getBabyProductDetails(barcode) { if (!barcode) { return null; } const cacheKey = `off:product:${barcode}`; try { const cachedData = await redisClient.get(cacheKey); if (cachedData) { console.log(`Cache hit for OFF product: ${barcode}`); return JSON.parse(cachedData); } console.log(`Fetching OFF product details: ${barcode}`); const headers = getOffHeaders(); // Use v3 API (recommended) for product details // READ operations don't require auth per OFF docs, only User-Agent const response = await axios.get(`${OFF_API_BASE}/api/v3/product/${barcode}.json`, { headers, timeout: 10000, validateStatus: (status) => status === 200, }); if (response.data && response.data.product) { const product = transformOFFProduct(response.data.product); if (product) { try { await redisClient.setEx(cacheKey, CACHE_TTL, JSON.stringify(product)); console.log(`Cached OFF product: ${barcode}`); } catch (cacheErr) { // cache write failed, still return the data } return product; } } return null; } catch (error) { console.error(`Error fetching OFF product ${barcode}:`, error.message); return null; } } export async function clearCache(pattern = 'off:*') { try { const keys = await redisClient.keys(pattern); if (keys.length > 0) { await redisClient.del(keys); console.log(`Cleared ${keys.length} OFF cache entries`); return keys.length; } return 0; } catch (error) { console.error('Error clearing OFF cache:', error); return 0; } }