#!/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: { 'Jueves': [promo,...], ... } function addToByDay(byDay, days, promo) { const key = days.length === 7 ? 'Todos los días' : days.join(', '); if (!byDay[key]) byDay[key] = []; byDay[key].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; const DAYS_MAP = { lunes:'Lunes',martes:'Martes','miércoles':'Miércoles',miercoles:'Miércoles',jueves:'Jueves',viernes:'Viernes','sábado':'Sábado',sabado:'Sábado',domingo:'Domingo','todos los días':'Todos los días','lunes a domingo':'Todos los días' }; const byDay = {}; let total = 0; function extractPromos(groups) { (groups || []).forEach(group => { (group.items || []).forEach(item => { const rawDay = (item.dias || group.dia || 'Todos los días').toLowerCase(); const canonDay = DAYS_MAP[rawDay] || item.dias || 'Todos los días'; const promo = { descuento: item.descuento || '', bancos: [item.banco || item.medio || group.banco || ''].filter(Boolean), tope: item.tope || '', detalle: item.detalle || item.descripcion || '', legals: item.legal || item.legales || '', vigencia: item.vigencia || item.hasta || '', }; if (!byDay[canonDay]) byDay[canonDay] = []; byDay[canonDay].push(promo); total++; }); }); } // porDia: array of { dia, items } extractPromos(result.porDia); // porBanco: array of { banco, items: [{descuento,dias,...}] } extractPromos(result.porBanco); // Deduplicate within each day for (const day of Object.keys(byDay)) { const seen = new Set(); byDay[day] = byDay[day].filter(p => { const k = p.descuento + '|' + p.bancos.join(','); if (seen.has(k)) return false; seen.add(k); return true; }); } 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, tope: '', 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), }; // Dedupe by discount+banks const key = promo.descuento + '|' + banks.sort().join(','); const dayKey = days.length === 6 || days.length === 7 ? 'Todos los días' : days.join(', '); if (!byDay[dayKey]) byDay[dayKey] = []; if (!byDay[dayKey].some(p => p.descuento + '|' + p.bancos.sort().join(',') === key)) { byDay[dayKey].push(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)); console.log(`\nSaved: ${latestPath}`); // 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); });