commit ff281f51c34c8796138b455cb7daa10ce4afeb40 Author: Marcio Bevervanso Date: Tue Jun 30 18:13:19 2026 -0300 Initial commit: ML scraper service (Playwright + stealth + Redis cache) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2d7ec5c --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.env +node_modules/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e487e68 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,8 @@ +FROM mcr.microsoft.com/playwright:v1.45.0-jammy +WORKDIR /app +COPY package.json ./ +RUN npm install +RUN npx playwright install chromium --with-deps +COPY server.js ./ +EXPOSE 3000 +CMD ["node", "server.js"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8f9a5e0 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,41 @@ +version: "3.8" + +services: + ml-scraper: + build: . + restart: unless-stopped + depends_on: + - redis-ml + environment: + REDIS_URL: "redis://redis-ml:6379/0" + API_KEY: "${ML_API_KEY}" + CACHE_TTL_SECONDS: "7200" + PORT: "3000" + networks: + - internal + - coolify + labels: + - "traefik.enable=true" + - "traefik.http.routers.ml-scraper.rule=Host(`mlsearch.bizpilot.com.br`)" + - "traefik.http.routers.ml-scraper.entrypoints=websecure" + - "traefik.http.routers.ml-scraper.tls.certresolver=letsencrypt" + - "traefik.http.services.ml-scraper.loadbalancer.server.port=3000" + - "traefik.docker.network=coolify" + + redis-ml: + image: redis:7-alpine + restart: unless-stopped + volumes: + - redis_ml_data:/data + networks: + - internal + command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru + +volumes: + redis_ml_data: + +networks: + internal: + driver: bridge + coolify: + external: true diff --git a/package.json b/package.json new file mode 100644 index 0000000..6745ec7 --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "ml-scraper", + "version": "1.0.0", + "main": "server.js", + "dependencies": { + "express": "^4.18.2", + "ioredis": "^5.3.2", + "playwright-extra": "^0.0.7", + "puppeteer-extra-plugin-stealth": "^2.11.2", + "playwright": "^1.45.0" + } +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..fdce318 --- /dev/null +++ b/server.js @@ -0,0 +1,217 @@ +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(/