您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Adds export buttons to Summary and Details pages. They will export transactions within certain timeframe into CSV, options are "This Month", "Last 3 Month", "All". This should provide better transaction description than what is provided by preexisting CSV export feature.
// ==UserScript== // @name RBC export transactions as CSV // @namespace Violentmonkey Scripts // @match https://www1.royalbank.com/* // @grant GM.xmlHttpRequest // @version 1.0.1 // @license MIT // @author eaglesemanation // @description Adds export buttons to Summary and Details pages. They will export transactions within certain timeframe into CSV, options are "This Month", "Last 3 Month", "All". This should provide better transaction description than what is provided by preexisting CSV export feature. // ==/UserScript== /** * @typedef {Object} PageInfo * @property {"summary" | "details" | null} pageType * @property {HTMLElement?} anchor - Element to which buttons will be "attached". Buttons should be inserted before it. * @property {string?} accountId */ /** * Figures out which paget we're currently on and where to attach buttons. Should not do any queries, * because it gets spammed executed by MutationObserver. * * @returns {PageInfo} */ function getPageInfo() { /** * @type PageInfo */ let emptyInfo = { pageType: null, anchor: null, accountId: null, }; let info = structuredClone(emptyInfo); let hashParts = window.location.hash.split("/"); if (hashParts.length === 2 && hashParts[1] === "summary") { info.pageType = "summary"; let anchor = document.querySelectorAll("#ribbon-statements"); if (anchor.length !== 1) { return emptyInfo; } info.anchor = anchor[0]; } else if (hashParts.length === 2 && hashParts[1].startsWith("details")) { info.pageType = "details"; const hashKV = hashParts[1].split(";"); for (const kv of hashKV) { const kvParts = kv.split("="); if (kvParts.length === 2 && kvParts[0] === "selectedAccount") { info.accountId = kvParts[1]; break; } } let downloadButtons = document.querySelectorAll( `a[rbcportalsubmit="DownloadTransactions"], a[rbcportalsubmit="CC_Posted_Transactions_Download"]`, ); if (downloadButtons.length !== 2) { return emptyInfo; } for (let button of downloadButtons) { for (let attr of button.attributes) { if (attr.name.startsWith("_ngcontent")) { info.anchor = button; } } } if (info.anchor === null) { return emptyInfo; } } else { // Didn't match any expected page return emptyInfo; } return info; } // ID for quickly verifying if buttons were already injected const exportCsvId = "export-transactions-csv"; /** * Keeps button shown after rerenders and href changes * * @returns {Promise<void>} */ async function keepButtonShown() { // Early exit, to avoid unnecessary requests if already injected if (document.querySelector(`div#${exportCsvId}`)) { return; } const pageInfo = getPageInfo(); if (!pageInfo.pageType) { return; } console.log("[csv-export] Adding buttons"); addButtons(pageInfo); } (async function () { const observer = new MutationObserver(async (mutations) => { for (const _ of mutations) { await keepButtonShown(); } }); observer.observe(document.documentElement, { childList: true, subtree: true, }); // Try running on load if there are no mutations for some reason window.addEventListener("load", async () => { await keepButtonShown(); }); })(); /** * Stub, just forcing neovim to corectly highlight HTML syntax in literal */ function html(strings, ...values) { return strings.reduce((result, string, i) => { return result + string + (values[i] || ""); }, ""); } const downloadIcon = function (ngcontentAttr) { return html` <rbc-icon ${ngcontentAttr}="" name="download" size="s" class="download s rbc-icon" aria-hidden="true" > <svg viewBox="0 0 16 16" fit="" height="100%" width="100%" preserveAspectRatio="xMidYMid meet" focusable="false" > <path fill-rule="evenodd" d="m11.905 9.356-3.555 3.5a.498.498 0 0 1-.7 0h-.001l-3.554-3.5a.5.5 0 1 1 .701-.712L7.5 11.306V.5a.5.5 0 0 1 1 0v10.806l2.704-2.662a.5.5 0 0 1 .701.712ZM15 9.5a.5.5 0 0 1 1 0V15a1 1 0 0 1-1 1H1a1 1 0 0 1-1-1V9.5a.5.5 0 0 1 1 0V15h14V9.5Z" clip-rule="evenodd" ></path> </svg> </rbc-icon> `; }; /** * Attaches button row to anchor element. Should be synchronous to avoid attaching row twice, because Mutex is not cool enough for JS? * * @param {PageInfo} pageInfo * @returns {void} */ function addButtons(pageInfo) { let linkRow = document.createElement("div"); linkRow.id = exportCsvId; linkRow.style.display = "flex"; linkRow.style.alignItems = "baseline"; linkRow.style.gap = "1em"; linkRow.style.marginLeft = "auto"; // Seems to be some sort of component CSS system, just copy attribute from adjasent node let ngcontentAttrName = ""; for (let attr of pageInfo.anchor.attributes) { if (attr.name.startsWith("_ngcontent")) { ngcontentAttrName = attr.name; } } const now = new Date(); const links = [ { text: "This Month", fromDate: new Date(now.getFullYear(), now.getMonth(), 1), }, { text: "3 Months", fromDate: new Date(now.getFullYear(), now.getMonth() - 3, 1), }, { text: "All", fromDate: null, }, ]; for (const link of links) { let exportLink = document.createElement("a"); exportLink.href = "JavaScript:void(0);"; // Makes browser believe that it's interactive, what a silly thing to do exportLink.className = "export-csv-button"; exportLink.innerHTML = html`${downloadIcon(ngcontentAttrName)} <span ${pageInfo.pageType === "summary" ? `class="label label--shadowed"` : null} ${pageInfo.pageType === "details" ? `style="margin-left: 8px;"` : null} ${ngcontentAttrName}="" > ${link.text} </span>`; exportLink.setAttribute(ngcontentAttrName, ""); exportLink.onclick = async () => { console.log("[csv-export] Fetching account details"); let accountsInfo = await accountListSummary(); /** * @type {Record<string, AccountSummary>} */ let idToAccount = accountsInfo.reduce((acc, v) => { acc[v.accountId] = v; return acc; }, {}); /** * @type {Record<string, Blob>} */ let blobs = {}; if (pageInfo.pageType === "summary") { for (let acc of accountsInfo) { let transactions = await accountTransactions(acc, link.fromDate); blobs[acc.accountId] = await transactionsToCsvBlob(transactions); } } else if (pageInfo.pageType === "details") { if (pageInfo.accountId === null) { throw "details page is missing selectedAccount argument"; } let transactions = await accountTransactions( idToAccount[pageInfo.accountId], link.fromDate, ); blobs[pageInfo.accountId] = await transactionsToCsvBlob(transactions); } saveBlobsToFiles(blobs, idToAccount, link.fromDate); }; linkRow.appendChild(exportLink); } let anchorParent = pageInfo.anchor.parentNode; if (pageInfo.pageType === "details") { // Remove already existing download button pageInfo.anchor.innerHTML = ""; anchorParent.insertBefore(linkRow, pageInfo.anchor); } else { let divider = document.createElement("div"); divider.className = "mat-divider left-divider"; divider.setAttribute(ngcontentAttrName, ""); anchorParent.insertBefore(divider, pageInfo.anchor); anchorParent.insertBefore(linkRow, divider); } anchorParent.style.gap = "1em"; pageInfo.anchor.style.marginLeft = "0"; } /** * @typedef {Object} AccountListSummary * * @property {{accounts: [AccountSummary]}} depositAccounts * @property {{accounts: [AccountSummary]}} creditCards */ /** * @typedef {Object} AccountSummary * * @property {string} accountId * @property {string} accountNumber * @property {string} encryptedAccountNumber * @property {string} nickName * @property {{productName: string}} product * @property {"CREDIT" | "DEBIT"} type */ /** * Gets list of all types of accounts and their details * * @returns {Promise<[AccountSummary]>} */ async function accountListSummary() { let respJson = await GM.xmlHttpRequest({ url: "https://www1.royalbank.com/sgw5/digital/product-summary-presentation-service-v3/v3/accountListSummary", method: "GET", responseType: "json", headers: { "content-type": "application/json", }, }); if (respJson.status !== 200) { throw `Failed to fetch account list: ${respJson.responseText}`; } /** * @type {AccountListSummary} */ let resp = JSON.parse(respJson.responseText); if (resp.errorState.hasError === true) { throw `Failed to fetch account list: ${resp.errorState}`; } return resp.creditCards.accounts .map((v) => ({ ...v, type: "CREDIT" })) .concat( resp.depositAccounts.accounts.map((v) => ({ ...v, type: "DEBIT", })), ); } /** * Given Date returns yyyy-mm-dd * * @param {Date} date * @returns {string} */ function toDateString(date) { const offset = date.getTimezoneOffset(); const dateWithOffset = new Date(date.getTime() - offset * 60 * 1000); return dateWithOffset.toISOString().split("T")[0]; } /** * @param {Date} date * @param {number} years * @returns {Date} */ function dateSubstractYears(date, years) { let olderDate = new Date(date); olderDate.setFullYear(date.getFullYear() - years); return olderDate; } function getXSRFCookie() { let match = decodeURIComponent(document.cookie) .split(";") .map((v) => v.trim().split("=", 2)) .filter(([key, _]) => key === "XSRF-TOKEN"); if (match.length === 1) { return match[0][1]; } return undefined; } /** * @typedef {Object} Transaction * * @property {number} amount * @property {"CREDIT" | "DEBIT"} creditDebitIndicator * @property {[string]} description * @property {string} bookingDate * @property {string} merchantName * @property {string} merchantCity */ /** * @param {AccountSummary} account * @param {Date?} fromDate */ async function accountTransactions(account, fromDate) { if (account.type === "CREDIT") { // TODO: Figure out what is actual limit for export, it might be related to date of card issue const fromDateWithDefault = fromDate ?? dateSubstractYears(new Date(), 4); return await creditAccountTransactions( account.encryptedAccountNumber, fromDateWithDefault, ); } else if (account.type === "DEBIT") { const fromDateWithDefault = fromDate ?? dateSubstractYears(new Date(), 7); return await debitAccountTransactions( account.encryptedAccountNumber, fromDateWithDefault, ); } } /** * Gets transactions for debit account * * @param {string} encryptedAccountNumber * @param {Date} fromDate * @returns {Promise<[Transaction]>} */ async function debitAccountTransactions(encryptedAccountNumber, fromDate) { const transactionFromDate = toDateString(fromDate); let transactions = []; let hasNextPage = true; let offsetKey = undefined; while (hasNextPage) { let respJson = await GM.xmlHttpRequest({ url: `https://www1.royalbank.com/sgw5/digital/transaction-presentation-service-v3-dbb/v3/search/pda/account/${encodeURIComponent(encryptedAccountNumber)}`, method: "POST", responseType: "json", headers: { "content-type": "application/json", "X-XSRF-TOKEN": getXSRFCookie(), }, data: JSON.stringify({ transactionFromDate, transactionToDate: toDateString(new Date()), limit: 200, offsetKey, }), }); if (respJson.status !== 200) { throw `Failed to fetch transactions: ${respJson.responseText}`; } let resp = JSON.parse(respJson.responseText); if (resp.hasError === true) { throw `Failed to fetch transactions: [${resp.errorLevel}] ${resp.errorDescription}`; } transactions = transactions.concat(resp.transactionList); if (transactions.length === 0) { break; } offsetKey = transactions[transactions.length - 1].transactionOffsetKey; hasNextPage = resp.totalMatches > transactions.length; } return transactions; } /** * Gets transactions for credit card * * @param {string} encryptedAccountNumber * @param {Date} fromDate * @returns {Promise<[Transaction]>} */ async function creditAccountTransactions(encryptedAccountNumber, fromDate) { const transactionFromDate = toDateString(fromDate); let transactions = []; let hasNextPage = true; let offsetKey = undefined; while (hasNextPage) { let respJson = await GM.xmlHttpRequest({ url: `https://www1.royalbank.com/sgw5/digital/transaction-presentation-service-v3-dbb/v3/search/cc/posted/account/${encodeURIComponent(encryptedAccountNumber)}`, method: "POST", responseType: "json", headers: { "content-type": "application/json", "X-XSRF-TOKEN": getXSRFCookie(), }, data: JSON.stringify({ transactionFromDate, transactionToDate: toDateString(new Date()), limit: 200, offsetKey, }), }); if (respJson.status !== 200) { throw `Failed to fetch transactions: ${respJson.responseText}`; } let resp = JSON.parse(respJson.responseText); if (resp.hasError === true) { throw `Failed to fetch transactions: [${resp.errorLevel}] ${resp.errorDescription}`; } transactions = transactions.concat(resp.transactionList); if (transactions.length === 0) { break; } offsetKey = transactions[transactions.length - 1].transactionOffsetKey; hasNextPage = resp.totalMatches > transactions.length; } return transactions; } /** * @param {[Transaction]} transactions * @returns {Promise<Blob>} */ async function transactionsToCsvBlob(transactions) { let csv = `"Date","Payee","Notes","Category","Amount"\n`; for (const transaction of transactions) { let payee = "unknown"; let notes = transaction.description.join(" "); // Most transactions in RBC don't have category let category = ""; const desc = transaction.description; if (transaction.merchantName !== null) { payee = transaction.merchantName; notes = transaction.merchantCity ?? transaction.merchantName; } else if (desc.length === 3 && desc[0].startsWith("e-Transfer")) { payee = desc[1]; notes = `${desc[0]} [${desc[2]}]`; } else if (desc.length >= 1 && desc[0].toLowerCase().includes("fee")) { console.log( `[csv-export] ${transaction.bookingDate} transaction includes "fee" in its description: "${desc.join(" ")}"; setting payee to RBC. Please report on greasyfork.org in case of false positives.`, ); payee = "RBC"; notes = desc[0]; } else if ( desc.length === 1 && desc[0].toLowerCase().endsWith("deposit interest") ) { payee = "RBC"; notes = desc[0]; } else if (desc.length === 2) { payee = desc[1]; notes = desc[0]; } else { console.log( `[csv-export] ${transaction.bookingDate} transaction has description that could not be parsed: "${desc.join(" ")}". If you beleive this description has identifiable payee - please report on greasyfork.org for assistanse.`, ); console.log(transaction); } let amount = transaction.amount; if (transaction.creditDebitIndicator === "DEBIT") { amount = `-${amount}`; } // Transactions in RBC don't have category, skipping let entry = `"${transaction.bookingDate}","${payee}","${notes}","${category}","${amount}"`; csv += `${entry}\n`; } // Signals to some apps that file encoded with UTF-8 const BOM = "\uFEFF"; return new Blob([BOM, csv], { type: "text/csv;charset=utf-8" }); } /** * @param {Record<string, Blob>}} accountBlobs * @param {Record<string, AccountInfo>} idToAccounts * @param {Date?} fromDate */ function saveBlobsToFiles(accountBlobs, idToAccounts, fromDate) { for (let acc in accountBlobs) { let blobUrl = URL.createObjectURL(accountBlobs[acc]); let now = new Date(); let nowStr = `${now.getFullYear()}-${now.getMonth() + 1}-${now.getDate()}`; let timeFrame = ""; if (fromDate) { timeFrame += `From ${fromDate.getFullYear()}-${fromDate.getMonth() + 1}-${fromDate.getDate()} `; } timeFrame += `Up to ${nowStr}`; let link = document.createElement("a"); link.href = blobUrl; link.download = `${idToAccounts[acc].product.productName} Transactions ${timeFrame}.csv`; link.style.display = "none"; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(blobUrl); } }