/* Observe responses already requested by the page. Never make a banking request. */ (() => { if (location.protocol !== 'https:') return; const marker = '__merchantLensCaptureV1'; if (window[marker]) return; window[marker] = true; const channel = 'merchant-lens:v1'; let enabled = false; const recent = new Map(); const lifetime = 30 * 60 * 1000; function onTransactions() { return bank === 'rogers' ? /^\/transactions(?:\/|$)/i.test(location.pathname) : /^\/Card(?:\/|$)/i.test(location.pathname); } function pruneRecent() { const now = Date.now(); for (const [key,value] of recent) if (now - value.seen >= lifetime) recent.delete(key); if (!onTransactions()) recent.clear(); } function replay() { pruneRecent(); if (enabled && recent.size) window.postMessage({channel,rows:[...recent.values()].map(v=>({...v.row,capturedAt:v.seen}))},location.origin); } window.addEventListener('message', event => { if (event.source === window && event.origin === location.origin && event.data?.channel === 'merchant-lens:control:v1' && typeof event.data.enabled === 'boolean') { const becameEnabled = !enabled && event.data.enabled; enabled = event.data.enabled; if (becameEnabled) replay(); } }); window.postMessage({channel:'merchant-lens:ready:v1'}, location.origin); const limit = 8 * 1024 * 1024; const bank = location.hostname === 'selfserve.rogersbank.com' ? 'rogers' : 'capital-one'; setInterval(pruneRecent, 15000); window.addEventListener('pagehide', () => recent.clear()); // Only the MCC projection crosses into the extension's isolated context. const clean = value => typeof value === 'string' ? value.slice(0, 160).trim() : ''; function project(payload) { const result = []; let visited = 0; function walk(value, depth) { if (!value || typeof value !== 'object' || depth > 16 || ++visited > 50000) return; const merchant = bank === 'rogers' ? value.merchant : value.transactionMerchant; const amount = bank === 'rogers' && /^-?\d+(?:\.\d+)?$/.test(String(value.amount?.value)) ? Number(value.amount.value) : value.transactionAmount; if (merchant && /^\d{4}$/.test(String(merchant.categoryCode)) && Number.isFinite(amount)) { if (bank === 'rogers') { const name = clean(merchant.name); if (name) result.push({bank, name, amount, last4: /\d{4}$/.exec(clean(value.cardNumber))?.[0] || '', holder: clean(value.name?.nameOnCard), date: clean(value.date).slice(0, 10), postedDate: clean(value.postedDate).slice(0, 10), state: clean(value.activityStatus), mcc: String(merchant.categoryCode), type: clean(merchant.category), category: clean(merchant.categoryDescription)}); } else { const name = clean(value.transactionDescription || merchant.name); if (name) result.push({ bank, name, amount: Math.abs(amount), last4: /^\d{4}$/.test(value.transactingCardLastFour) ? value.transactingCardLastFour : '', date: clean(value.transactionDisplayDate || value.transactionDate).slice(0, 10), state: clean(value.transactionState), mcc: String(merchant.categoryCode), type: clean(merchant.merchantType || merchant.parentCategory || value.displayCategory), category: clean(merchant.category) }); } } for (const child of Object.values(value)) walk(child, depth + 1); } walk(payload, 0); return result.slice(0, 5000); } function relevant(url) { try { const u = new URL(url, location.href); if (u.protocol !== 'https:') return false; if (bank === 'rogers') return ['selfserve.apis.rogersbank.com', 'selfserve.rogersbank.com'].includes(u.hostname) && /\/transactions(?:\/|$)/i.test(u.pathname); return /(^|\.)capitalone\.com$/.test(u.hostname) && /transaction/i.test(u.pathname); } catch { return false; } } function publish(payload) { try { pruneRecent(); if (!onTransactions()) return; const rows = project(payload); for (const row of rows) { recent.set(JSON.stringify(row), {row,seen:Date.now()}); if (recent.size > 5000) recent.delete(recent.keys().next().value); } if (enabled && rows.length) window.postMessage({channel, rows}, location.origin); } catch { /* Never affect the bank page. */ } } const originalFetch = window.fetch; if (originalFetch) window.fetch = function (...args) { const promise = Reflect.apply(originalFetch, this, args); promise.then(response => { if (!onTransactions() || !relevant(response.url || (typeof args[0] === 'string' ? args[0] : args[0]?.url))) return; if (Number(response.headers.get('content-length')) > limit) return; const copy = response.clone(); // Read a bounded stream so unexpectedly large responses are not retained. (async () => { const reader = copy.body?.getReader(); if (!reader) return; let size = 0, parts = []; try { while (true) { const {done, value} = await reader.read(); if (done) break; size += value.byteLength; if (size > limit) { void reader.cancel(); return; } parts.push(value); } const bytes = new Uint8Array(size); let offset = 0; for (const part of parts) { bytes.set(part, offset); offset += part.byteLength; } publish(JSON.parse(new TextDecoder().decode(bytes))); } catch { /* Non-JSON responses are ignored. */ } })().catch(() => {}); }).catch(() => {}); return promise; }; const urls = new WeakMap(); const open = XMLHttpRequest.prototype.open; const send = XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.open = function (method, url, ...rest) { urls.set(this, String(url)); return Reflect.apply(open, this, [method, url, ...rest]); }; XMLHttpRequest.prototype.send = function (...args) { this.addEventListener('load', () => { try { if (!onTransactions() || !relevant(this.responseURL || urls.get(this))) return; if (this.responseType === 'json') publish(this.response); else if ((!this.responseType || this.responseType === 'text') && this.responseText.length <= limit) publish(JSON.parse(this.responseText)); } catch { /* Preserve normal XHR behavior. */ } }, {once: true}); return Reflect.apply(send, this, args); }; })();