Last active
March 30, 2026 20:40
-
-
Save adampetrovic/5821aac0c2aa9759e56ddca9b033f0fa to your computer and use it in GitHub Desktop.
Violentmonkey userscript: Export CommBank NetBank transactions (including pending) as JSON for Actual Budget CLI import
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name CommBank → Actual Budget Export | |
| // @namespace https://github.com/adampetrovic | |
| // @version 2.0.0 | |
| // @description Export CommBank NetBank transactions (including pending) as JSON for Actual Budget CLI import | |
| // @author Adam Petrovic | |
| // @match https://www.my.commbank.com.au/netbank/TransactionHistory/* | |
| // @match https://www2.my.commbank.com.au/netbank/TransactionHistory/* | |
| // @grant none | |
| // ==/UserScript== | |
| (function () { | |
| 'use strict'; | |
| const MONTHS = { | |
| Jan: '01', Feb: '02', Mar: '03', Apr: '04', May: '05', Jun: '06', | |
| Jul: '07', Aug: '08', Sep: '09', Oct: '10', Nov: '11', Dec: '12', | |
| }; | |
| /** | |
| * Parse "27 Mar 2026" → "2026-03-27" (ISO format for Actual Budget) | |
| * Uses regex to extract the date pattern, ignoring surrounding text | |
| * (e.g. CommBank injects "transaction" text in the date cell). | |
| */ | |
| function parseDate(text) { | |
| const match = text.match( | |
| /(\d{1,2})\s+(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+(\d{4})/ | |
| ); | |
| if (!match) return null; | |
| const [, day, mon, year] = match; | |
| const mm = MONTHS[mon]; | |
| if (!mm) return null; | |
| return `${year}-${mm}-${day.padStart(2, '0')}`; | |
| } | |
| /** | |
| * Extract dollar amount from an amount cell. | |
| * Returns amount in cents (integer), negative for debits. | |
| * Returns null for empty/zero-amount rows. | |
| */ | |
| function parseAmount(row) { | |
| const amountCell = row.querySelector('td.amount'); | |
| if (!amountCell || amountCell.textContent.trim() === '' || amountCell.innerHTML.includes(' ')) { | |
| return null; | |
| } | |
| const creditSpan = amountCell.querySelector('.currencyUICredit'); | |
| const debitSpan = amountCell.querySelector('.currencyUIDebit'); | |
| const span = creditSpan || debitSpan; | |
| if (!span) return null; | |
| const raw = span.textContent.replace(/[$,+\-\s]/g, '').trim(); | |
| const dollars = parseFloat(raw); | |
| if (isNaN(dollars)) return null; | |
| const cents = Math.round(dollars * 100); | |
| return debitSpan ? -cents : cents; | |
| } | |
| /** | |
| * Get the merchant/payee description from a row. | |
| * Strips "PENDING - " prefix and "## XXX MERCHANT" suffixes. | |
| */ | |
| function parseDescription(row) { | |
| const merchantEl = row.querySelector('td.arrow span.merchant'); | |
| if (!merchantEl) return null; | |
| let text = ''; | |
| for (const node of merchantEl.childNodes) { | |
| if (node.nodeType === Node.TEXT_NODE) { | |
| text += node.textContent; | |
| } else if (node.tagName === 'A') { | |
| for (const child of node.childNodes) { | |
| if (child.nodeType === Node.TEXT_NODE) { | |
| text += child.textContent; | |
| } else if (child.tagName === 'BR') { | |
| text += ' '; | |
| } | |
| } | |
| } else if (node.tagName === 'BR') { | |
| text += ' '; | |
| } | |
| } | |
| text = text.trim(); | |
| text = text.replace(/^PENDING\s*-\s*/i, ''); | |
| text = text.replace(/\s*##\s+\w+\s+MERCHANT\s*$/i, ''); | |
| return text.trim() || null; | |
| } | |
| /** | |
| * Check if a row is a pending transaction | |
| */ | |
| function isPending(row) { | |
| return !!row.querySelector('a[data-is-pending="true"]'); | |
| } | |
| /** | |
| * Generate a stable imported_id from transaction fields. | |
| * Uses date + amount + first 30 chars of description. | |
| */ | |
| function generateImportedId(date, amount, description) { | |
| const key = `${date}|${amount}|${description.substring(0, 30).trim()}`; | |
| // Simple hash — djb2 | |
| let hash = 5381; | |
| for (let i = 0; i < key.length; i++) { | |
| hash = ((hash << 5) + hash + key.charCodeAt(i)) >>> 0; | |
| } | |
| return `cba-${hash.toString(16)}`; | |
| } | |
| /** | |
| * Scrape all visible transactions into Actual Budget JSON format. | |
| * Each object: { date, amount (cents), payee_name, cleared, imported_id } | |
| */ | |
| function scrapeTransactions() { | |
| const rows = document.querySelectorAll('#transactionsTableBody tr'); | |
| const transactions = []; | |
| for (const row of rows) { | |
| const dateCell = row.querySelector('td.date'); | |
| if (!dateCell) continue; | |
| const date = parseDate(dateCell.textContent); | |
| if (!date) continue; | |
| const amount = parseAmount(row); | |
| if (amount === null) continue; | |
| const description = parseDescription(row); | |
| if (!description) continue; | |
| const pending = isPending(row); | |
| transactions.push({ | |
| date, | |
| amount, | |
| payee_name: description, | |
| cleared: !pending, | |
| imported_id: generateImportedId(date, amount, description), | |
| }); | |
| } | |
| return transactions; | |
| } | |
| /** | |
| * Get the account name from the page | |
| */ | |
| function getAccountName() { | |
| const el = document.querySelector('span.account-name'); | |
| return el ? el.textContent.trim() : 'CommBank Account'; | |
| } | |
| /** | |
| * Trigger a JSON file download | |
| */ | |
| function downloadFile(content, filename) { | |
| const blob = new Blob([content], { type: 'application/json' }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = filename; | |
| document.body.appendChild(a); | |
| a.click(); | |
| document.body.removeChild(a); | |
| URL.revokeObjectURL(url); | |
| } | |
| /** | |
| * Generate filename like "Mastercard_Ultimate_2026-03-30.json" | |
| */ | |
| function generateFilename(accountName) { | |
| const safe = accountName.replace(/[^a-zA-Z0-9]+/g, '_').replace(/_+$/, ''); | |
| const today = new Date().toISOString().split('T')[0]; | |
| return `${safe}_${today}.json`; | |
| } | |
| /** | |
| * Count exportable transactions currently in the DOM | |
| */ | |
| function countTransactions() { | |
| const rows = document.querySelectorAll('#transactionsTableBody tr'); | |
| let total = 0; | |
| let pending = 0; | |
| for (const row of rows) { | |
| const dateCell = row.querySelector('td.date'); | |
| if (!dateCell) continue; | |
| if (!parseDate(dateCell.textContent)) continue; | |
| if (parseAmount(row) === null) continue; | |
| if (!parseDescription(row)) continue; | |
| total++; | |
| if (isPending(row)) pending++; | |
| } | |
| return { total, pending, cleared: total - pending }; | |
| } | |
| /** | |
| * Update the button label with current transaction count | |
| */ | |
| function updateButtonLabel(btn) { | |
| if (btn.dataset.confirming === 'true') return; | |
| const { total, pending, cleared } = countTransactions(); | |
| const parts = []; | |
| if (pending > 0) parts.push(`${pending} pending`); | |
| if (cleared > 0) parts.push(`${cleared} cleared`); | |
| btn.textContent = total > 0 | |
| ? `⬇ Export JSON (${parts.join(', ')})` | |
| : '⬇ Export JSON'; | |
| } | |
| /** | |
| * Create and inject the floating export button | |
| */ | |
| function injectButton() { | |
| const tbody = document.getElementById('transactionsTableBody'); | |
| if (!tbody) { | |
| setTimeout(injectButton, 500); | |
| return; | |
| } | |
| const btn = document.createElement('button'); | |
| btn.id = 'actual-export-btn'; | |
| btn.style.cssText = ` | |
| position: fixed; | |
| bottom: 24px; | |
| right: 24px; | |
| z-index: 99999; | |
| background: #ffcc00; | |
| color: #231f20; | |
| border: none; | |
| border-radius: 8px; | |
| padding: 12px 20px; | |
| font-size: 14px; | |
| font-weight: bold; | |
| cursor: pointer; | |
| font-family: HelveticaNeue, Helvetica, Arial, sans-serif; | |
| box-shadow: 0 2px 8px rgba(0,0,0,0.25); | |
| `; | |
| updateButtonLabel(btn); | |
| btn.addEventListener('mouseenter', () => { | |
| if (btn.dataset.confirming !== 'true') btn.style.background = '#e6b800'; | |
| }); | |
| btn.addEventListener('mouseleave', () => { | |
| if (btn.dataset.confirming !== 'true') btn.style.background = '#ffcc00'; | |
| }); | |
| btn.addEventListener('click', () => { | |
| const transactions = scrapeTransactions(); | |
| if (transactions.length === 0) { | |
| alert('No transactions found on this page.'); | |
| return; | |
| } | |
| const accountName = getAccountName(); | |
| const pendingCount = transactions.filter(t => !t.cleared).length; | |
| const clearedCount = transactions.length - pendingCount; | |
| const json = JSON.stringify(transactions, null, 2); | |
| const filename = generateFilename(accountName); | |
| downloadFile(json, filename); | |
| btn.dataset.confirming = 'true'; | |
| btn.textContent = `✓ ${transactions.length} txns (${pendingCount} pending, ${clearedCount} cleared)`; | |
| btn.style.background = '#4CAF50'; | |
| btn.style.color = '#fff'; | |
| setTimeout(() => { | |
| btn.dataset.confirming = 'false'; | |
| btn.style.background = '#ffcc00'; | |
| btn.style.color = '#231f20'; | |
| updateButtonLabel(btn); | |
| }, 3000); | |
| }); | |
| document.body.appendChild(btn); | |
| // Watch for new rows from infinite scroll | |
| const observer = new MutationObserver(() => updateButtonLabel(btn)); | |
| observer.observe(tbody, { childList: true, subtree: true }); | |
| } | |
| injectButton(); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment