#!/usr/bin/env node /** * Unified promotions fetcher for all supermarkets. * - Coto: ATG API (structured bank/day promos, no WAF) * - VTEX stores: product search with Teasers/PromotionTeasers + ListPrice comparison * * Usage: node fetch_all_promos.js * Output: super-ranking-report/discounts-data.js (+ data/discounts/all-promos-latest.json) */ 'use strict'; const https = require('https'); const fs = require('fs/promises'); const path = require('path'); const WORKSPACE = '/home/ubuntu/.openclaw/workspace'; const OUT_JS = path.join(WORKSPACE, 'super-ranking-report', 'discounts-data.js'); const OUT_JSON = path.join(WORKSPACE, 'data', 'discounts', 'all-promos-latest.json'); // ── HTTP helper ────────────────────────────────────────────────────────────── function httpGet(url, extraHeaders = {}) { return new Promise((resolve, reject) => { const req = https.request(url, { headers: { 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 'Accept': 'application/json, text/html, */*', ...extraHeaders, }, }, (res) => { const chunks = []; res.on('data', c => chunks.push(c)); res.on('end', () => resolve({ status: res.statusCode, body: Buffer.concat(chunks).toString('utf8') })); }); req.on('error', reject); req.setTimeout(15000, () => { req.destroy(new Error('timeout')); }); req.end(); }); } // ── Coto ATG promo fetch ───────────────────────────────────────────────────── async function fetchCotoPromos() { const ATG_URL = 'https://www.cotodigital.com.ar/rest/model/atg/actors/cProfileActor/getPromociones?enviroment=ag'; const { status, body } = await httpGet(ATG_URL); if (status !== 200) throw new Error(`Coto ATG HTTP ${status}`); const data = JSON.parse(body); const result = data.result || {}; function parseItems(arr) { return (arr || []) .filter(i => i && i.titDescuento) .map(i => ({ descuento: i.titDescuento, detalle: i.titDescuentoDet || '', dias: i.titDias || '', footer: i.titFooter || '', imagen: i.imagenPath || '', })); } function parseSection(arr) { return (arr || []) .map(g => ({ imagen: g.imageUrl || g.imagenPath || '', items: parseItems(g.arrayItem) })) .filter(g => g.items.length > 0); } const porBanco = parseSection(result.porBanco); const porDia = parseSection(result.porDia); const conComunidad = parseSection(result.conComunidad); // Build byDay index const DAYS = ['lunes', 'martes', 'mi\u00e9rcoles', 'miercoles', 'jueves', 'viernes', 's\u00e1bado', 'sabado', 'domingo']; const CANONICAL = { miercoles: 'Miércoles', sabado: 'Sábado' }; const byDay = {}; for (const group of [...porBanco, ...porDia, ...conComunidad]) { for (const item of group.items) { const diasLc = (item.dias || '').toLowerCase(); let dayKey; if (diasLc.includes('lunes a domingo') || diasLc.includes('todos')) { dayKey = 'Todos los días'; } else { for (const d of DAYS) { if (diasLc.includes(d)) { dayKey = CANONICAL[d] || (d.charAt(0).toUpperCase() + d.slice(1)); break; } } if (!dayKey) dayKey = item.dias || 'Otros'; } if (!byDay[dayKey]) byDay[dayKey] = []; byDay[dayKey].push({ ...item, imagen: group.imagen }); } } const allItems = [...porBanco, ...porDia, ...conComunidad].flatMap(g => g.items); return { source: 'coto-atg-api', totalPromos: allItems.length, byDay, porBanco, porDia, conComunidad, }; } // ── VTEX product promo scan ────────────────────────────────────────────────── const VTEX_STORES = { dia: { host: 'diaonline.supermercadosdia.com.ar' }, carrefour: { host: 'www.carrefour.com.ar' }, disco: { host: 'www.disco.com.ar' }, jumbo: { host: 'www.jumbo.com.ar' }, changomas: { host: 'www.masonline.com.ar' }, }; // Sample pages sequentially to avoid rate-limiting async function fetchVtexPage(host, from, to) { const url = `https://${host}/api/catalog_system/pub/products/search?_fields=productName,items&_from=${from}&_to=${to}&O=OrderByScoreDESC`; const { status, body } = await httpGet(url); if (status !== 200 && status !== 206) return []; try { return JSON.parse(body); } catch { return []; } } function extractTeaser(co) { const t = co.Teasers || []; const pt = co.PromotionTeasers || []; // VTEX serializes Name in two ways depending on API version const getName = x => x.Name || x['k__BackingField'] || ''; return [...t, ...pt].map(getName).filter(Boolean); } async function scanVtexStore(name, { host }) { const PAGES = 4; // 4 pages × 50 products = 200 products const PER_PAGE = 50; let allProducts = []; for (let page = 0; page < PAGES; page++) { const from = page * PER_PAGE; const to = from + PER_PAGE - 1; const products = await fetchVtexPage(host, from, to); allProducts = allProducts.concat(products); // small delay between pages await new Promise(r => setTimeout(r, 300)); } const stats = { sampled: allProducts.length, discounted: 0, teaserHits: 0, topTeasers: {}, topDiscounted: [], }; for (const p of allProducts) { for (const item of (p.items || []).slice(0, 1)) { for (const seller of (item.sellers || []).slice(0, 1)) { const co = seller.commertialOffer || {}; const price = co.Price; const listPrice = co.ListPrice; const teasers = extractTeaser(co); // Count teasers for (const t of teasers) { stats.topTeasers[t] = (stats.topTeasers[t] || 0) + 1; stats.teaserHits++; } // Discount via price comparison (only trust if listPrice is significantly higher) // Note: for Disco/Jumbo, ListPrice is often just the RRP — discount here is unreliable if (price && listPrice && listPrice > price * 1.05) { stats.discounted++; const pct = ((listPrice - price) / listPrice) * 100; stats.topDiscounted.push({ name: p.productName, price, listPrice, discPct: pct }); } } } } // Sort and slice stats.topDiscounted.sort((a, b) => b.discPct - a.discPct); stats.topDiscounted = stats.topDiscounted.slice(0, 10); const teaserList = Object.entries(stats.topTeasers) .sort((a, b) => b[1] - a[1]) .slice(0, 15) .map(([name, count]) => ({ name, count })); return { source: 'vtex-product-scan', sampledProducts: stats.sampled, discountedProducts: stats.discounted, discountedRatioPct: stats.sampled ? +((stats.discounted / stats.sampled) * 100).toFixed(1) : 0, teaserHits: stats.teaserHits, topTeasers: teaserList, topDiscounted: stats.topDiscounted, paymentPromoUrl: `https://${host}/medios-de-pago`, }; } // ── Main ───────────────────────────────────────────────────────────────────── async function main() { const result = { generatedAt: new Date().toISOString(), method: 'coto-atg-api + vtex-teasers-scan', stores: {}, }; // Coto process.stdout.write('Coto ATG... '); try { result.stores.coto = await fetchCotoPromos(); console.log(`OK (${result.stores.coto.totalPromos} promos, ${Object.keys(result.stores.coto.byDay).length} días)`); } catch (e) { console.log(`ERROR: ${e.message}`); result.stores.coto = { source: 'coto-atg-api', error: e.message }; } // VTEX stores for (const [name, cfg] of Object.entries(VTEX_STORES)) { process.stdout.write(`${name}... `); try { result.stores[name] = await scanVtexStore(name, cfg); const s = result.stores[name]; console.log(`OK (${s.sampledProducts} sampled, ${s.discountedProducts} discounted, ${s.teaserHits} teaser hits)`); } catch (e) { console.log(`ERROR: ${e.message}`); result.stores[name] = { source: 'vtex-product-scan', error: e.message }; } } // Write outputs await fs.mkdir(path.dirname(OUT_JSON), { recursive: true }); await fs.mkdir(path.dirname(OUT_JS), { recursive: true }); await fs.writeFile(OUT_JSON, JSON.stringify(result, null, 2), 'utf8'); const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); await fs.writeFile( path.join(path.dirname(OUT_JSON), `all-promos-${ts}.json`), JSON.stringify(result, null, 2), 'utf8' ); const jsContent = `// Auto-generated by fetch_all_promos.js — ${result.generatedAt}\nwindow.DISCOUNTS_REPORT = ${JSON.stringify(result, null, 2)};\n`; await fs.writeFile(OUT_JS, jsContent, 'utf8'); console.log(`\nSaved: ${OUT_JSON}`); console.log(`Saved: ${OUT_JS}`); } main().catch(e => { console.error(e); process.exit(1); });