您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Adds export buttons to Activity feed and to Account specific activity. 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 Wealthsimple export transactions as CSV // @namespace Violentmonkey Scripts // @match https://my.wealthsimple.com/* // @grant GM.xmlHttpRequest // @version 1.2 // @license MIT // @author eaglesemanation // @description Adds export buttons to Activity feed and to Account specific activity. 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== /** * @callback ReadyPredicate * @returns {boolean} */ /** * @typedef {Object} PageInfo * @property {"account-details" | "activity" | null} pageType * @property {HTMLElement?} anchor - Element to which buttons will be "attached". Buttons should be inserted before it. * @property {ReadyPredicate?} readyPredicate - Verifies if ready to insert */ /** * 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, readyPredicate: null, accountsInfo: null, }; let info = structuredClone(emptyInfo); let pathParts = window.location.pathname.split("/"); if (pathParts.length === 4 && pathParts[2] === "account-details") { // All classes within HTML have been obfuscated/minified, using icons as a starting point, in hope that they don't change that much. const threeDotsSvgPath = "M5.333 11.997c0 1.466-1.2 2.666-2.666 2.666A2.675 2.675 0 0 1 0 11.997C0 10.53 1.2 9.33 2.667 9.33c1.466 0 2.666 1.2 2.666 2.667Zm16-2.667a2.675 2.675 0 0 0-2.666 2.667c0 1.466 1.2 2.666 2.666 2.666 1.467 0 2.667-1.2 2.667-2.666 0-1.467-1.2-2.667-2.667-2.667ZM12 9.33a2.675 2.675 0 0 0-2.667 2.667c0 1.466 1.2 2.666 2.667 2.666 1.467 0 2.667-1.2 2.667-2.666 0-1.467-1.2-2.667-2.667-2.667Z"; const threeDotsButtonContainerQuery = `div:has(> div > button svg > path[d="${threeDotsSvgPath}"])`; info.pageType = "account-details"; let anchor = document.querySelectorAll(threeDotsButtonContainerQuery); if (anchor.length !== 1) { return emptyInfo; } info.anchor = anchor[0]; info.readyPredicate = () => info.anchor.parentNode.children.length >= 1; } else if (pathParts.length === 3 && pathParts[2] === "activity") { const threeLinesSvgPath = "M14 8c0 .6-.4 1-1 1H3c-.6 0-1-.4-1-1s.4-1 1-1h10c.6 0 1 .4 1 1Zm1-6H1c-.6 0-1 .4-1 1s.4 1 1 1h14c.6 0 1-.4 1-1s-.4-1-1-1Zm-4 10H5c-.6 0-1 .4-1 1s.4 1 1 1h6c.6 0 1-.4 1-1s-.4-1-1-1Z"; const threeLinesButtonContainerQuery = `div:has(> button svg > path[d="${threeLinesSvgPath}"])`; info.pageType = "activity"; let anchor = document.querySelectorAll(threeLinesButtonContainerQuery); if (anchor.length !== 1) { return emptyInfo; } info.anchor = anchor[0]; info.readyPredicate = () => info.anchor.parentNode.children.length >= 1; } 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; } if (!pageInfo.readyPredicate || !pageInfo.readyPredicate()) { 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 CSS syntax in literal */ function css(str) { return str; } const stylesheet = new CSSStyleSheet(); stylesheet.insertRule(css` .export-csv-button:hover { color: rgb(50, 48, 47); background-image: linear-gradient( 0deg, rgba(0, 0, 0, 0.04) 0%, rgba(0, 0, 0, 0.04) 100% ); } `); stylesheet.insertRule(css` .export-csv-button { display: inline-flex; background: rgb(255, 255, 255); border: 1px solid rgb(228, 226, 225); border-radius: 4.5em; font-size: 16px; padding-left: 1em; padding-right: 1em; font-family: "FuturaPT-Demi"; font-weight: unset; } `); /** * Attaches button row to anchor element. Should be syncronous to avoid attaching row twice, because Mutex is not cool enough for JS? * * @param {PageInfo} pageInfo * @returns {void} */ function addButtons(pageInfo) { document.adoptedStyleSheets = [stylesheet]; let buttonRow = document.createElement("div"); buttonRow.id = exportCsvId; buttonRow.style.display = "flex"; buttonRow.style.alignItems = "baseline"; buttonRow.style.gap = "1em"; buttonRow.style.marginLeft = "auto"; let buttonRowText = document.createElement("span"); buttonRowText.innerText = "Export Transactions as CSV:"; buttonRow.appendChild(buttonRowText); const now = new Date(); const buttons = [ { text: "This Month", fromDate: new Date(now.getFullYear(), now.getMonth(), 1), }, { text: "Last 3 Months", fromDate: new Date(now.getFullYear(), now.getMonth() - 3, 1), }, { text: "All", fromDate: null, }, ]; for (const button of buttons) { let exportButton = document.createElement("button"); exportButton.innerText = button.text; exportButton.className = "export-csv-button"; exportButton.onclick = async () => { console.log("[csv-export] Fetching account details"); let accountsInfo = await accountFinancials(); let accountNicknames = accountsInfo.reduce((acc, v) => { acc[v.id] = v.nickname; return acc; }, {}); let transactions = []; console.log("[csv-export] Fetching transactions"); if (pageInfo.pageType === "account-details") { let pathParts = window.location.pathname.split("/"); accountIds = [pathParts[3]]; transactions = await activityList(accountIds, button.fromDate); } else if (pageInfo.pageType === "activity") { let params = new URLSearchParams(window.location.search); let ids_param = params.get("account_ids"); if (ids_param) { accountIds = ids_param.split(","); } else { accountIds = accountsInfo.map((acc) => acc.id); } transactions = await activityFeedItems(accountIds, button.fromDate); } let blobs = await transactionsToCsvBlobs(transactions, accountNicknames); saveBlobsToFiles(blobs, accountsInfo, button.fromDate); }; buttonRow.appendChild(exportButton); } let anchorParent = pageInfo.anchor.parentNode; anchorParent.insertBefore(buttonRow, pageInfo.anchor); anchorParent.style.gap = "1em"; pageInfo.anchor.style.marginLeft = "0"; let currencyToggle = anchorParent.querySelector( `div:has(> ul > li > button)`, ); if (currencyToggle) { // NOTE: Patch to currency toggle, for some reason it sets width="100%", and it's ugly for (const s of document.styleSheets) { for (const r of s.rules) { if ( currencyToggle.matches(r.selectorText) && r.style.width === "100%" ) { currencyToggle.classList.remove(r.selectorText.substring(1)); } } } // NOTE: Swap with currency toggle, just looks nicer buttonRow.parentNode.insertBefore(buttonRow, currencyToggle); } } /** * @typedef {Object} OauthCookie * @property {string} access_token * @property {string} identity_canonical_id */ /** * @returns {OauthCookie} */ function getOauthCookie() { let decodedCookie = decodeURIComponent(document.cookie).split(";"); for (let cookieKV of decodedCookie) { if (cookieKV.indexOf("_oauth2_access_v2") !== -1) { let [_, val] = cookieKV.split("="); return JSON.parse(val); } } return null; } /** * Subset of ActivityFeedItem type in GraphQL API * * @typedef {Object} Transaction * @property {string} accountId * @property {string} externalCanonicalId * @property {string} amount * @property {string} amountSign * @property {string} occurredAt * @property {string} type * @property {string} subType * @property {string?} eTransferEmail * @property {string?} eTransferName * @property {string?} assetSymbol * @property {string?} assetQuantity * @property {string?} aftOriginatorName * @property {string?} aftTransactionCategory * @property {string?} opposingAccountId * @property {string?} spendMerchant * @property {string?} billPayCompanyName * @property {string?} billPayPayeeNickname */ const activityFeedItemFragment = ` fragment Activity on ActivityFeedItem { accountId externalCanonicalId amount amountSign occurredAt type subType eTransferEmail eTransferName assetSymbol assetQuantity aftOriginatorName aftTransactionCategory aftTransactionType canonicalId currency identityId institutionName p2pHandle p2pMessage spendMerchant securityId billPayCompanyName billPayPayeeNickname redactedExternalAccountNumber opposingAccountId status strikePrice contractType expiryDate chequeNumber provisionalCreditAmount primaryBlocker interestRate frequency counterAssetSymbol rewardProgram counterPartyCurrency counterPartyCurrencyAmount counterPartyName fxRate fees reference } `; const fetchActivityListQuery = ` query FetchActivityList( $first: Int! $cursor: Cursor $accountIds: [String!] $types: [ActivityFeedItemType!] $subTypes: [ActivityFeedItemSubType!] $endDate: Datetime $securityIds: [String] $startDate: Datetime $legacyStatuses: [String] ) { activities( first: $first after: $cursor accountIds: $accountIds types: $types subTypes: $subTypes endDate: $endDate securityIds: $securityIds startDate: $startDate legacyStatuses: $legacyStatuses ) { edges { node { ...Activity } } pageInfo { hasNextPage endCursor } } } `; /** * API used by account specific activity view. * Seems like it's just outdated API, will use it just as safetyguard * * @returns {Promise<[Transaction]>} */ async function activityList(accountIds, startDate) { let transactions = []; let hasNextPage = true; let cursor = undefined; while (hasNextPage) { let respJson = await GM.xmlHttpRequest({ url: "https://my.wealthsimple.com/graphql", method: "POST", responseType: "json", headers: { "content-type": "application/json", authorization: `Bearer ${getOauthCookie().access_token}`, }, data: JSON.stringify({ operationName: "FetchActivityList", query: ` ${fetchActivityListQuery} ${activityFeedItemFragment} `, variables: { first: 100, cursor, startDate, endDate: new Date().toISOString(), accountIds, }, }), }); if (respJson.status !== 200) { throw `Failed to fetch transactions: ${respJson.responseText}`; } let resp = JSON.parse(respJson.responseText); let activities = resp.data.activities; hasNextPage = activities.pageInfo.hasNextPage; cursor = activities.pageInfo.endCursor; transactions = transactions.concat(activities.edges.map((e) => e.node)); } return transactions; } const fetchActivityFeedItemsQuery = ` query FetchActivityFeedItems( $first: Int $cursor: Cursor $condition: ActivityCondition $orderBy: [ActivitiesOrderBy!] = OCCURRED_AT_DESC ) { activityFeedItems( first: $first after: $cursor condition: $condition orderBy: $orderBy ) { edges { node { ...Activity } } pageInfo { hasNextPage endCursor } } } `; /** * API used by activity feed page. * @returns {Promise<[Transaction]>} */ async function activityFeedItems(accountIds, startDate) { let transactions = []; let hasNextPage = true; let cursor = undefined; while (hasNextPage) { let respJson = await GM.xmlHttpRequest({ url: "https://my.wealthsimple.com/graphql", method: "POST", responseType: "json", headers: { "content-type": "application/json", authorization: `Bearer ${getOauthCookie().access_token}`, }, data: JSON.stringify({ operationName: "FetchActivityFeedItems", query: ` ${fetchActivityFeedItemsQuery} ${activityFeedItemFragment} `, variables: { first: 100, cursor, condition: { startDate, accountIds, unifiedStatuses: ["COMPLETED"], }, }, }), }); if (respJson.status !== 200) { throw `Failed to fetch transactions: ${respJson.responseText}`; } let resp = JSON.parse(respJson.responseText); let activities = resp.data.activityFeedItems; hasNextPage = activities.pageInfo.hasNextPage; cursor = activities.pageInfo.endCursor; transactions = transactions.concat(activities.edges.map((e) => e.node)); } return transactions; } const fetchAllAccountFinancialsQuery = ` query FetchAllAccountFinancials( $identityId: ID! $pageSize: Int = 25 $cursor: String ) { identity(id: $identityId) { id accounts(filter: {}, first: $pageSize, after: $cursor) { pageInfo { hasNextPage endCursor } edges { cursor node { ...Account } } } } } fragment Account on Account { id unifiedAccountType nickname } `; /** * @typedef {Object} AccountInfo * @property {string} id * @property {string} nickname */ /** * Query all accounts * @returns {Promise<[AccountInfo]>} */ async function accountFinancials() { let oauthCookie = getOauthCookie(); let respJson = await GM.xmlHttpRequest({ url: "https://my.wealthsimple.com/graphql", method: "POST", responseType: "json", headers: { "content-type": "application/json", authorization: `Bearer ${oauthCookie.access_token}`, }, data: JSON.stringify({ operationName: "FetchAllAccountFinancials", query: fetchAllAccountFinancialsQuery, variables: { identityId: oauthCookie.identity_canonical_id, pageSize: 25, }, }), }); if (respJson.status !== 200) { throw `Failed to fetch account info: ${respJson.responseText}`; } let resp = JSON.parse(respJson.responseText); const self_directed_re = /^SELF_DIRECTED_(?<name>.*)/; let accounts = resp.data.identity.accounts.edges.map((e) => { let nickname = e.node.nickname; if (!nickname) { if (e.node.unifiedAccountType === "CASH") { nickname = "Cash"; } else if (self_directed_re.test(e.node.unifiedAccountType)) { let found = e.node.unifiedAccountType.match(self_directed_re); nickname = found.groups.name; if (nickname === "CRYPTO") { nickname = "Crypto"; } else if (nickname === "NON_REGISTERED") { nickname = "Non-registered"; } } else { nickname = "Unknown"; } } return { id: e.node.id, nickname, }; }); return accounts; } /** * @typedef {Object} TransferInfo * @property {string} id * @property {string} status * @property {{"bankAccount": BankInfo}} source * @property {{"bankAccount": BankInfo}} destination */ /** * @typedef {Object} BankInfo * @property {string} accountName * @property {string} accountNumber * @property {string} institutionName * @property {string} nickname */ const fetchFundsTransferQuery = ` query FetchFundsTransfer($id: ID!) { fundsTransfer: funds_transfer(id: $id, include_cancelled: true) { id status source { ...BankAccountOwner } destination { ...BankAccountOwner } } } fragment BankAccountOwner on BankAccountOwner { bankAccount: bank_account { id institutionName: institution_name nickname ...CaBankAccount ...UsBankAccount } } fragment CaBankAccount on CaBankAccount { accountName: account_name accountNumber: account_number } fragment UsBankAccount on UsBankAccount { accountName: account_name accountNumber: account_number } `; /** * @param {string} transferId * @returns {Promise<TransferInfo>} */ async function fundsTransfer(transferId) { let respJson = await GM.xmlHttpRequest({ url: "https://my.wealthsimple.com/graphql", method: "POST", responseType: "json", headers: { "content-type": "application/json", authorization: `Bearer ${getOauthCookie().access_token}`, }, data: JSON.stringify({ operationName: "FetchFundsTransfer", query: fetchFundsTransferQuery, variables: { id: transferId, }, }), }); if (respJson.status !== 200) { throw `Failed to fetch transfer info: ${respJson.responseText}`; } let resp = JSON.parse(respJson.responseText); return resp.data.fundsTransfer; } /** * @param {[Transaction]} transactions * @param {{[string]: string}} accountNicknames * @returns {Promise<{[string]: Blob}>} */ async function transactionsToCsvBlobs(transactions, accountNicknames) { let accTransactions = transactions.reduce((acc, transaction) => { const id = transaction.accountId; (acc[id] = acc[id] || []).push(transaction); return acc; }, {}); let accBlobs = {}; for (let acc in accTransactions) { accBlobs[acc] = await accountTransactionsToCsvBlob( accTransactions[acc], accountNicknames, ); } return accBlobs; } /** * @param {[Transaction]} transactions * @param {{[string]: string}} accountNicknames * @returns {Promise<Blob>} */ async function accountTransactionsToCsvBlob(transactions, accountNicknames) { let csv = `"Date","Payee","Notes","Category","Amount"\n`; for (const transaction of transactions) { let date = new Date(transaction.occurredAt); // JS Date type is absolutly horible, I hope Temporal API will be better let dateStr = `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`; let payee = null; let notes = null; let type = transaction.type; if (transaction.subType) { type = `${type}/${transaction.subType}`; } // Most transactions in Wealthsimple don't have category, skipping let category = ""; switch (type) { case "INTEREST": { payee = "Wealthsimple"; notes = "Interest"; break; } case "DEPOSIT/E_TRANSFER": { payee = transaction.eTransferEmail; notes = `INTERAC e-Transfer from ${transaction.eTransferName}`; break; } case "WITHDRAWAL/E_TRANSFER": { payee = transaction.eTransferEmail; notes = `INTERAC e-Transfer to ${transaction.eTransferName}`; break; } case "DIVIDEND/DIY_DIVIDEND": { payee = transaction.assetSymbol; notes = `Received dividend from ${transaction.assetSymbol}`; break; } case "DIY_BUY/DIVIDEND_REINVESTMENT": { payee = transaction.assetSymbol; notes = `Reinvested dividend into ${transaction.assetQuantity} ${transaction.assetSymbol}`; break; } case "DIY_BUY/MARKET_ORDER": { payee = transaction.assetSymbol; notes = `Bought ${transaction.assetQuantity} ${transaction.assetSymbol}`; break; } case "DEPOSIT/AFT": { payee = transaction.aftOriginatorName; notes = `Direct deposit from ${transaction.aftOriginatorName}`; category = transaction.aftTransactionCategory; break; } case "WITHDRAWAL/AFT": { payee = transaction.aftOriginatorName; notes = `Direct deposit to ${transaction.aftOriginatorName}`; category = transaction.aftTransactionCategory; break; } case "DEPOSIT/EFT": { let info = await fundsTransfer(transaction.externalCanonicalId); let bankInfo = info.source.bankAccount; payee = `${bankInfo.institutionName} ${bankInfo.nickname || bankInfo.accountName} ${bankInfo.accountNumber || ""}`; notes = `Direct deposit from ${payee}`; break; } case "WITHDRAWAL/EFT": { let info = await fundsTransfer(transaction.externalCanonicalId); let bankInfo = info.source.bankAccount; payee = `${bankInfo.institutionName} ${bankInfo.nickname || bankInfo.accountName} ${bankInfo.accountNumber || ""}`; notes = `Direct deposit to ${payee}`; break; } case "INTERNAL_TRANSFER/SOURCE": { payee = accountNicknames[transaction.opposingAccountId]; notes = `Internal transfer to ${payee}`; break; } case "INTERNAL_TRANSFER/DESTINATION": { payee = accountNicknames[transaction.opposingAccountId]; notes = `Internal transfer from ${payee}`; break; } case "SPEND/PREPAID": { payee = transaction.spendMerchant; notes = `Prepaid to ${payee}`; break; } case "WITHDRAWAL/BILL_PAY": { payee = transaction.billPayPayeeNickname; notes = `Bill payment to ${transaction.billPayCompanyName}`; category = "bill"; break; } default: { console.log( `[csv-export] ${dateStr} transaction [${type}] has unexpected type, skipping it. Please report on greasyforks.org for assistanse.`, ); console.log(transaction); continue; } } let amount = transaction.amount; if (transaction.amountSign === "negative") { amount = `-${amount}`; } let entry = `"${dateStr}","${payee}","${notes}","${category}","${amount}"`; csv += `${entry}\n`; } return new Blob([csv], { type: "text/csv" }); } /** * @param {{[string]: Blob}} accountBlobs * @param {[AccountInfo]} accountsInfo * @param {Date?} fromDate */ function saveBlobsToFiles(accountBlobs, accountsInfo, fromDate) { let accToName = accountsInfo.reduce((accum, info) => { accum[info.id] = info.nickname; return accum; }, {}); 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 = `Wealthsimple ${accToName[acc]} Transactions ${timeFrame}.csv`; link.style.display = "none"; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(blobUrl); } }