#!/usr/bin/env node /** * Fetches bank promotions for all supermarkets autonomously. * * Sources: * - Coto: ATG API /rest/model/atg/actors/cProfileActor/getPromociones * - Jumbo: VTEX Master Data JN/documents/bankDiscount?an=jumboargentina (filter websites=jumboargentina) * - Disco: Same Master Data, filter websites=discoargentina * - Carrefour: VTEX GraphQL persisted query GetPromotions * - Changomas: VTEX GraphQL persisted query GetPromos * - DIA: Chrome CDP relay (ws://127.0.0.1:18792/cdp) — requires relay active * * Output: data/discounts/bank-promos-latest.json * data/discounts/bank-promos-YYYY-MM-DDTHH-MM-SS.json * * Usage: node fetch_bank_promos.js */ 'use strict'; const https = require('https'); const fs = require('fs/promises'); const path = require('path'); const WORKSPACE = '/home/ubuntu/.openclaw/workspace'; // ── HTTP helper ─────────────────────────────────────────────────────────────── function httpGet(url) { return new Promise((resolve, reject) => { const u = new URL(url); const req = https.request({ hostname: u.hostname, path: u.pathname + u.search, headers: { 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/120.0 Safari/537.36', 'Accept': 'application/json, */*', }, }, res => { let data = ''; res.on('data', chunk => data += chunk); res.on('end', () => resolve({ status: res.statusCode, data })); }); req.on('error', reject); setTimeout(() => req.destroy(new Error('HTTP timeout')), 20000); req.end(); }); } // ── Day name utilities ──────────────────────────────────────────────────────── const DAY_NAMES = ['Todos los días','Lunes','Martes','Miércoles','Jueves','Viernes','Sábado','Domingo']; const DAY_KEYS = ['', 'monday','tuesday','wednesday','thursday','friday','saturday','sunday']; // day number (1-7) → canonical name (1=Lunes...7=Domingo) function dayNumToName(n) { return DAY_NAMES[n] || String(n); } // {monday:true,...} → array of canonical day names function dayBoolsToNames(fields) { const days = []; if (fields.monday === 'true' || fields.monday === true) days.push('Lunes'); if (fields.tuesday === 'true' || fields.tuesday === true) days.push('Martes'); if (fields.wednesday === 'true' || fields.wednesday === true) days.push('Miércoles'); if (fields.thursday === 'true' || fields.thursday === true) days.push('Jueves'); if (fields.friday === 'true' || fields.friday === true) days.push('Viernes'); if (fields.saturday === 'true' || fields.saturday === true) days.push('Sábado'); if (fields.sunday === 'true' || fields.sunday === true) days.push('Domingo'); return days.length ? days : ['Todos los días']; } // Build byDay index exploded per individual day: { 'Jueves': [promo,...], ... } // Each promo appears under every day it's valid on. function addToByDay(byDay, days, promo) { const allDays = ['Lunes','Martes','Miércoles','Jueves','Viernes','Sábado','Domingo']; const targets = (days.length === 7 || (days.length === 1 && days[0] === 'Todos los días')) ? allDays : days; targets.forEach(day => { if (!byDay[day]) byDay[day] = []; // avoid duplicates within the same day const key = promo.descuento + '|' + (promo.bancos || []).join(',') + '|' + (promo.titulo || promo.detalle || '').slice(0, 40); if (!byDay[day].some(p => (p.descuento+'|'+(p.bancos||[]).join(',')+'|'+(p.titulo||p.detalle||'').slice(0,40)) === key)) { byDay[day].push(promo); } }); } // Normalize Spanish day text → array of day names // "Jueves 5" → ['Jueves'], "De Lunes a Domingo" → ['Todos los días'] function normalizeDayText(text) { const t = (text || '').toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, ''); if (t.includes('lunes a domingo') || t.includes('todos los dias') || t.includes('todos los d')) return ['Todos los días']; const found = []; if (t.includes('lunes')) found.push('Lunes'); if (t.includes('martes')) found.push('Martes'); if (t.includes('miercoles')) found.push('Miércoles'); if (t.includes('jueves')) found.push('Jueves'); if (t.includes('viernes')) found.push('Viernes'); if (t.includes('sabado')) found.push('Sábado'); if (t.includes('domingo')) found.push('Domingo'); return found.length ? found : ['Todos los días']; } // ── COTO (ATG API) ──────────────────────────────────────────────────────────── async function fetchCoto() { const ATG_URL = 'https://www.cotodigital.com.ar/rest/model/atg/actors/cProfileActor/getPromociones?enviroment=ag'; const r = await httpGet(ATG_URL); if (r.status !== 200) throw new Error(`Coto ATG status ${r.status}`); const raw = JSON.parse(r.data); const result = raw?.result || raw; // Extract bank name from image filename: logo_macro_bma2.png → Macro function bankFromImage(imgPath) { if (!imgPath) return ''; const base = imgPath.replace(/^.*\//, '').replace(/\.[^.]+$/, '').toLowerCase(); const BANK_MAP = {macro:'Macro',bbva:'BBVA',santander:'Santander',galicia:'Galicia',nacion:'Banco Nación','banco-nacion':'Banco Nación',icbc:'ICBC',hsbc:'HSBC',patagonia:'Patagonia',brubank:'Brubank',naranja:'Naranja X','naranja-x':'Naranja X',modo:'MODO',visa:'Visa',mastercard:'Mastercard',cabal:'Cabal','banco-ciudad':'Banco Ciudad',ciudad:'Banco Ciudad',cencosud:'CencoPay',cencopay:'CencoPay',bna:'Banco Nación',bpba:'Banco Provincia',provincia:'Banco Provincia',comafi:'Banco Comafi',supervielle:'Supervielle',credicoop:'Credicoop',comadr:'Comadr'}; for (const [k,v] of Object.entries(BANK_MAP)) { if (base.includes(k)) return v; } return base.replace(/^logo_?/, '').replace(/_bma\d*$/, '').replace(/_/g,' '); } const byDay = {}; const seen = new Set(); // porDia groups by current day – use this as primary source (result.porDia || []).forEach(group => { const groupDays = normalizeDayText(group.dia); (group.arrayItem || []).forEach(item => { const itemDays = normalizeDayText(item.titDias || group.dia); const banco = bankFromImage(item.imagenPath); const promo = { descuento: item.titDescuento || '', bancos: banco ? [banco] : [], detalle: item.titDescuentoDet || '', legals: item.titFooter || '', vigencia: '', }; const dedupeKey = promo.descuento + '|' + banco; if (!seen.has(dedupeKey)) { seen.add(dedupeKey); addToByDay(byDay, itemDays, promo); } }); }); const total = Object.values(byDay).reduce((s, a) => s + a.length, 0); return { source: 'atg-api', totalPromos: total, byDay }; } // ── JUMBO / DISCO (VTEX Master Data) ───────────────────────────────────────── async function fetchCencosud() { const r = await httpGet('https://www.jumbo.com.ar/api/dataentities/JN/documents/bankDiscount?_fields=value,id&an=jumboargentina&_size=200'); if (r.status !== 200) throw new Error(`Cencosud MD status ${r.status}`); const outer = JSON.parse(r.data); const items = JSON.parse(outer.value); const nowUnix = Math.floor(Date.now() / 1000); const active = items.filter(x => parseInt(x.dateEnd || 0) > nowUnix); function buildStoreData(storeWebsite) { const storeItems = active.filter(x => { const sites = x.websites || []; return sites.includes(storeWebsite); }); const byDay = {}; storeItems.forEach(x => { const days = (x.days || []).map(d => dayNumToName(parseInt(d))); const banks = (x.banks || []).map(b => b.name).filter(Boolean); const promo = { descuento: x.discount ? x.discount.replace('.00', '') + '%' : '', bancos: banks, detalle: x.discountText || '', legals: x.legals || '', vigencia: x.dateEnd ? new Date(parseInt(x.dateEnd) * 1000).toISOString().slice(0, 10) : '', info: (x.info || '').slice(0, 300), }; addToByDay(byDay, days, promo); }); const totalPromos = Object.values(byDay).reduce((s, a) => s + a.length, 0); return { source: 'vtex-masterdata', totalPromos, byDay }; } return { jumbo: buildStoreData('jumboargentina'), disco: buildStoreData('discoargentina'), }; } // ── VTEX GraphQL persisted query helper ─────────────────────────────────────── function buildVtexGraphqlUrl(host, operationName, hash, sender, account) { const now = new Date().toISOString().replace(/\.\d+Z$/, ''); const innerVars = { where: `active=true AND ((active_from < ${now}) AND (active_to > ${now}))`, account, }; const ext = { persistedQuery: { version: 1, sha256Hash: hash, sender, provider: 'vtex.store-graphql@2.x' }, variables: Buffer.from(JSON.stringify(innerVars)).toString('base64'), }; return `https://${host}/_v/public/graphql/v1?` + new URLSearchParams({ workspace: 'master', maxAge: 'short', appsEtag: 'remove', domain: 'store', locale: 'es-AR', operationName, variables: '{}', extensions: JSON.stringify(ext), }); } function parseVtexPromoDoc(doc) { const f = {}; (doc.fields || []).forEach(field => f[field.key] = field.value); return f; } function vtexDocsToByDay(docs) { const byDay = {}; docs.forEach(doc => { const f = parseVtexPromoDoc(doc); const days = dayBoolsToNames(f); const discount = f.discount_percentage && f.discount_percentage !== 'null' ? f.discount_percentage + '%' : (f.discounts_text_installments && f.discounts_text_installments !== 'null' ? f.discounts_text_installments : ''); const installments = f.discounts_amount_installments && f.discounts_amount_installments !== 'null' ? f.discounts_amount_installments : ''; const promo = { descuento: discount || installments || '?', bancos: [], // not directly available; use title validText: f.validText || '', detalle: f.sub_title || '', titulo: f.title || '', legals: f.legal || '', ecommerce: f.ecommerce === 'true', hyper: f.hyper === 'true', market: f.market === 'true', express: f.express === 'true', }; addToByDay(byDay, days, promo); }); return byDay; } // ── CARREFOUR ──────────────────────────────────────────────────────────────── async function fetchCarrefour() { const url = buildVtexGraphqlUrl( 'www.carrefour.com.ar', 'GetPromotions', 'cdedb2142b133164ce61b85e94287592451ebee4a2fbede815e09336d40d29ae', 'valtech.carrefourar-bank-promotions@0.x', 'carrefourar' ); const r = await httpGet(url); if (r.status !== 200) throw new Error(`Carrefour GraphQL status ${r.status}`); const d = JSON.parse(r.data); if (d.errors) { const firstErr = d.errors[0]?.message; if (!d.data?.documents) throw new Error(`Carrefour GraphQL error: ${firstErr}`); } const docs = d.data?.documents || []; const byDay = vtexDocsToByDay(docs); const total = Object.values(byDay).reduce((s, a) => s + a.length, 0); return { source: 'vtex-graphql', totalPromos: total, byDay }; } // ── CHANGOMAS ───────────────────────────────────────────────────────────────── async function fetchChangomas() { const url = buildVtexGraphqlUrl( 'www.masonline.com.ar', 'GetPromos', '1a071ebc5dc407a3f65e687b0f4c0a3b8d12a0c45d8d11370075c3b2a505251c', 'valtech.gdn-banks-promotions@0.x', 'masonlineprod' ); const r = await httpGet(url); if (r.status !== 200) throw new Error(`Changomas GraphQL status ${r.status}`); const d = JSON.parse(r.data); if (d.errors && !d.data?.documents) throw new Error(`Changomas GraphQL error: ${d.errors[0]?.message}`); const docs = d.data?.documents || []; const byDay = vtexDocsToByDay(docs); const total = Object.values(byDay).reduce((s, a) => s + a.length, 0); return { source: 'vtex-graphql', totalPromos: total, byDay }; } // ── DIA (Chrome CDP relay) ──────────────────────────────────────────────────── function fetchDIA() { const CDP_WS = 'ws://127.0.0.1:18792/cdp'; const TOKEN = '0537d84a67f2e43b525964bb43d93f6dfae1ec1b50946455'; const DIA_URL = 'https://diaonline.supermercadosdia.com.ar/medios-de-pago-y-promociones'; let WS; try { WS = require('/usr/lib/node_modules/openclaw/node_modules/ws').WebSocket || require('/usr/lib/node_modules/openclaw/node_modules/ws'); } catch (e) { return Promise.reject(new Error('ws module not found: ' + e.message)); } return new Promise((resolve, reject) => { const ws = new WS(CDP_WS, { headers: { 'x-openclaw-relay-token': TOKEN } }); let _id = 1, sessionId = null, started = false, finished = false; const cbs = new Map(); const rawPromos = []; const globalTimeout = setTimeout(() => finish(), 150000); function finish() { if (finished) return; finished = true; clearTimeout(globalTimeout); try { ws.close(); } catch (_) {} resolve(buildResult()); } function buildResult() { const byDay = {}; rawPromos.forEach(p => { // Infer days from legal text (uppercase) then vigencia const days = normalizeDayText(p.legal + ' ' + p.vigencia); const canal = (p.canal || '').toLowerCase(); const promo = { descuento: p.descuento, bancos: p.imgAlt ? [p.imgAlt] : [], detalle: p.detalle, vigencia: p.vigencia, legals: p.legal || '', online: canal.includes('online'), tienda: canal.includes('tienda'), }; addToByDay(byDay, days, promo); }); const totalPromos = Object.values(byDay).reduce((s, a) => s + a.length, 0); return { source: 'chrome-cdp-dom', totalPromos, byDay }; } function send(method, params, cb) { const id = _id++; const m = { id, method, params: params || {} }; if (sessionId) m.sessionId = sessionId; if (cb) cbs.set(id, cb); ws.send(JSON.stringify(m)); } function evalS(expr, cb) { send('Runtime.evaluate', { expression: expr, returnByValue: true }, r => cb(r && r.result && r.result.value)); } function evalJ(expr, cb) { const wrapped = '(function(){try{var __r=(' + expr + ');return JSON.stringify(__r);}catch(e){return JSON.stringify({__err:e.message});}})()'; evalS(wrapped, v => { if (!v) { cb(null); return; } try { const p = JSON.parse(v); if (p && p.__err) { cb(null); } else cb(p); } catch (_) { cb(null); } }); } ws.on('error', e => { if (!finished) reject(new Error('CDP relay: ' + e.message)); }); ws.on('close', () => { if (!finished) finish(); }); ws.on('open', () => { send('Target.getTargets', {}, res => { const t = (res.targetInfos || []).filter(t => t.type === 'page')[0]; if (!t) { reject(new Error('No page target in CDP relay')); return; } send('Target.attachToTarget', { targetId: t.targetId, flatten: true }, r2 => { sessionId = r2.sessionId; send('Page.enable'); send('Page.navigate', { url: DIA_URL }); }); }); }); ws.on('message', raw => { const msg = JSON.parse(raw); if (msg.id && cbs.has(msg.id)) { const cb = cbs.get(msg.id); cbs.delete(msg.id); cb(msg.result || {}); return; } if (!msg.method) return; if (msg.method === 'Page.frameNavigated') { const url = msg.params && msg.params.frame && msg.params.frame.url; if (url && url.includes('medios-de-pago') && !started) { started = true; setTimeout(extractBase, 10000); } } }); function extractBase() { const expr = [ '(function(){', ' var items=document.querySelectorAll(\'[class*="list-by-days__item"]\');', ' var out=[];', ' [].forEach.call(items,function(item){', ' var flags=[].map.call(item.querySelectorAll(\'[class*="flag"]\'),function(el){return el.textContent.trim();});', ' var first=item.querySelector(\'[class*="first-text"]\');', ' var second=item.querySelector(\'[class*="second-text"]\');', ' var third=item.querySelector(\'[class*="third-text"]\');', ' var img=item.querySelector("img");', ' out.push({', ' canal:flags.join("|"),', ' descuento:first?first.textContent.trim():"",', ' detalle:second?second.textContent.trim():"",', ' vigencia:third?third.textContent.trim():"",', ' imgAlt:img?img.alt:""', ' });', ' });', ' return {count:items.length,items:out};', '})()' ].join('\n'); evalJ(expr, data => { if (!data || !data.items || !data.items.length) { finish(); return; } data.items.forEach((p, i) => rawPromos.push(Object.assign({ legal: '', idx: i }, p))); clickNext(0); }); } function clickNext(idx) { if (idx >= rawPromos.length) { finish(); return; } evalS('(function(){var c=document.querySelector(\'[aria-label="Close"],[aria-label="Cerrar"],[class*="modal__close"],[class*="closeButton"]\');if(c)c.click();document.dispatchEvent(new KeyboardEvent("keydown",{key:"Escape",bubbles:true}));})()', () => { setTimeout(() => { evalS('(function(){var btns=document.querySelectorAll(\'[class*="bank-modal__button"]\');if(!btns[' + idx + '])return "missing";btns[' + idx + '].scrollIntoView({block:"center",behavior:"instant"});btns[' + idx + '].click();return "ok";})()', res => { if (res === 'missing') { clickNext(idx + 1); return; } setTimeout(() => { const legalExpr = [ '(function(){', ' var sel=["[class*=\'bank-modal__text\']","[class*=\'vtex-modal__modal\'] p","[role=\'dialog\'] p"];', ' for(var i=0;i20)return t;}}', ' var all=document.querySelectorAll("*");', ' for(var j=0;j50&&t2.length<5000&&!/cookie|chat|bot|menu/i.test(t2))return t2.slice(0,800);}}', ' return "";', '})()' ].join('\n'); evalS(legalExpr, legal => { rawPromos[idx].legal = legal || ''; clickNext(idx + 1); }); }, 2000); }); }, 400); }); } }); } // ── MAIN ────────────────────────────────────────────────────────────────────── async function main() { const results = {}; const errors = {}; // Coto process.stdout.write('[coto] fetching ATG promos... '); try { results.coto = await fetchCoto(); console.log(`${results.coto.totalPromos} promos, días: [${Object.keys(results.coto.byDay).join(', ')}]`); } catch (e) { console.log(`FAILED: ${e.message}`); errors.coto = e.message; results.coto = { source: 'atg-api', error: e.message, byDay: {}, totalPromos: 0 }; } // Jumbo + Disco (shared fetch) process.stdout.write('[jumbo+disco] fetching VTEX Master Data... '); try { const cencosud = await fetchCencosud(); results.jumbo = cencosud.jumbo; results.disco = cencosud.disco; console.log(`jumbo: ${results.jumbo.totalPromos} promos, disco: ${results.disco.totalPromos} promos`); } catch (e) { console.log(`FAILED: ${e.message}`); results.jumbo = { source: 'vtex-masterdata', error: e.message, byDay: {}, totalPromos: 0 }; results.disco = { source: 'vtex-masterdata', error: e.message, byDay: {}, totalPromos: 0 }; } // Carrefour process.stdout.write('[carrefour] fetching GraphQL promos... '); try { results.carrefour = await fetchCarrefour(); console.log(`${results.carrefour.totalPromos} promos, días: [${Object.keys(results.carrefour.byDay).join(', ')}]`); } catch (e) { console.log(`FAILED: ${e.message}`); results.carrefour = { source: 'vtex-graphql', error: e.message, byDay: {}, totalPromos: 0 }; } // Changomas process.stdout.write('[changomas] fetching GraphQL promos... '); try { results.changomas = await fetchChangomas(); console.log(`${results.changomas.totalPromos} promos, días: [${Object.keys(results.changomas.byDay).join(', ')}]`); } catch (e) { console.log(`FAILED: ${e.message}`); results.changomas = { source: 'vtex-graphql', error: e.message, byDay: {}, totalPromos: 0 }; } // DIA (Chrome CDP relay — requires relay active) process.stdout.write('[dia] fetching via CDP relay... '); try { results.dia = await fetchDIA(); console.log(`${results.dia.totalPromos} promos, días: [${Object.keys(results.dia.byDay).join(', ')}]`); } catch (e) { console.log(`FAILED: ${e.message}`); results.dia = { source: 'chrome-cdp-dom', error: e.message, byDay: {}, totalPromos: 0 }; } // Save const out = { generatedAt: new Date().toISOString(), source: 'autonomous-api', stores: results, }; const outDir = path.join(WORKSPACE, 'data', 'discounts'); await fs.mkdir(outDir, { recursive: true }); const latestPath = path.join(outDir, 'bank-promos-latest.json'); await fs.writeFile(latestPath, JSON.stringify(out, null, 2)); const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); await fs.writeFile(path.join(outDir, `bank-promos-${ts}.json`), JSON.stringify(out, null, 2)); // Also write JS file for HTML page const reportDir = path.join(WORKSPACE, 'super-ranking-report'); const jsPath = path.join(reportDir, 'bank-promos-data.js'); await fs.writeFile(jsPath, `// Auto-generated by fetch_bank_promos.js — do not edit\nwindow.BANK_PROMOS_REPORT = ${JSON.stringify(out, null, 2)};\n`); console.log(`Saved: ${latestPath}`); console.log(`Saved: ${jsPath}`); // Print summary console.log('\n=== SUMMARY ==='); for (const [store, data] of Object.entries(results)) { if (data.error) { console.log(`${store}: ERROR - ${data.error}`); continue; } if (data.note) { console.log(`${store}: ${data.note}`); continue; } const dayList = Object.entries(data.byDay).map(([d,ps]) => `${d}(${ps.length})`).join(', '); console.log(`${store}: ${data.totalPromos} promos | ${dayList}`); } return out; } main().catch(e => { console.error(e); process.exit(1); });