您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Adds a button to transactions page that exports all transactions into a CSV file. Developed for use with "Actual" budgeting tool, will probably work fine with any other importer.
当前为
// ==UserScript== // @name NeoFinancial export transactions as CSV // @namespace Violentmonkey Scripts // @match https://member.neofinancial.com/* // @grant GM.xmlHttpRequest // @version 1.1 // @license MIT // @author eaglesemanation // @description Adds a button to transactions page that exports all transactions into a CSV file. Developed for use with "Actual" budgeting tool, will probably work fine with any other importer. // ==/UserScript== /** * Type returned by GraphQL api for credit transactions * * @typedef {Object} Transaction * @property {string} description * @property {string} currency * @property {string} type * @property {string} status * @property {string} category * @property {number} amountCents * @property {string} authorizationProcessedAt * @property {MerchantDetails?} merchantDetails * @property {SourceInformation?} sourceInformation * @property {string?} transferContactName * @property {string?} etransferContactName * @property {string?} billPayVendorName */ /** * @typedef {Object} MerchantDetails * @property {string} description * @property {string} category */ /** * @typedef {Object} SourceInformation * @property {string} friendlyName */ /** * GraphQL query for credit account transactions */ const creditTransactionQuery = ` query TransactionsList($input: CursorQueryInput!, $creditAccountId: ObjectID!) { user { creditAccount(id: $creditAccountId) { creditTransactionList(input: $input) { cursor hasNextPage results { description currency type status category merchantDetails { description category } sourceInformation { friendlyName } amountCents authorizationProcessedAt } } } } } `; /** * @param {string} accountId - credit account ID * @param {[any]?} filters * @returns {Promise<[Transaction]>} */ async function creditTransactions(accountId, filters) { if (!filters) { filters = []; } let transactions = []; let hasNextPage = true; let cursor = undefined; while (hasNextPage) { let respJson = await GM.xmlHttpRequest({ url: "https://api.production.neofinancial.com/graphql", method: "POST", responseType: "json", headers: { "content-type": "application/json", }, data: JSON.stringify({ operationName: "TransactionsList", query: creditTransactionQuery, variables: { creditAccountId: accountId, input: { cursor: cursor, filter: filters, limit: 1000, sort: { direction: "DESC", field: "authorizationProcessedAt", }, }, }, }), }); let resp = JSON.parse(respJson.responseText); let transactionList = resp.data.user.creditAccount.creditTransactionList; hasNextPage = transactionList.hasNextPage; cursor = transactionList.cursor; transactions = transactions.concat(transactionList.results); } return transactions; } /** * GraphQL query for savings account transactions */ const savingsTransactionQuery = ` fragment SavingsTransactionPurchaseFragment on SavingsTransactionPurchase { merchantDetails { description category } redemptions { totalRedeemed totalCount } dispute { id status } } fragment SavingsTransactionFundsTransferFragment on SavingsTransactionFundsTransfer { etransferContactName: transferContactName } fragment SavingsTransactionETransferFragment on SavingsTransactionETransfer { transferContactName } fragment SavingsTransactionBillPaymentFragment on SavingsTransactionBillPayment { billPayVendorName } fragment SavingsTransactionFeeFragment on SavingsTransactionFee { parentTransactionId } query FilteredSortedSavingsTransactionList($input: CursorQueryInput!, $savingsAccountId: ObjectID!) { user { savingsAccount(id: $savingsAccountId) { savingsTransactionList(input: $input) { cursor hasNextPage results { id amountCents authorizationProcessedAt category currency description type status completedAt ...SavingsTransactionPurchaseFragment ...SavingsTransactionFundsTransferFragment ...SavingsTransactionETransferFragment ...SavingsTransactionBillPaymentFragment ...SavingsTransactionFeeFragment } } } } } `; /** * @param {string} accountId - savings account ID * @param {[any]?} filters * @returns {Promise<[Transaction]>} */ async function savingsTransactions(accountId, filters) { if (!filters) { filters = []; } let transactions = []; let hasNextPage = true; let cursor = undefined; while (hasNextPage) { let respJson = await GM.xmlHttpRequest({ url: "https://api.production.neofinancial.com/graphql", method: "POST", responseType: "json", headers: { "content-type": "application/json", }, data: JSON.stringify({ operationName: "FilteredSortedSavingsTransactionList", query: savingsTransactionQuery, variables: { savingsAccountId: accountId, input: { cursor: cursor, filter: filters, limit: 1000, sort: { direction: "DESC", field: "authorizationProcessedAt", }, }, }, }), }); let resp = JSON.parse(respJson.responseText); let transactionList = resp.data.user.savingsAccount.savingsTransactionList; hasNextPage = transactionList.hasNextPage; cursor = transactionList.cursor; transactions = transactions.concat(transactionList.results); } return transactions; } /** * GraphQL query for figuring out custom name of savings account */ const accountPersonalizationQuery = ` query SavingsAccountPersonalization($savingsAccountId: ObjectID!) { user { savingsAccount(id: $savingsAccountId) { accountPersonalization { customizedName } } } } `; /** * @param {string} accountId - savings account ID * @returns {Promise<string>} */ async function savingsAccountName(accountId) { let respJson = await GM.xmlHttpRequest({ url: "https://api.production.neofinancial.com/graphql", method: "POST", responseType: "json", headers: { "content-type": "application/json", }, data: JSON.stringify({ operationName: "SavingsAccountPersonalization", query: accountPersonalizationQuery, variables: { savingsAccountId: accountId, }, }), }); let resp = JSON.parse(respJson.responseText); return resp.data.user.savingsAccount.accountPersonalization.customizedName; } /** * @param {[Transaction]} transactions * @returns {Blob} */ function transactionsToCsvBlob(transactions) { let csv = `"Date","Payee","Notes","Category","Amount"\n`; for (const transaction of transactions) { let date = new Date(transaction.authorizationProcessedAt); // 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 category = transaction.category; // Assume that transaction is a purchase by default let amountCents = -transaction.amountCents; switch (transaction.category) { case "PURCHASE": payee = transaction.merchantDetails.description; category = transaction.merchantDetails.category; break; case "NEO_STORE_PURCHASE": payee = "Neo Financial"; category = "PURCHASE"; break; case "REWARDS_ACCOUNT_CASH_OUT": payee = "Neo Financial"; category = "PAYMENT"; amountCents = transaction.amountCents; break; case "REFUND": payee = transaction.merchantDetails.description; category = transaction.merchantDetails.category; amountCents = transaction.amountCents; break; case "PAYMENT": payee = transaction.sourceInformation.friendlyName; amountCents = transaction.amountCents; break; case "WITHDRAWAL": if (transaction.description.search(/Payment to Credit/) !== -1) { payee = "Neo Credit"; } else if (transaction.merchantDetails) { payee = transaction.merchantDetails.description; category = transaction.merchantDetails.category; } else { payee = transaction.transferContactName; } break; case "TRANSFER": if (transaction.transferContactName) { payee = transaction.transferContactName; } else if (transaction.etransferContactName) { payee = transaction.etransferContactName; } amountCents = transaction.amountCents; break; case "INTEREST": payee = "Neo Financial"; category = "PAYMENT"; amountCents = transaction.amountCents; break; case "DEPOSIT": if (transaction.description.search(/Reward Cashed Out/) !== -1) { payee = "Neo Financial"; category = "PAYMENT"; } amountCents = transaction.amountCents; break; default: console.log( `${dateStr} transaction [${transaction.category}] has unexpected category. Object logged below. Skipping`, ); console.log(transaction); continue; } if (!payee) { console.log( `${dateStr} transaction [${transaction.category}] could not figure out payee. Object logged below. Skipping`, ); console.log(transaction); continue; } let amountCentsStr = amountCents.toString(); let amount = ""; // Insert decimal separator into a string to avoid any shenanigans with floating point numbers if (amountCentsStr.length > 2) { amount = amountCentsStr.substring(0, amountCentsStr.length - 2) + "." + amountCentsStr.substring(amountCentsStr.length - 2); } else { amount = `0.${amountCentsStr}`; } let notes = transaction.description; let entry = `"${dateStr}","${payee}","${notes}","${category}","${amount}"`; // Transaction is not affecting balance, skipping if (!["CONFIRMED", "AUTHORIZED"].includes(transaction.status)) { // Catching unhandled status values. if (!["DECLINED"].includes(transaction.status)) { console.log( `${dateStr} transaction [${transaction.category}] from "${payee}" has unexpected status: ${transaction.status}. Object logged below. Skipping`, ); console.log(transaction); } continue; } csv += `${entry}\n`; } return new Blob([csv], { type: "text/csv" }); } // ID for quickly verifying if button was already injected const exportCsvId = "export-transactions-csv"; /** * Copied style of a credit payment button * * @type {CSSStyleDeclaration} */ const buttonStyle = { display: "inline-flex", alignItems: "center", justifyContent: "center", position: "relative", boxSizing: "border-box", backgroundColor: "transparent", outline: "0", border: "0", margin: "0", borderRadius: "4px", padding: "0rem 1rem", cursor: "pointer", userSelect: "none", verticalAlign: "middle", color: "inherit", fontFamily: `TTCommons, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif`, fontWeight: "600", fontSize: "1rem", lineHeight: "1.25", letterSpacing: "0.02857em", minWidth: "64px", borderRadius: "4px", backgroundColor: "#EDEEEF", color: "#000000", height: "32px", width: "100%", }; /** * Inserts a CSV export button next to transaction filters button * * @param {Element?} filtersElement * @param {AccountInfo?} accountInfo */ function addDownloadButtons(filtersElement, accountInfo) { if (!filtersElement || !accountInfo) { return; } let csvExportRow = document.createElement("div"); csvExportRow.id = exportCsvId; csvExportRow.style.display = "flex"; csvExportRow.style.alignItems = "center"; csvExportRow.style.gap = "1em"; let csvExportText = document.createElement("div"); csvExportText.innerText = "Export as CSV:"; csvExportText.style.fontFamily = buttonStyle.fontFamily; csvExportText.style.fontWeight = "400"; csvExportRow.appendChild(csvExportText); 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 filters = []; if (button.fromDate) { filters = [ { field: "authorizationProcessedAt", operator: "GTE", type: "DATE", value: button.fromDate.toISOString(), }, ]; } let exportButton = document.createElement("button"); exportButton.innerText = button.text; exportButton.onclick = saveBlobToFileCallback( accountInfo, filters, button.fromDate, ); Object.assign(exportButton.style, buttonStyle); let exportButtonBox = document.createElement("div"); exportButtonBox.className = "MuiBox-root"; exportButtonBox.appendChild(exportButton); csvExportRow.appendChild(exportButtonBox); } filtersElement.insertBefore(csvExportRow, filtersElement.children[0]); filtersElement.style.alignItems = "center"; filtersElement.style.justifyContent = "space-between"; filtersElement.style.gap = "1em"; } /** * @callback TransactionCallback * @param {string} accountId * @param {[any]} filters * @returns {Promise<[Transaction]>} */ /** * Creates a wraper function that calls to transaction callback, then downloads resulting blob as a file by * injecting anchor element into a body, clicking it and removing it. * * @param {AccountInfo} accountInfo * @param {[any]?} filters * @param {Date?} fromDate * @return {Function} */ function saveBlobToFileCallback(accountInfo, filters, fromDate) { if (!filters) { filters = []; } return async () => { console.log("Fetching Transactions"); let blob = transactionsToCsvBlob( await accountInfo.transactionsCallback(accountInfo.id, filters), ); console.log("Writing transactions into a file"); let blobUrl = URL.createObjectURL(blob); 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 = `Neo ${accountInfo.name} Transactions ${timeFrame}.csv`; link.style.display = "none"; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(blobUrl); }; } /** * Identifies if current page is a transactions page and returns apropriate button callback * * @typedef {Object} PageInfo * @property {boolean} isTransactionsPage * @property {string?} transactionFiltersQuery - CSS query for a place where to put a button * @property {AccountInfo?} accountInfo */ /** * @typedef {Object} AccountInfo * @property {string} id * @property {string} name * @property {"credit"|"savings"} type * @property {TransactionCallback} transactionsCallback */ /** * @returns {Promise<PageInfo>} */ async function detectPageType() { /** * @type {PageInfo} */ let pageInfo = { isTransactionsPage: false, transactionFiltersQuery: null, accountInfo: null, }; let pathParts = window.location.pathname.split("/"); if (pathParts[pathParts.length - 1] !== "transactions") { return pageInfo; } else { pageInfo.isTransactionsPage = true; } let accountsIdx = pathParts.findIndex((v) => v === "accounts"); let accountType = pathParts[accountsIdx + 1]; let accountId = pathParts[accountsIdx + 2]; // Handling different types of accounts if (accountType === "credit") { pageInfo.transactionFiltersQuery = `div[data-sentry-source-file="transactions-filters.view.tsx"]`; pageInfo.accountInfo = { id: accountId, type: accountType, // Looks like credit account cannot have custom name, hardcoding it name: "Credit", transactionsCallback: creditTransactions, }; } else if (accountType === "savings") { pageInfo.transactionFiltersQuery = `main > div.MuiBox-root`; pageInfo.accountInfo = { id: accountId, type: accountType, name: await savingsAccountName(accountId), transactionsCallback: savingsTransactions, }; } return pageInfo; } /** * 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 = await detectPageType(); if (!pageInfo.isTransactionsPage) { return; } const transactionFilters = document.querySelector( pageInfo.transactionFiltersQuery, ); if (!transactionFilters) { return; } // Intentional duplicate, avoiding race condidion on detectPageType call if (document.querySelector(`div#${exportCsvId}`)) { return; } addDownloadButtons(transactionFilters, pageInfo.accountInfo); } (async function() { // Keeping track of DOM modifications to detect when "Transactions Filter" button will reappear 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(); }); })();