Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save mark05e/4e8bcfa54df846529a0bd756c27f2222 to your computer and use it in GitHub Desktop.

Select an option

Save mark05e/4e8bcfa54df846529a0bd756c27f2222 to your computer and use it in GitHub Desktop.
Export transactions from Wealthsimple to a CSV file for YNAB import
// ==UserScript==
// @name Export Wealthsimple transactions to CSV for YNAB
// @namespace wealthsimple.activity.export
// @version 20260208
// @description Export transactions from Wealthsimple to a CSV file for YNAB import with enhanced Payee info
// @author https://gist.github.com/shotasenga
// @author https://gist.github.com/kaipee
// @author https://gist.github.com/mark05e
// @downloadURL https://gist.githubusercontent.com/mark05e/4e8bcfa54df846529a0bd756c27f2222/raw
// @updateURL https://gist.githubusercontent.com/mark05e/4e8bcfa54df846529a0bd756c27f2222/raw
// @match https://my.wealthsimple.com/app/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=wealthsimple.com
// @grant none
// ==/UserScript==
/*
* DISCLAIMER:
* This script extracts sensitive financial information (transaction data) from Wealthsimple.
* Ensure that you use this script in a secure environment and handle the extracted data responsibly.
* The developer of this script is not responsible for any issues or troubles that arise from its use.
*/
(function () {
"use strict";
// Constants
const SELECTORS = {
activityHeader: "//h1[contains(., 'Activity')]",
transactionButtons: "//button[contains(., 'Chequing')][contains(., '$')]",
amountElement: ".//p[contains(., '$')]",
dateElement: ".//p[text()='Date' or text()='Scheduled date']/parent::div/following-sibling::div//p",
loadMoreButton: "//button[contains(text(), 'Load more')]"
};
const CSV_HEADERS = ["Date", "Payee", "Amount"];
const DATE_REGEX = /(\w+) (\d+), (\d+)/;
// Initialize the script
init();
function init() {
waitUntilElementExists(SELECTORS.activityHeader, addExportButton);
}
function addExportButton(activityHeader) {
const exportButton = createExportButton();
activityHeader.parentElement.appendChild(exportButton);
}
function createExportButton() {
const button = document.createElement("button");
button.innerText = "Export transactions";
button.onclick = handleExportClick;
button.style.cssText = `
margin-left: 10px;
padding: 8px 16px;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
`;
return button;
}
async function handleExportClick(event) {
const exportButton = event.target;
let spinner = null;
try {
console.log('Starting transaction export...');
// Show spinner and disable button
spinner = showSpinner(exportButton);
exportButton.disabled = true;
exportButton.style.opacity = '0.6';
// First, load all available transactions
updateSpinnerText(spinner, 'Loading all transactions...');
await loadAllTransactions();
// Then extract all transaction data
updateSpinnerText(spinner, 'Extracting transaction data...');
const transactions = await extractAllTransactions();
updateSpinnerText(spinner, 'Generating CSV file...');
const csvContent = generateCsvContent(transactions);
downloadCsvFile(csvContent);
updateSpinnerText(spinner, `✅ Export complete! ${transactions.length} transactions exported`);
console.log(`Exported ${transactions.length} transactions successfully`);
// Keep success message for 2 seconds
setTimeout(() => {
hideSpinner(spinner);
resetExportButton(exportButton);
}, 2000);
} catch (error) {
console.error('Export failed:', error);
if (spinner) {
updateSpinnerText(spinner, '❌ Export failed - check console');
setTimeout(() => {
hideSpinner(spinner);
resetExportButton(exportButton);
}, 3000);
}
alert('Failed to export transactions. Please check the console for details.');
}
}
async function loadAllTransactions() {
let loadMoreAttempts = 0;
const maxAttempts = 100; // Increased safety limit for large transaction histories
console.log('Loading all transactions...');
while (loadMoreAttempts < maxAttempts) {
const loadMoreButton = getElementsByXPath(SELECTORS.loadMoreButton).next().value;
if (!loadMoreButton) {
console.log(`✅ All transactions loaded - "Load more" button disappeared after ${loadMoreAttempts} loads`);
break;
}
console.log(`🔄 Loading more transactions... (batch ${loadMoreAttempts + 1})`);
// Update spinner with current progress
const spinner = document.getElementById('wealthsimple-export-spinner');
if (spinner) {
updateSpinnerText(spinner, `Loading transactions... (batch ${loadMoreAttempts + 1})`);
}
loadMoreButton.click();
// Wait for new content to load and DOM to update
await new Promise(resolve => setTimeout(resolve, 1500));
loadMoreAttempts++;
// Log progress every 10 loads for large datasets
if (loadMoreAttempts % 10 === 0) {
console.log(`📊 Progress: Loaded ${loadMoreAttempts} batches so far...`);
}
}
if (loadMoreAttempts >= maxAttempts) {
console.warn(`⚠️ Reached maximum load attempts (${maxAttempts}). This may indicate an issue or extremely large transaction history.`);
}
console.log(`🎉 Transaction loading complete! Total batches loaded: ${loadMoreAttempts}`);
}
function showSpinner(exportButton) {
const spinner = document.createElement('div');
spinner.id = 'wealthsimple-export-spinner';
spinner.style.cssText = `
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(255, 255, 255, 0.95);
border: 2px solid #007bff;
border-radius: 8px;
padding: 20px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 10000;
display: flex;
align-items: center;
gap: 12px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
color: #333;
min-width: 250px;
`;
const spinnerIcon = document.createElement('div');
spinnerIcon.style.cssText = `
width: 20px;
height: 20px;
border: 2px solid #f3f3f3;
border-top: 2px solid #007bff;
border-radius: 50%;
animation: spin 1s linear infinite;
`;
const spinnerText = document.createElement('span');
spinnerText.id = 'spinner-text';
spinnerText.textContent = 'Initializing export...';
spinner.appendChild(spinnerIcon);
spinner.appendChild(spinnerText);
// Add CSS animation
if (!document.getElementById('spinner-styles')) {
const style = document.createElement('style');
style.id = 'spinner-styles';
style.textContent = `
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`;
document.head.appendChild(style);
}
document.body.appendChild(spinner);
return spinner;
}
function updateSpinnerText(spinner, text) {
if (spinner) {
const textElement = spinner.querySelector('#spinner-text');
if (textElement) {
textElement.textContent = text;
}
}
}
function hideSpinner(spinner) {
if (spinner && spinner.parentNode) {
spinner.parentNode.removeChild(spinner);
}
}
function resetExportButton(exportButton) {
exportButton.disabled = false;
exportButton.style.opacity = '1';
}
async function extractAllTransactions() {
const transactions = [];
const transactionButtons = Array.from(getElementsByXPath(SELECTORS.transactionButtons));
let accountName = null;
for (const button of transactionButtons) {
try {
const transaction = await extractTransactionData(button);
if (transaction) {
transactions.push(transaction);
// Capture account name from first transaction if not already set
if (!accountName && transaction.accountName) {
accountName = transaction.accountName;
}
}
} catch (error) {
console.error('Failed to extract transaction from button:', button, error);
}
}
// Store account name globally for filename generation
window.wealthsimpleAccountName = accountName;
return transactions;
}
async function extractTransactionData(button) {
// 1. Initial extraction from the button face
let payee = getPayeeName(button);
const amount = getTransactionAmount(button);
const accountName = getAccountName(button);
// 2. Click to reveal transaction details (needed for Date and "To" location)
button.click();
await nextTick();
// 3. If it's a transfer, look for the "To" field in the expanded view
if (payee.toLowerCase().includes("transfer out")) {
const destination = getTransferDestination(button);
if (destination) {
payee = `Transfer out: ${destination}`;
}
}
const date = getTransactionDate(button);
return {
payee,
amount: normalizeAmount(amount),
date: formatDateForYNAB(date),
accountName
};
}
function getPayeeName(button) {
const paragraphs = Array.from(button.querySelectorAll("p[data-fs-privacy-rule]"));
const mainName = paragraphs[0]?.innerText.trim() || "UNKNOWN_PAYEE";
const typeLabel = paragraphs[1]?.innerText.trim() || "";
const ignoredLabels = ["Chequing", "Cash", "Savings"];
// If the secondary label is valid and not an account name, prepend it
if (typeLabel && !ignoredLabels.includes(typeLabel)) {
return `${typeLabel}: ${mainName}`;
}
return mainName;
}
function getTransferDestination(button) {
// Look for the "To" label and get its following sibling's text
// We target the expanded region associated with this button
const regionId = button.getAttribute('aria-controls');
const region = document.getElementById(regionId);
if (region) {
// Find the div containing the text "To", then go to the value div next to it
const toLabel = Array.from(region.querySelectorAll('p')).find(p => p.innerText === 'To');
if (toLabel) {
// The structure is: div(LabelContainer) -> p(To)
// We want the text in the next div's paragraph
const valueElement = toLabel.closest('.lizokw')?.querySelector('.gQehiP p');
return valueElement?.innerText.trim();
}
}
return null;
}
function getTransactionAmount(button) {
const amountElement = getElementsByXPath(SELECTORS.amountElement, button).next().value;
return amountElement?.innerText || "0.00";
}
function getAccountName(button) {
// Look for account name in the transaction button structure
const accountElements = button.querySelectorAll('p');
for (const element of accountElements) {
const text = element.innerText;
// Look for text containing bullet point (•) which indicates account info
if (text.includes('•')) {
return text;
}
}
return null;
}
function getTransactionDate(button) {
const dateElements = Array.from(
getElementsByXPath(SELECTORS.dateElement, button.parentElement.parentElement)
);
return dateElements[0]?.innerText || null;
}
function normalizeAmount(amount) {
if (!amount) return "0.00";
return amount
.replace(/−/g, '-') // Unicode minus to ASCII hyphen
.replace(/\u2212/g, '-') // Mathematical minus to ASCII hyphen
.replace(/\s*[−\u2212-]\s*\$/g, '-$') // Clean spacing around minus and $
.trim();
}
function formatDateForYNAB(dateString) {
if (!dateString) {
console.warn('formatDateForYNAB received null/undefined date');
return getCurrentDateFormatted();
}
const match = dateString.match(DATE_REGEX);
if (!match) {
console.error('formatDateForYNAB could not parse date string:', dateString);
return getCurrentDateFormatted();
}
const [, monthName, day, year] = match;
const month = getMonthNumber(monthName);
const paddedDay = day.padStart(2, "0");
const paddedMonth = month.toString().padStart(2, "0");
return `${year}-${paddedMonth}-${paddedDay}`;
}
function getMonthNumber(monthName) {
const monthDate = new Date(Date.parse(`${monthName} 1, 2020`));
return monthDate.getMonth() + 1;
}
function getCurrentDateFormatted() {
const today = new Date();
const year = today.getFullYear();
const month = (today.getMonth() + 1).toString().padStart(2, "0");
const day = today.getDate().toString().padStart(2, "0");
return `${year}-${month}-${day}`;
}
function generateCsvContent(transactions) {
const csvRows = [CSV_HEADERS.join(",")];
transactions.forEach(transaction => {
const row = [
escapeCsvField(transaction.date),
escapeCsvField(transaction.payee),
escapeCsvField(transaction.amount)
].join(",");
csvRows.push(row);
});
return csvRows.join("\n");
}
function downloadCsvFile(csvContent) {
const blob = new Blob([csvContent], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const downloadLink = document.createElement("a");
const filename = generateFilename();
downloadLink.href = url;
downloadLink.download = filename;
downloadLink.click();
// Clean up the object URL
setTimeout(() => URL.revokeObjectURL(url), 100);
}
function generateFilename() {
const baseDate = getCurrentDateFormatted();
const accountName = window.wealthsimpleAccountName;
if (accountName) {
const sanitizedAccount = sanitizeFilename(accountName);
return `wealthsimple-${sanitizedAccount}-${baseDate}.csv`;
}
return `wealthsimple-transactions-${baseDate}.csv`;
}
function sanitizeFilename(filename) {
return filename
.replace(/•/g, '-') // Replace bullet points with hyphens
.replace(/[<>:"/\\|?*]/g, '') // Remove invalid filename characters
.replace(/\s+/g, '-') // Replace spaces with hyphens
.replace(/-+/g, '-') // Replace multiple consecutive hyphens with single hyphen
.replace(/^-|-$/g, '') // Remove leading/trailing hyphens
.toLowerCase(); // Convert to lowercase for consistency
}
function escapeCsvField(field) {
if (!field) return '""';
const escaped = field.toString().replace(/"/g, '""');
return `"${escaped}"`;
}
// Utility Functions
function* getElementsByXPath(xpath, root = document) {
const xpathResult = document.evaluate(
xpath,
root,
null,
XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,
null
);
for (let i = 0; i < xpathResult.snapshotLength; i++) {
yield xpathResult.snapshotItem(i);
}
}
function nextTick() {
return new Promise(resolve => setTimeout(resolve, 100));
}
function waitUntilElementExists(xpath, callback) {
// Check if element already exists
const existingElement = getElementsByXPath(xpath).next().value;
if (existingElement) {
callback(existingElement);
return;
}
// Wait for element to appear
const observer = new MutationObserver(() => {
const element = getElementsByXPath(xpath).next().value;
if (element) {
observer.disconnect();
callback(element);
}
});
observer.observe(document.documentElement, {
childList: true,
subtree: true,
});
}
// Legacy function alias for backward compatibility
const x = getElementsByXPath;
})();
@mark05e
Copy link
Author

mark05e commented Feb 8, 2026

Changelog:

20260208

  • Fixed "null date" errors by updating the date extraction XPath to traverse parent/sibling containers, ensuring compatibility with nested DOM structure.
  • Enhanced the Payee logic to automatically prepend the transaction type (e.g., "Purchase" or "Bill pay") and append the destination account (e.g., "Transfer out - RRSP") by extracting details from expanded transaction views.
  • Updated metadata with contributor Gist profiles and added downloadURL/updateURL to enable automatic script updates directly from the gist source.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment