#!/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 disc = x.discount ? x.discount.replace('.00', '') : ''; const isInstallment = /cuota/i.test(x.discountText || ''); const promo = { descuento: isInstallment ? (disc + ' cuotas sin interés') : (disc ? disc + '%' : ''), bancos: banks, detalle: isInstallment ? '' : (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 (SSR HTML parse — no browser needed) ───────────────────────────────── // DIA uses VTEX IO with server-side rendering. All promo data including legal // text is embedded in the page's __STATE__ JSON blob on first load. async function fetchDIA() { const r = await httpGet('https://diaonline.supermercadosdia.com.ar/medios-de-pago-y-promociones'); if (r.status !== 200) throw new Error(`DIA page status ${r.status}`); // Extract all inline