Yodlee Virtual Subaccounts

Adds virtual subaccounts to Yodlee Moneycenter

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name           Yodlee Virtual Subaccounts
// @namespace      http://www.arthaey.com
// @description    Adds virtual subaccounts to Yodlee Moneycenter
// @include        https://moneycenter.yodlee.com/moneycenter/accountSummary.moneycenter.do*
// @include        https://moneycenter.yodlee.com/moneycenter/networth.moneycenter.do*
// @include        https://moneycenter.yodlee.com/moneycenter/dashboard.moneycenter.do*
// @version        1.3
//
// Backed up from http://userscripts.org/scripts/review/11674
// Last updated on 2007-09-19
// ==/UserScript==

/* HOW TO USE:
 *
 * By setting an account's caption/description with a specially formatted string,
 * you can have virtual subaccounts. The string format is:
 *
 *   My First Subaccount XX% $X,XXX.XX max;
 *    |                   |   |         |
 *    '-> name            |   |         `-> optional (see below)
 *                        |   |
 *                        |   `-> dollar goal
 *                        |
 *                        `-> percentage
 *
 * You can have have multiple subaccounts. You need to have either a percentage
 * or a specific dollar goal; you may have both, but that's optional. The
 * percentage limits the value of the subaccount to a fraction of the real
 * account's value. The dollar goal limits the value of the subaccount to a
 * set dollar amount.
 *
 * The "max" is optional. Without it, the subaccount's value will be the
 * minimum of its percentage and goal. With it, the value will be the maximum.
 *
 * The value of the real account is distributed among the virtual subaccounts
 * in order, trying to completely satisfy the first subaccount before
 * distributing any funds to the second subaccount, and so on. Keep this is
 * mind when you define the order of your subaccounts, especially if you use
 * the "max" setting.
 *
 * EXAMPLE:
 *
 *   Emergency Fund 50% $12,000; Laptop 25% $2000 max; Travel 20%; Other $100;
 *
 * CHANGELOG:
 *  v1.3 - added subaccounts to the Dashboard page's Net Worth module
 *  v1.2 - added subaccounts to the Net Worth Statement page
 *  v1.1 - updated to work with Yodlee 8.0
 *  v1.0 - initial release (subaccounts only on the Accounts Summary page)
 *
 */

window.addEventListener("load", function(){

    var DEBUG = false;

    /* UTILITY FUNCTIONS *****************************************************/

    function debug(msg) {
        if (DEBUG) console.log("DEBUG: " + msg);
    }

    /* Finds elements whose id matches the given regexp. */
    function getElementsByIdRegExp(regex, restrict) {
        var matchingElements = [];

        if (!regex) return matchingElements;
        //if (restrict != "id" && restrict != "class") restrict = null;

        var elements = document.getElementsByTagName("*");
        var element;

        for (var i = 0; i < elements.length; i++) {
            element = elements[i];
            if (element.id.match(regex)) {
                matchingElements.push(element);
            }
        }

        return matchingElements;
    }

    /*
     * Written by Jonathan Snook, http://www.snook.ca/jonathan
     * Add-ons by Robert Nyman, http://www.robertnyman.com
     */
    function getElementsByClassName(className, tag, elm){
        var testClass = new RegExp("(^|\\s)" + className + "(\\s|$)");
        var tag = tag || "*";
        var elm = elm || document;
        var elements = (tag == "*" && elm.all)? elm.all : elm.getElementsByTagName(tag);
        var returnElements = [];
        var current;
        var length = elements.length;
        for(var i=0; i<length; i++){
            current = elements[i];
            if(testClass.test(current.className)){
                returnElements.push(current);
            }
        }
        return returnElements;
    }

    // returns cents
    function stringToMoney(moneyStr) {
        if (!moneyStr) return null;

        // convert to string, if necessary
        if (!moneyStr.replace) {
            moneyStr = moneyStr.toString();
        }

        // remove any non-digit characters, excepting "."
        moneyStr = moneyStr.replace(/[^0-9.]/g, '');

        // add cents to even dollar amounts
        if (!moneyStr.match(/[.]/)) {
            moneyStr += ".00";
        }

        // convert to an integer amount of cents
        return Math.round(parseFloat(moneyStr) * 100);
    }

    function moneyToString(money) {
        var cents = Math.round(money);
        var even = (cents % 100 == 0);
        var moneyStr = new Number(Math.round(cents) / 100).toLocaleString();
        return "$" + moneyStr + (even ? ".00" : "");
    }

    String.prototype.trim = function() { return this.replace(/^\s+|\s+$/, ''); };

    /* VIRTUAL SUBACCOUNTS OBJECT ********************************************/

    const Site = new Object();

    Site.BASE_URL        = "https://moneycenter.yodlee.com/moneycenter/";
    Site.ACCOUNT_SUMMARY = Site.BASE_URL + "accountSummary.moneycenter.do";
    Site.NET_WORTH       = Site.BASE_URL + "networth.moneycenter.do";
    Site.DASHBOARD       = Site.BASE_URL + "dashboard.moneycenter.do";

    Site.page = null;

    Site.determinePage = function() {
        var url = window.location.href;
        var pages = [Site.ACCOUNT_SUMMARY, Site.NET_WORTH, Site.DASHBOARD];
        for (var i in pages) {
            var page = pages[i];
            if (url.match('^' + page)) {
                Site.page = page;
                break;
            }
        }
        debug("Site.page == " + Site.page);
    }

    const Subaccounts = new Object();

    Subaccounts.all = [];

    Subaccounts.parseCaption = function(fullCaption, parentAccount) {
        var name, percent, goal;
        name = percent = goal = null;

        fullCaption = fullCaption.trim();
        var captions = fullCaption.split(";");
        var matches, subaccount;
        var thisParse = [];

        for (var i = 0; i < captions.length; i++) {
            matches = captions[i].match(SUBACCOUNTS);
            if (matches) {
                name = matches[NAME_NDX];

                goal = matches[GOAL_NDX] || matches[GOAL_ONLY_NDX];
                if (goal) { goal = stringToMoney(goal); }

                percent = matches[PERCENT_NDX] || matches[PERCENT_ONLY_NDX];
                if (percent) { percent /= 100; }

                subaccount = new Subaccount(name, percent, goal, parentAccount);

                modifiers = matches[MODIFIERS_NDX];
                if (modifiers == "max") { subaccount.max = true };

                thisParse.push(subaccount);
                this.all.push(subaccount);
            }
        }

        return thisParse;
    };

    function Subaccount(name, percent, goal, parentAccount) {
        this.name = name;
        this.percent = percent;
        this.goal = goal;
        this.parentAccount = parentAccount;
        this.amount = null;
        this.max = false;

        this.toString = function() {
            return this.name + " " + moneyToString(this.amount);
        };

        this.settingsHTML = function() {
            var content;
            var html = document.createElement("span");

            if (!this.percent && !this.goal) return html;

            if (this.percent) {
                var percent = (this.percent * 100) + "%";
                if (this.amount >= this.percent * this.parentAccount.amount) {
                    content = document.createElement("b");
                    content.appendChild(document.createTextNode(percent));
                }
                else {
                    content = document.createTextNode(percent);
                }
                html.appendChild(content);
            }

            if (this.percent && this.goal) {
                content = (this.max ? " or " : " only ");
                html.appendChild(document.createTextNode(content));
            }

            if (this.goal) {
                if (this.amount >= this.goal) {
                    content = document.createElement("b");
                    content.appendChild(document.createTextNode(
                        moneyToString(this.goal)));
                    html.appendChild(document.createTextNode("up to "));
                    html.appendChild(content);
                }
                else {
                    var goal = "up to " + moneyToString(this.goal);
                    html.appendChild(document.createTextNode(goal));
                }
            }

            return html;
        };
    }

    const Accounts = new Object();

    Accounts.parseAmount = function(tableRow) {
        var cellNdx;
        switch (Site.page) {
            case Site.ACCOUNT_SUMMARY:
                cellNdx = 2;
                break;
            case Site.NET_WORTH:
            case Site.DASHBOARD:
                cellNdx = 1;
                break;
            default:
                return null;
        }
        var amountTD = tableRow.getElementsByTagName("td")[cellNdx];
        return stringToMoney(amountTD.textContent);
    };

    function Account(name) {
        this.name = name;
        this.subaccounts = null;
        this.captionDiv = null;
        this.amount = 0;
        this.amountUnassigned = 0;

        this.toString = function() {
            return this.name + " (" + this.subaccounts.length + " subaccounts)";
        };

        this.addSubaccountRows = function() {
            if (this.captionDiv == null) return;

            // create a fake subaccount for all unassigned, "leftover" money
            this.distributeFunds();
            var unassigned = new Subaccount("Unassigned");
            unassigned.amount = this.amountUnassigned;
            var subaccounts = Array.concat(this.subaccounts, [unassigned]);

            var subaccount, row, nameCell, amountCell, settingsCell;
            var subaccountTable = document.createElement("table");

            // create table headers
            row = document.createElement("tr");
            nameHeader = document.createElement("th");
            amountHeader = document.createElement("th");
            settingsHeader = document.createElement("th");

            nameHeader.appendChild(document.createTextNode("Subaccount"));
            amountHeader.appendChild(document.createTextNode("Value"));
            settingsHeader.appendChild(document.createTextNode("Settings"));

            row.appendChild(nameHeader);
            row.appendChild(amountHeader);
            row.appendChild(settingsHeader);
            subaccountTable.appendChild(row);

            // create row for each subaccount
            for (var i = 0; i < subaccounts.length; i++) {
                subaccount = subaccounts[i];
                row = document.createElement("tr");
                nameCell = document.createElement("td");
                amountCell = document.createElement("td");
                settingsCell = document.createElement("td");

                nameCell.appendChild(document.createTextNode(subaccount.name));
                amountCell.appendChild(document.createTextNode(
                    moneyToString(subaccount.amount)));
                settingsCell.appendChild(subaccount.settingsHTML());

                nameCell.style.width = "100%";
                if (subaccount.name == "Unassigned") {
                    nameCell.style.fontStyle = "italic";
                }
                amountCell.style.textAlign = "right";
                amountCell.style.whiteSpace = "nowrap";
                settingsCell.style.whiteSpace = "nowrap";

                row.appendChild(nameCell);
                row.appendChild(amountCell);
                row.appendChild(settingsCell);
                subaccountTable.appendChild(row);
            }

            // add new subaccounts table and remove the original caption
            this.captionDiv.parentNode.insertBefore(
                subaccountTable, this.captionDiv.nextSibling);
            this.captionDiv.parentNode.removeChild(this.captionDiv);
            this.captionDiv = null;
        };

        this.distributeFunds = function() {
            var amountLeft = this.amount;
            var subaccount, amount;

            for (var i = 0; i < this.subaccounts.length; i++) {
                subaccount = this.subaccounts[i];
                amount = null;

                if (subaccount.max) {
                    var want = Math.max(subaccount.percent * this.amount, subaccount.goal);
                    amount = Math.min(want, amountLeft);
                }
                else {
                    if (subaccount.percent) {
                        amount = Math.min(subaccount.percent * this.amount, amountLeft);
                    }
                    if (subaccount.goal) {
                        amount = Math.min(subaccount.goal,
                            (subaccount.percent ? amount : amountLeft));
                    }
                }

                amountLeft -= amount;
                subaccount.amount = amount;
            }

            this.amountUnassigned = amountLeft;
        };
    }

    /* VIRTUAL SUBACCOUNTS REGULAR EXPRESSIONS *******************************/

    // name (maybe multi-word), not followed by '%', followed by whitespace
    const NAME = "(\\w+(?:\\s+\\w+)*)(?!%)(?=\\s+)";

    // numbers, followed by '%'
    const PERCENT = "(\\d+)(?:%)";

    // '$', followed by numbers (maybe comma-separated), maybe with cents
    const GOAL = "[$]((?:\\d{1,3},?)*\\d{1,3}(?:[.]\\d{2})?)";

    // both PERCENT and GOAL, or just one or the other
    const PERCENT_AND_OR_GOAL = "(?:" + PERCENT + "\\s+" + GOAL + "|" +
                                PERCENT + "|" + GOAL + ")";

    const MODIFIERS = "(max)?";

    // optional whitespace
    const WS = "\\s*";

    // subaccount is "NAME PERCENT GOAL"; one of PERCENT or GOAL can be optional
    const SUBACCOUNT = WS + NAME + WS + PERCENT_AND_OR_GOAL + WS + MODIFIERS + WS;

    // whole string is a series of subaccounts, separated by semicolons or EOL
    const SUBACCOUNTS = "^(?:" + SUBACCOUNT + "(?:;|$))+";

    // indices for array returned by match(SUBACCOUNT)
    const ENTIRE_MATCH_NDX = 0;
    const NAME_NDX         = 1;
    const PERCENT_NDX      = 2;
    const GOAL_NDX         = 3;
    const PERCENT_ONLY_NDX = 4;
    const GOAL_ONLY_NDX    = 5;
    const MODIFIERS_NDX    = 6;

    /* VIRTUAL SUBACCOUNTS FUNCTIONS *****************************************/

    var MAX_TRIES = 3;
    var numTries = 0;

    function createSubaccounts() {
        var accountsWithVirtualSubaccounts = [];

        var accountsTable;
        switch (Site.page) {
            case Site.DASHBOARD:
                var div = document.getElementById("net_worth_module_dynamic");
                accountsTable = getElementsByClassName("datatable", "table", div)[0];
                // the dynamic Net Worth module on the dashboard can take a
                // while to load, so we'll try again later.
                if (!accountsTable && numTries++ < MAX_TRIES) {
                    debug("Expected table not found yet. Will try to create subaccounts " +
                          (MAX_TRIES - numTries) + " more times...");
                    window.setTimeout(doVirtualSubaccounts, 2000);
                    return;
                }
                break;
            case Site.ACCOUNT_SUMMARY:
            case Site.DASHBOARD:
                accountsTable = document.getElementById("accntsummary");
                break;
            default:
                return;
        }

        var accounts = getElementsByClassName("lcell", "td", accountsTable);
        if (!accounts) return;
        debug(accounts.length + " accounts found, total");

        var accountTD, name, captionDivs, caption, subaccount;
        var pattern = new RegExp(SUBACCOUNTS);

        for (var i = 0; i < accounts.length; i++) {
            accountTD = accounts[i];
            name = parseAccountName(accountTD);
            debug("Account " + i + " name: " + name);
            captionDivs = getElementsByClassName("caption", "span", accountTD);

            if (captionDivs.length > 0) {
                // if the caption is formatted as required, assume it's meant
                // to be a virtual subaccount used by this Greasemonkey script
                caption = captionDivs[0].innerHTML.trim();
                debug("Account " + i + " caption: " + caption);
                if (pattern.test(caption)) {
                    account = new Account(name);
                    account.captionDiv = captionDivs[0];
                    account.amount = Accounts.parseAmount(accountTD.parentNode);
                    // if we couldn't parse the amount, then skip this account,
                    // even though its description matches the correct format
                    if (!account.amount) {
                        debug("Could not create subaccounts for this account.");
                        continue;
                    }
                    account.subaccounts = Subaccounts.parseCaption(caption, account);
                    accountsWithVirtualSubaccounts.push(account);
                }
            }
        }

        return accountsWithVirtualSubaccounts;
    }

    function parseAccountName(accountTD) {
        var links = accountTD.getElementsByTagName("a");

        if (!links) return null;
        var nameLink = links[0];
        if (!nameLink) return null;

        return nameLink.textContent.trim().replace(/(\n|\r)+/g, '');
    }

    function prettifyCaptions(captionDiv) {
        captionDiv.innerHTML = null;
    }

    function doVirtualSubaccounts() {
        var accounts = createSubaccounts();
        if (!accounts) return;

        for (var i = 0; i < accounts.length; i++ ) {
            accounts[i].addSubaccountRows();
        }
    }

    Site.determinePage();
    doVirtualSubaccounts();

}, true);