Forked from shotasenga/wealthsimple-transaction-for-ynab.user.js
Last active
March 7, 2026 04:03
-
-
Save mark05e/4e8bcfa54df846529a0bd756c27f2222 to your computer and use it in GitHub Desktop.
Export transactions from Wealthsimple to a CSV file for YNAB 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 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; | |
| })(); |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Changelog:
20260208
downloadURL/updateURLto enable automatic script updates directly from the gist source.