452a835b64
- Add retry logic with exponential backoff (2 retries) - Remove custom User-Agent that was being blocked by OFF - Fix syntax error (missing catch block) - OFF API blocks anonymous users during high demand
194 lines
5.6 KiB
JavaScript
194 lines
5.6 KiB
JavaScript
import axios from 'axios';
|
|
import redisClient from './redis-client.js';
|
|
|
|
const OFF_API_BASE = 'https://world.openfoodfacts.org';
|
|
const CACHE_TTL = 3600;
|
|
const MAX_RETRIES = 2;
|
|
const RETRY_DELAY_MS = 1000;
|
|
|
|
// Open Food Facts authentication (required for reliable access)
|
|
const OFF_USERNAME = process.env.OFF_USERNAME;
|
|
const OFF_PASSWORD = process.env.OFF_PASSWORD;
|
|
|
|
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 auth = getOffAuth();
|
|
|
|
let lastError;
|
|
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
try {
|
|
const response = await axios.get(`${OFF_API_BASE}/cgi/search.pl`, {
|
|
params: {
|
|
search_terms: searchTerm,
|
|
json: true,
|
|
fields: 'product_name,brands,image_url,nutriscore_grade,ingredients_text,nova_group,ecoscore_grade,categories_tags',
|
|
page_size: 20,
|
|
action: 'process',
|
|
},
|
|
timeout: 10000,
|
|
validateStatus: (status) => status === 200,
|
|
...(auth && { auth }),
|
|
});
|
|
|
|
// 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 auth = getOffAuth();
|
|
const response = await axios.get(`${OFF_API_BASE}/api/v2/product/${barcode}.json`, {
|
|
timeout: 10000,
|
|
validateStatus: (status) => status === 200,
|
|
...(auth && { auth }),
|
|
});
|
|
|
|
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;
|
|
}
|
|
}
|