您需要先安装一个扩展,例如 篡改猴、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.0 // @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 >= 2; } 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 >= 2; } 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; } 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("Fetching account details"); let accountsInfo = await accountFinancials(); let transactions = []; console.log("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); 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"; } /** * @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 */ const activityFeedItemFragment = ` fragment Activity on ActivityFeedItem { accountId externalCanonicalId amount amountSign occurredAt type subType eTransferEmail eTransferName assetSymbol assetQuantity aftOriginatorName aftTransactionCategory } `; 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 * @returns {Promise<{[string]: Blob}>} */ async function transactionsToCsvBlobs(transactions) { 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]); } return accBlobs; } /** * @param {[Transaction]} transactions * @returns {Promise<Blob>} */ async function accountTransactionsToCsvBlob(transactions) { 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 "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 "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; default: console.log( `${dateStr} transaction [${type}] has unexpected type. Object logged below. Skipping`, ); 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); } }