您需要先安装一个扩展,例如 篡改猴、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.0 // @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 * @returns {Promise<[Transaction]>} */ async function creditTransactions(accountId) { 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: [], 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 * @returns {Promise<[Transaction]>} */ async function savingsTransactions(accountId) { 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: [], 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(); // Insert decimal separator into a string to avoid any shenanigans with floating point numbers let amount = amountCentsStr.substring(0, amountCentsStr.length - 2) + "." + amountCentsStr.substring(amountCentsStr.length - 2); 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 downloadButtonId = "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%", }; /** * @callback TransactionCallback * @returns {Promise<[Transaction]>} */ /** * Inserts a CSV export button next to transaction filters button * * @param {Element?} filtersElement * @param {Funcion} buttonCallback */ function addDownloadButton(filtersElement, buttonCallback) { if (filtersElement === null || buttonCallback === null) { return; } let exportButton = document.createElement("button"); exportButton.innerText = "Export transactions as CSV"; exportButton.onclick = buttonCallback; Object.assign(exportButton.style, buttonStyle); let exportButtonBox = document.createElement("div"); exportButtonBox.className = "MuiBox-root"; exportButtonBox.appendChild(exportButton); exportButtonBox.id = downloadButtonId; filtersElement.insertBefore(exportButtonBox, filtersElement.children[0]); filtersElement.style.alignItems = "center"; filtersElement.style.gap = "1em"; } /** * 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 {string} accountName - Prefix to output file * @param {TransactionCallback?} transactionCallback - Async function that will return array of transactions * @return {Function} */ function saveBlobToFileCallback(accountName, transactionCallback) { return async () => { console.log("Fetching Transactions"); let blob = transactionsToCsvBlob(await transactionCallback()); console.log("Writing transactions into a file"); let blobUrl = URL.createObjectURL(blob); let now = new Date(); let link = document.createElement("a"); link.href = blobUrl; link.download = `Neo ${accountName} Transactions ${now.getFullYear()}-${now.getMonth() + 1}-${now.getDate()}.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 * @property {Function?} buttonCallback */ /** * @returns {Promise<PageInfo>} */ async function detectPageType() { /** * @type {PageInfo} */ let pageInfo = { isTransactionsPage: false, transactionFiltersQuery: null, buttonCallback: 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.buttonCallback = saveBlobToFileCallback( // Looks like credit account cannot have custom name, hardcoding it "Credit", async () => await creditTransactions(accountId), ); } else if (accountType === "savings") { let accountName = await savingsAccountName(accountId); pageInfo.transactionFiltersQuery = `main > div.MuiBox-root`; pageInfo.buttonCallback = saveBlobToFileCallback( accountName, async () => await savingsTransactions(accountId), ); } 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#${downloadButtonId}`)) { 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#${downloadButtonId}`)) { return; } addDownloadButton(transactionFilters, pageInfo.buttonCallback); } (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(); }); })();