#!/usr/bin/env node /** * Fetches bank promotions for all supermarkets autonomously (no browser needed). * * 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: No public API found; skipped (needs browser/CDP) * * 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); } }); } // ── 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; // Normalize day name: "Jueves 5" → "Jueves", "De Lunes a Domingo" → 7-day const DAY_WORDS = {jueves:'Jueves',lunes:'Lunes',martes:'Martes','miércoles':'Miércoles',miercoles:'Miércoles',viernes:'Viernes','sábado':'Sábado',sabado:'Sábado',domingo:'Domingo'}; function normalizeDiaText(text) { const t = (text || '').toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g,''); if (t.includes('lunes a domingo') || t.includes('todos')) 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']; } // 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 = normalizeDiaText(group.dia); (group.arrayItem || []).forEach(item => { const itemDays = normalizeDiaText(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 }; } // ── 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: no public API found results.dia = { source: 'none', note: 'DIA no expone API pública de promos bancarias. Datos disponibles solo via browser/CDP.', byDay: {}, totalPromos: 0, }; console.log('[dia] skipped (no public API)'); // 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); });