const express = require('express'); const { chromium } = require('playwright-extra'); const stealth = require('puppeteer-extra-plugin-stealth'); const Redis = require('ioredis'); chromium.use(stealth()); const app = express(); app.use(express.json()); const redis = new Redis(process.env.REDIS_URL || 'redis://redis-ml:6379/0'); const API_KEY = process.env.API_KEY; const CACHE_TTL = parseInt(process.env.CACHE_TTL_SECONDS || '7200'); const COOKIE_TTL = 8 * 60 * 60; // 8 hours const COOKIE_KEY = 'ml:session:cookies'; let refreshing = false; // Auth app.use((req, res, next) => { if (req.path === '/health') return next(); if (API_KEY && req.headers['x-api-key'] !== API_KEY) { return res.status(401).json({ error: 'Unauthorized' }); } next(); }); app.get('/health', (req, res) => res.json({ status: 'ok' })); // Cookie refresh via Playwright (runs rarely — ~once per 6h) async function refreshCookies() { if (refreshing) return; refreshing = true; console.log('[cookies] starting refresh...'); let browser; try { browser = await chromium.launch({ headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'], }); const context = await browser.newContext({ userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36', viewport: { width: 1280, height: 800 }, locale: 'pt-BR', timezoneId: 'America/Sao_Paulo', }); const page = await context.newPage(); await page.goto('https://lista.mercadolivre.com.br/air-fryer', { waitUntil: 'domcontentloaded', timeout: 30000, }); await page.waitForTimeout(3000); const cookies = await context.cookies(); const cookieHeader = cookies.map(c => `${c.name}=${c.value}`).join('; '); await redis.setex(COOKIE_KEY, COOKIE_TTL, cookieHeader); console.log(`[cookies] refreshed — ${cookies.length} cookies saved`); return cookieHeader; } catch (e) { console.error('[cookies] refresh failed:', e.message); return null; } finally { if (browser) await browser.close().catch(() => {}); refreshing = false; } } async function getCookies() { const cached = await redis.get(COOKIE_KEY).catch(() => null); if (cached) return cached; return refreshCookies(); } // Parse products from ML's __PRELOADED_STATE__ JSON (more stable than CSS selectors) function parsePreloadedState(html) { const match = html.match(/]*>\s*window\.__PRELOADED_STATE__\s*=\s*(\{[\s\S]*?\});\s*<\/script>/); if (!match) return null; try { const state = JSON.parse(match[1]); const results = state?.initialState?.results || state?.results || state?.listingMain?.results || null; if (!Array.isArray(results)) return null; return results.map(r => ({ title: r.title || r.name || '', price: r.price ? `R$ ${String(r.price).replace('.', ',')}` : null, url: (r.permalink || r.url || '').split('?')[0], imageUrl: r.thumbnail || r.pictures?.[0]?.url || null, })).filter(p => p.title && p.url); } catch { return null; } } // Fallback: parse poly-cards from raw HTML (no browser, just regex/string) function parseHtmlFallback(html) { const products = []; // Extract JSON-LD product data if present const jsonldMatches = [...html.matchAll(/