From e0f2252124c036037f2e1447c7113165c358946f Mon Sep 17 00:00:00 2001 From: Kavi Date: Tue, 2 Jun 2026 14:41:09 -0400 Subject: [PATCH] Add Brandfetch merchant logos to dashboard - web/logos.js: high-precision alias map (description regex -> brand domain) feeding the Brandfetch logo CDN; only attaches a logo when a merchant confidently maps to a known brand, else a coloured-initial fallback. - Logos rendered in: top-merchants list, category breakdown rows, ledger. - scripts/resolve-logos.mjs: offline resolver (alias + Brandfetch search) that measures coverage and writes merchant-logos.json. Current: 33 brands cover ~35% of expense txns (Uber, MercadoPago, Apple, Rappi, Santa Isabel, Copec, etc.); the long tail is local businesses with no brand -> fallback. - Client ID is public (CDN); no secret key in the repo. --- scripts/resolve-logos.mjs | 127 +++++++++++++++++++++++++++++ web/dashboard.css | 7 ++ web/dashboard.html | 1 + web/dashboard.js | 6 +- web/dashboard2.js | 2 +- web/logos.js | 91 +++++++++++++++++++++ web/merchant-logos.json | 167 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 398 insertions(+), 3 deletions(-) create mode 100644 scripts/resolve-logos.mjs create mode 100644 web/logos.js create mode 100644 web/merchant-logos.json diff --git a/scripts/resolve-logos.mjs b/scripts/resolve-logos.mjs new file mode 100644 index 0000000..c0dbe12 --- /dev/null +++ b/scripts/resolve-logos.mjs @@ -0,0 +1,127 @@ +import fs from 'node:fs'; +const CLIENT_ID='1idZRzFTMDBLO5z-dbd'; +const LEDGERS=['web/ledger.json','web/ledger-backfill.json']; + +// High-frequency / important brands: regex on the RAW upper description -> canonical domain. +// These are resolved directly (logo via Brandfetch CDN by domain) with high precision. +const ALIAS=[ + [/UBER\s*EATS|UBR.*EATS|PS UBER EATS/, 'Uber Eats','ubereats.com'], + [/\bUBER\b|\bUBR\b/, 'Uber','uber.com'], + [/RAPPI/, 'Rappi','rappi.com'], + [/MERCADO ?PAGO|MERPAGO|\bMP\b/, 'Mercado Pago','mercadopago.cl'], + [/APPLE/, 'Apple','apple.com'], + [/NETFLIX/, 'Netflix','netflix.com'], + [/SPOTIFY/, 'Spotify','spotify.com'], + [/\bTEMU\b/, 'Temu','temu.com'], + [/ALIEXPRESS/, 'AliExpress','aliexpress.com'], + [/\bAMAZON\b/, 'Amazon','amazon.com'], + [/\bSHEIN\b/, 'Shein','shein.com'], + [/CABIFY/, 'Cabify','cabify.com'], + [/\bDIDI\b/, 'DiDi','didiglobal.com'], + [/PAYPAL/, 'PayPal','paypal.com'], + [/PAYSEND/, 'Paysend','paysend.com'], + [/\bMC ?DONALD/, "McDonald's",'mcdonalds.com'], + [/\bKFC\b/, 'KFC','kfc.com'], + [/STA ISABEL|SANTA ISABEL/, 'Santa Isabel','santaisabel.cl'], + [/\bJUMBO\b/, 'Jumbo','jumbo.cl'], + [/HIP ?LIDER|\bLIDER\b/, 'Lider','lider.cl'], + [/TOTTUS/, 'Tottus','tottus.cl'], + [/UNIMARC/, 'Unimarc','unimarc.cl'], + [/\bCOPEC\b/, 'Copec','copec.cl'], + [/\bSHELL\b/, 'Shell','shell.com'], + [/FALABELLA/, 'Falabella','falabella.com'], + [/RIPLEY/, 'Ripley','ripley.cl'], + [/SODIMAC/, 'Sodimac','sodimac.cl'], + [/\bPARIS\b/, 'Paris','paris.cl'], + [/\bENTEL\b/, 'Entel','entel.cl'], + [/\bWOM\b/, 'WOM','wom.cl'], + [/MOVISTAR/, 'Movistar','movistar.cl'], + [/\bCLARO\b/, 'Claro','claro.cl'], + [/TURBUS|TUR BUS/, 'Turbus','turbus.cl'], + [/CRUZ VERDE/, 'Cruz Verde','cruzverde.cl'], + [/SALCOBRAND/, 'Salcobrand','salcobrand.cl'], + [/FARMACIA.*AHUMADA|AHUMADA/, 'Farmacias Ahumada','farmaciasahumada.cl'], + [/INTEGRAMEDICA/, 'IntegraMédica','integramedica.cl'], + [/\bGOOGLE\b/, 'Google','google.com'], + [/MICROSOFT/, 'Microsoft','microsoft.com'], + [/\bOPENAI\b/, 'OpenAI','openai.com'], + [/ANTHROPIC/, 'Anthropic','anthropic.com'], + [/HETZNER/, 'Hetzner','hetzner.com'], + [/SPOTMINDERS/, 'Spotminders','spotminders.com'], + [/ADIDAS/, 'adidas','adidas.com'], + [/NEW BALANCE/, 'New Balance','newbalance.com'], + [/ENEL/, 'Enel','enel.cl'], + [/SERVIPAG/, 'Servipag','servipag.com'], + [/SENCILLITO/, 'Sencillito','sencillito.com'], +]; + +function aliasOf(descRaw){ + const s=(descRaw||'').toUpperCase(); + for(const [re,name,domain] of ALIAS) if(re.test(s)) return {name,domain,via:'alias'}; + return null; +} +function brandTerm(descRaw){ + let s=(descRaw||'').toUpperCase(); + if(/INTERES|IMPUEST|COMISION|COM\.|MANTENCION|PASAJE QR|RENDIMIENTO|COBRANZA|\bMORA\b|DL ?3475|CARGO INTERES|GASTO DE|TRASPASO|\bABONO\b|\bGIRO\b|RETIRO|TEF |TRANSF|SEGURO|DIVISAS|PREPAGO EN CUOTAS|PAGO (AUTOMATICO|APP|TARJETA)|O\.GERENCIA/.test(s)) return null; + s=s.replace(/^(PS|PAGO|PAGO:|PREPAGO|COMPRA)\s+/i,''); + s=s.replace(/^(PAYU|MP|CV ?\d*|DL|PAYSCAN|MERPAGO|MERCADOPAGO|SUMUP|TUU|MACH|WEBPAY|ONECLICK|KUSHKI|SERVIPAG\.COM)\s*[\*—\-·:]*\s*/i,''); + s=s.replace(/\b(COMPRA POR INTERNET|COMPRA NACIONAL|COMPRA INTERNAC\w*|COMPRA( \d+)? CUOTAS( PRECIO CONTADO)?|PRECIO CONTADO|RECARGA ONLINE|WEB ?\d*|ONLINE)\b/gi,' '); + s=s.replace(/·.*/,' ').replace(/\.(COM|CO|CL)\b.*/i,' ').replace(/HELP\..*/i,' '); + s=s.replace(/\b(PENDING|PEND|SANTIAGO|CL|LAS CONDES|PROVIDENCIA|VITACURA|CURICO)\b/g,' '); + s=s.replace(/[\*—·|\/]/g,' ').replace(/\s{2,}/g,' ').trim(); + if(s.length<3) return null; + if(/^(COMPRA|PAGO|TARJETA|NACIONAL|INTERNET|VENTA|DIGITAL|VIRTUAL|PUN|WEB)$/.test(s)) return null; + return s.slice(0,40); +} + +const aliasHits=new Map(); const terms=new Map(); +let totalTx=0, aliasTx=0; +for(const lf of LEDGERS){ if(!fs.existsSync(lf))continue; + const d=JSON.parse(fs.readFileSync(lf)); + for(const s of d.statements) for(const t of s.transactions){ + if(!['expense','fee'].includes(t.flow_type))continue; + totalTx++; + const a=aliasOf(t.description); + if(a){ aliasHits.set(a.domain,a); aliasTx++; continue; } + const term=brandTerm(t.description); if(!term)continue; + terms.set(term,(terms.get(term)||0)+1); + } +} + +const sleep=ms=>new Promise(r=>setTimeout(r,ms)); +async function search(q){ try{ const r=await fetch('https://api.brandfetch.io/v2/search/'+encodeURIComponent(q)+'?c='+CLIENT_ID); if(!r.ok)return null; const j=await r.json(); return Array.isArray(j)?j:null;}catch{return null;} } +function norm(x){return (x||'').toUpperCase().replace(/[^A-Z0-9]/g,'');} +function pick(term,results){ + if(!results)return null; + const words=term.toUpperCase().split(/\s+/).filter(w=>w.length>2); + for(const b of results){ + if(!b.verified||!b.icon||b.qualityScore<0.6)continue; + const N=norm(b.name); + const firstWordMatch = words.length && N.includes(norm(words[0])) && norm(words[0]).length>2; + const whole = norm(term); + if(firstWordMatch || N.includes(whole) || whole.includes(N)) return {name:b.name,domain:b.domain,via:'search'}; + } + return null; +} + +const out={}; +for(const a of aliasHits.values()) out[a.name.toUpperCase()]={name:a.name,domain:a.domain,via:'alias'}; +let shit=0,smiss=0; const missed=[]; +for(const [term,cnt] of [...terms.entries()].sort((a,b)=>b[1]-a[1])){ + const m=pick(term, await search(term)); + if(m){ out[term]=m; shit++; } else { smiss++; if(missed.length<20) missed.push(term+' ('+cnt+')'); } + await sleep(110); +} +// build coverage: how many TRANSACTIONS get a logo +function logoForTx(desc){ + const a=aliasOf(desc); if(a&&out[a.name.toUpperCase()])return true; + const term=brandTerm(desc); if(term&&out[term])return true; return false; +} +let covered=0; +for(const lf of LEDGERS){ if(!fs.existsSync(lf))continue; const d=JSON.parse(fs.readFileSync(lf)); + for(const s of d.statements) for(const t of s.transactions){ if(!['expense','fee'].includes(t.flow_type))continue; if(logoForTx(t.description))covered++; } } +fs.writeFileSync('web/merchant-logos.json',JSON.stringify(out,null,1)); +console.log('alias brands:',aliasHits.size,'| search resolved:',shit,'| search missed:',smiss); +console.log('TOTAL logo entries:',Object.keys(out).length); +console.log('TXN COVERAGE: '+covered+' / '+totalTx+' expense+fee txns ('+Math.round(covered/totalTx*100)+'%)'); +console.log('\n--- still missed (top) ---'); missed.forEach(m=>console.log(' '+m)); diff --git a/web/dashboard.css b/web/dashboard.css index 61e1c66..9984ae5 100644 --- a/web/dashboard.css +++ b/web/dashboard.css @@ -213,3 +213,10 @@ .bk-row-pct { font-size: 11px; color: var(--ink-dim); font-family: var(--font-num); text-align: right; } .bk-row-amt { font-size: 12px; color: var(--ink); font-family: var(--font-num); text-align: right; } .bk-more { font-size: 11px; color: var(--ink-dim); padding: 7px 0 3px 19px; } + +/* ---- merchant logos (Brandfetch) ---- */ +.mlogo { border-radius: 6px; object-fit: contain; background: rgba(255,255,255,0.06); vertical-align: middle; flex: none; } +.mlogo-fb { display: inline-flex; align-items: center; justify-content: center; border-radius: 6px; font-size: 11px; font-weight: 700; color: #fff; font-family: var(--font-ui); } +.mrow .m-name { display: flex; align-items: center; gap: 9px; } +.bk-row-desc.with-logo { display: flex; align-items: center; gap: 8px; } +.lx-desc .lx-logo-wrap { display: inline-flex; align-items: center; gap: 8px; } diff --git a/web/dashboard.html b/web/dashboard.html index 8ae965e..b64e8d7 100644 --- a/web/dashboard.html +++ b/web/dashboard.html @@ -149,6 +149,7 @@ window.addEventListener('DOMContentLoaded', () => { }); }); + diff --git a/web/dashboard.js b/web/dashboard.js index 87dd78b..587442d 100644 --- a/web/dashboard.js +++ b/web/dashboard.js @@ -158,7 +158,8 @@ merch.forEach(([name, d], i) => { const cat = window.MT.spendCategory(d.txns[0].description); const row = document.createElement('div'); row.className = 'mrow'; - row.innerHTML = `${i + 1}${esc(name)}${esc(cat)}${d.txns.length}×${window.MT.CLPk(d.value)}`; + const logo = window.MTlogo ? window.MTlogo.html(name, 22) : ''; + row.innerHTML = `${i + 1}${logo}${esc(name)}${esc(cat)}${d.txns.length}×${window.MT.CLPk(d.value)}`; row.addEventListener('mousemove', e => window.MTtip.rows(e, name, col.spend(), d.txns)); row.addEventListener('mouseleave', () => window.MTtip.hide()); ml.appendChild(row); @@ -179,8 +180,9 @@ section.className = 'bk-section'; const rowsHtml = descs.slice(0, 30).map(([desc, d]) => { const cpct = Math.round(d.value / catData.value * 100); + const blogo = window.MTlogo ? window.MTlogo.html(desc, 18) : ""; return `
- ${esc(desc)} + ${d.txns.length}× ${cpct}% ${MT.CLPk(d.value)} diff --git a/web/dashboard2.js b/web/dashboard2.js index b945d48..ad44dae 100644 --- a/web/dashboard2.js +++ b/web/dashboard2.js @@ -285,7 +285,7 @@ const shown = rows.slice(0, st.limit); $('#lx-rows').innerHTML = shown.map(t => ` ${t.date} - ${esc(t.description || t.counterparty || '—')} + ${window.MTlogo ? window.MTlogo.html(t.description || t.counterparty || '', 18) : ''}${esc(t.description || t.counterparty || '—')} ${esc(t.bank)} ${MT.acctShort(t.doc_type)}${t.last4 ? ' ··' + t.last4 : ''} ${FLOW_LABEL[t.flow_type] || t.flow_type} ${t.direction === 'credit' ? '+' : '−'}${CLP(t.amount)}`).join(''); diff --git a/web/logos.js b/web/logos.js new file mode 100644 index 0000000..a38bda7 --- /dev/null +++ b/web/logos.js @@ -0,0 +1,91 @@ +/* money-trace · merchant logos via Brandfetch CDN. + High-precision alias map (description regex -> brand domain). We only show a + logo when a description confidently maps to a known brand; everything else + falls back to a coloured initial. No guessing of arbitrary websites. */ +(function(){ + 'use strict'; + const CLIENT_ID = '1idZRzFTMDBLO5z-dbd'; + // [regex on UPPERCASED description, brand name, domain] + const ALIAS = [ + [/UBER\s*EATS|UBR.*EATS|PS UBER EATS/, 'Uber Eats','ubereats.com'], + [/\bUBER\b|\bUBR\b/, 'Uber','uber.com'], + [/RAPPI/, 'Rappi','rappi.com'], + [/MERCADO ?PAGO|MERPAGO|\bMP\b/, 'Mercado Pago','mercadopago.cl'], + [/APPLE/, 'Apple','apple.com'], + [/NETFLIX/, 'Netflix','netflix.com'], + [/SPOTIFY/, 'Spotify','spotify.com'], + [/\bTEMU\b/, 'Temu','temu.com'], + [/ALIEXPRESS/, 'AliExpress','aliexpress.com'], + [/\bAMAZON\b/, 'Amazon','amazon.com'], + [/\bSHEIN\b/, 'Shein','shein.com'], + [/CABIFY/, 'Cabify','cabify.com'], + [/\bDIDI\b/, 'DiDi','didiglobal.com'], + [/PAYPAL/, 'PayPal','paypal.com'], + [/PAYSEND/, 'Paysend','paysend.com'], + [/\bMC ?DONALD/, 'McDonalds','mcdonalds.com'], + [/\bKFC\b/, 'KFC','kfc.com'], + [/STA ISABEL|SANTA ISABEL/, 'Santa Isabel','santaisabel.cl'], + [/\bJUMBO\b/, 'Jumbo','jumbo.cl'], + [/HIP ?LIDER|\bLIDER\b/, 'Lider','lider.cl'], + [/TOTTUS/, 'Tottus','tottus.cl'], + [/UNIMARC/, 'Unimarc','unimarc.cl'], + [/\bCOPEC\b/, 'Copec','copec.cl'], + [/\bSHELL\b/, 'Shell','shell.com'], + [/FALABELLA/, 'Falabella','falabella.com'], + [/RIPLEY/, 'Ripley','ripley.cl'], + [/SODIMAC/, 'Sodimac','sodimac.cl'], + [/\bPARIS\b/, 'Paris','paris.cl'], + [/\bENTEL\b/, 'Entel','entel.cl'], + [/\bWOM\b/, 'WOM','wom.cl'], + [/MOVISTAR/, 'Movistar','movistar.cl'], + [/\bCLARO\b/, 'Claro','claro.cl'], + [/TURBUS|TUR BUS/, 'Turbus','turbus.cl'], + [/CRUZ VERDE/, 'Cruz Verde','cruzverde.cl'], + [/SALCOBRAND/, 'Salcobrand','salcobrand.cl'], + [/AHUMADA/, 'Farmacias Ahumada','farmaciasahumada.cl'], + [/INTEGRAMEDICA/, 'IntegraMedica','integramedica.cl'], + [/\bGOOGLE\b/, 'Google','google.com'], + [/MICROSOFT/, 'Microsoft','microsoft.com'], + [/\bOPENAI\b/, 'OpenAI','openai.com'], + [/ANTHROPIC/, 'Anthropic','anthropic.com'], + [/HETZNER/, 'Hetzner','hetzner.com'], + [/SITEGROUND/, 'SiteGround','siteground.com'], + [/ADIDAS/, 'adidas','adidas.com'], + [/NEW BALANCE/, 'New Balance','newbalance.com'], + [/\bENEL\b/, 'Enel','enel.cl'], + [/SERVIPAG/, 'Servipag','servipag.com'], + [/SENCILLITO/, 'Sencillito','sencillito.com'], + [/\bSKY\b/, 'Sky Airline','skyairline.com'], + [/LATAM/, 'LATAM','latamairlines.com'], + ]; + const PALETTE = ['#5b8def','#e0729a','#6fb86f','#d9a14b','#9b7fd4','#54b3c4','#cf7d56','#7d8aa0']; + const COLORS = new Map(); + function colorOf(key){ + if(COLORS.has(key)) return COLORS.get(key); + let h=0; for(let i=0;i>>0; + const c=PALETTE[h%PALETTE.length]; COLORS.set(key,c); return c; + } + const esc = s => String(s||'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); + function brandOf(desc){ + const s=(desc||'').toUpperCase(); + for(const [re,name,domain] of ALIAS) if(re.test(s)) return {name,domain}; + return null; + } + function initialOf(desc){ + const m=(desc||'').replace(/^(PS|PAGO:?|MERPAGO|PAYU|MP|DL|CV|COMPRA|PREPAGO)\b[\s\*:—-]*/i,'').trim(); + const ch=(m||desc||'?').replace(/[^A-Za-z0-9]/g,'').charAt(0).toUpperCase(); + return ch||'?'; + } + // returns an HTML string: a brand logo with initial fallback, or just the initial chip + function html(desc, size){ + size = size||22; + const b = brandOf(desc); + const key = b ? b.domain : (desc||''); + const init = b ? b.name.charAt(0).toUpperCase() : initialOf(desc); + const fb = ''; + if(!b) return fb; + const url='https://cdn.brandfetch.io/'+b.domain+'/w/'+(size*2)+'/h/'+(size*2)+'?c='+CLIENT_ID; + return ''; + } + window.MTlogo = { html, brandOf }; +})(); diff --git a/web/merchant-logos.json b/web/merchant-logos.json new file mode 100644 index 0000000..c1f8f2d --- /dev/null +++ b/web/merchant-logos.json @@ -0,0 +1,167 @@ +{ + "MERCADO PAGO": { + "name": "Mercado Pago", + "domain": "mercadopago.cl", + "via": "alias" + }, + "KFC": { + "name": "KFC", + "domain": "kfc.com", + "via": "alias" + }, + "MCDONALD'S": { + "name": "McDonald's", + "domain": "mcdonalds.com", + "via": "alias" + }, + "SANTA ISABEL": { + "name": "Santa Isabel", + "domain": "santaisabel.cl", + "via": "alias" + }, + "ENEL": { + "name": "Enel", + "domain": "enel.cl", + "via": "alias" + }, + "ENTEL": { + "name": "Entel", + "domain": "entel.cl", + "via": "alias" + }, + "HETZNER": { + "name": "Hetzner", + "domain": "hetzner.com", + "via": "alias" + }, + "RAPPI": { + "name": "Rappi", + "domain": "rappi.com", + "via": "alias" + }, + "UBER": { + "name": "Uber", + "domain": "uber.com", + "via": "alias" + }, + "CRUZ VERDE": { + "name": "Cruz Verde", + "domain": "cruzverde.cl", + "via": "alias" + }, + "SERVIPAG": { + "name": "Servipag", + "domain": "servipag.com", + "via": "alias" + }, + "LIDER": { + "name": "Lider", + "domain": "lider.cl", + "via": "alias" + }, + "UBER EATS": { + "name": "Uber Eats", + "domain": "ubereats.com", + "via": "alias" + }, + "APPLE": { + "name": "Apple", + "domain": "apple.com", + "via": "alias" + }, + "ALIEXPRESS": { + "name": "AliExpress", + "domain": "aliexpress.com", + "via": "alias" + }, + "ANTHROPIC": { + "name": "Anthropic", + "domain": "anthropic.com", + "via": "alias" + }, + "SHEIN": { + "name": "Shein", + "domain": "shein.com", + "via": "alias" + }, + "COPEC": { + "name": "Copec", + "domain": "copec.cl", + "via": "alias" + }, + "TURBUS": { + "name": "Turbus", + "domain": "turbus.cl", + "via": "alias" + }, + "WOM": { + "name": "WOM", + "domain": "wom.cl", + "via": "alias" + }, + "GOOGLE": { + "name": "Google", + "domain": "google.com", + "via": "alias" + }, + "INTEGRAMÉDICA": { + "name": "IntegraMédica", + "domain": "integramedica.cl", + "via": "alias" + }, + "FALABELLA": { + "name": "Falabella", + "domain": "falabella.com", + "via": "alias" + }, + "JUMBO": { + "name": "Jumbo", + "domain": "jumbo.cl", + "via": "alias" + }, + "PAYSEND": { + "name": "Paysend", + "domain": "paysend.com", + "via": "alias" + }, + "SALCOBRAND": { + "name": "Salcobrand", + "domain": "salcobrand.cl", + "via": "alias" + }, + "ADIDAS": { + "name": "adidas", + "domain": "adidas.com", + "via": "alias" + }, + "DIDI": { + "name": "DiDi", + "domain": "didiglobal.com", + "via": "alias" + }, + "UNIMARC": { + "name": "Unimarc", + "domain": "unimarc.cl", + "via": "alias" + }, + "CLARO": { + "name": "Claro", + "domain": "claro.cl", + "via": "alias" + }, + "AMAZON": { + "name": "Amazon", + "domain": "amazon.com", + "via": "alias" + }, + "PAYPAL": { + "name": "PayPal", + "domain": "paypal.com", + "via": "alias" + }, + "TEMU": { + "name": "Temu", + "domain": "temu.com", + "via": "alias" + } +} \ No newline at end of file