Skip to content

Instantly share code, notes, and snippets.

@adampetrovic
Last active March 30, 2026 20:40
Show Gist options
  • Select an option

  • Save adampetrovic/5821aac0c2aa9759e56ddca9b033f0fa to your computer and use it in GitHub Desktop.

Select an option

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
// ==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