UNPKG

banker

Version:

Downloads transactions and balances from online banking websites using Zombie.js.

368 lines (334 loc) 11.8 kB
#!/usr/bin/env node var async = require('async'), fs = require('fs'), mkdirp = require('mkdirp'), numeral = require('numeral'), path = require('path'), S = require('string'), util = require('util'), wordwrap = require('wordwrap'); JSON.stringifyAligned = require('json-align'); var program = require('yargs') .usage('Usage: $0 options') .wrap(80) .option('b', { alias : 'banks', describe : 'Just list the banks that this program can download information from.', type : 'boolean' }) .option('l', { alias : 'list', describe : 'Just list the banks and accounts in the given config file.', type : 'boolean' }) .option('describe-output', { describe : 'Just list the banks and accounts in the given output file.', type : 'string' }) .option('c', { alias : 'config', describe : 'The JSON config file(s) describing the banks and accounts to process.', type : 'string' }) .option('o', { alias : 'output', describe : 'The JSON output file with transaction details.', type : 'string' }) .option('s', { alias : 'skip', describe : 'Skip the specified bank(s) or account(s).', type : 'string' }) .option('only', { describe : 'Only process the specified bank(s) or account(s) - see --list for valid specifiers.', type : 'string' }) .option('d', { alias : 'debug-browser', describe : 'Save pages fetched by the browser (to the same directory as the output file).', type : 'boolean' }) .option('v', { alias : 'verbose', type : 'count', describe : 'Increase verbosity (up to -vvv).' }); var argv = program.argv; ['skip', 'only'].forEach(function(prop) { if (argv[prop] && !Array.isArray(argv[prop].length)) { argv[prop] = argv[prop].split(','); } }); function shouldSkip(name) { if (argv.skip && argv.skip.indexOf(name) > -1) { return true; } if (argv.only && argv.only.indexOf(name) == -1) { var skip = true; argv.only.forEach(function(o) { if (/:/.test(o)) { // Include the given account's bank if (o.split(':')[0] == name) { skip = false; } // Only include the given account if (o == name) { skip = false; } } else { // Include all accounts from the given bank if (o == name.split(':')[0]) { skip = false; } } }); return skip; } return false; } if (argv.banks) { console.log('Banks available for processing:'); var files = fs.readdirSync(path.join(__dirname, '..', 'lib', 'banks')), counts = { total : 0, shown : 0 }; files.sort(); files.forEach(function(fn) { var name = fn.replace(/\.js$/, ''); if (name != fn && fn != 'base.js') { counts.total++; if (!shouldSkip(name)) { counts.shown++; console.log(); console.log('Bank: ' + name); try { var info = require('../lib/banks/' + name).prototype.info(); console.log(' Description: ' + (info.description || '(missing bank description)')); [ { key : 'configItems' , desc : 'Config items' }, { key : 'accountItems', desc : 'Per-account config items' } ].forEach(function(s) { var props = Object.keys(info[s.key] || {}); if (props.length) { console.log(' %s:', s.desc); props.forEach(function(k) { console.log(' "%s" : %s', k, info[s.key][k]); }); } }); } catch (err) { console.log(' ' + err.message); } } } }); if (counts.shown < counts.total) { console.log(); console.log('(%d of %d bank%s shown)', counts.shown, counts.total, (counts.total == 1 ? '' : 's')); } process.exit(0); } if (argv.describeOutput) { var output = JSON.parse(fs.readFileSync(argv.describeOutput)); output.forEach(function(bank) { var msg = (bank.error || bank.status); if (msg) { console.log('Bank %s:', bank.bank); console.log(wordwrap(2, 60)(msg)); console.log(); } else { console.log('Bank %s: %d account%s', bank.bank, bank.data.length, (bank.data.length == 1 ? '' : 's')); bank.data.forEach(function(account) { var msg = (account.error || account.status), a = account.account, name = (a.filename || a.description || a.name || a.code || JSON.stringify(a)); if (msg) { console.log(' Account %s:', name); console.log(wordwrap(4, 60)(msg)); } else { console.log(' Account %s: %d transaction%s', name, account.transactions.length, (account.transactions.length == 1 ? '' : 's')); if (account.balances) { var keys = Object.keys(account.balances); keys.sort(); var maxLen = Math.max.apply(null, keys.map(function(k) { return k.length; })); keys.forEach(function(k) { console.log(' balance: %s = %s', S(k).padRight(maxLen).s, numeral(account.balances[k]).format('0,0.00')); }); } } console.log(); }); } }); process.exit(0); } if (!argv.config) { program.showHelp(); console.error("Config filename is required unless '--banks' is specified."); process.exit(1); } var config = [].concat.apply([], [].concat(argv.config).map(function(fn) { return JSON.parse(fs.readFileSync(fn)); })); if (argv.list) { config.forEach(function(bank) { var skipBank = shouldSkip(bank.bankName); console.error('bank: ' + bank.bankName + (skipBank ? ' (SKIP)' : '')); if (!skipBank) { var i = 0; bank.accounts.forEach(function(account) { var accountSpec = bank.bankName + ':' + (++i), skipAccount = shouldSkip(accountSpec); console.error(' account: %s%s', (account.filename || account.description || account.name || account.code || JSON.stringify(account)), (skipAccount ? ' (SKIP)' : '')); console.error(' spec: ' + accountSpec); }); } }); process.exit(0); } if (!argv.output) { program.showHelp(); console.error("Output filename is required unless '--banks' or '--list' is specified."); process.exit(1); } var outputDir = path.dirname(argv.output); mkdirp.sync(outputDir); if (argv.verbose >= 3) { if (process.env.NODE_DEBUG) { process.env.NODE_DEBUG += ',request'; } else { process.env.NODE_DEBUG = 'request'; } } // Zombie.js seems to have problems running multiple instances in parallel. I // kept getting an empty-looking DOM and a weird error here: // // var link = self.browser.link('Export'); // Cannot use 'in' operator to search for 'compareDocumentPosition' in null async.mapSeries(config, function(bank, nextBank) { if (shouldSkip(bank.bankName)) { nextBank(null, { bank : bank.bankName, status : 'skipped' }); return; } if (argv.debugBrowser) { bank.debugBrowser = true; } // Delay require until needed, this loads a bunch of stuff var BankSession = require('../lib/banks/base'); var session = BankSession.create(bank); session.on('log', console.error); var pageFilenameBase = null; session.on('debug', function(type, value) { if (type == 'browser') { ['raw', 'parsed'].forEach(function(prop) { var filename = util.format('%s-%s-%s-%s.html', bank.bankName, pageFilenameBase || 'session', value.pageName, prop); console.error('Writing file: ' + filename); fs.writeFileSync(path.join(outputDir, filename), value[prop]); }); } }); if (argv.verbose >= 1) { session.browser.silent = false; } if (argv.verbose >= 2) { session.browser.debug = true; session.browser.on('console', function(level, message) { console.log('browser console %s: %s', level, message); }); session.browser.on('error', function(err) { console.log('browser error: ' + err.message); }); } function logout(cb) { session.logout(function(err) { if (err) { console.log('Error logging out: ' + err.message); } session.destroy(); cb(); }); } session.login(function(err) { if (err) { console.error('Error logging in: ' + err.message); logout(function() { nextBank(null, { bank : bank.bankName, error : err.message }); }); return; } var i = 0; async.mapSeries(bank.accounts, function(account, nextAccount) { if (shouldSkip(bank.bankName + ':' + (++i))) { nextAccount(null, { account : account, status : 'skipped' }); return; } pageFilenameBase = account.filename || ('account' + i); session.getTransactions(account, function(err, data) { var results = { account : account }; if (err) { results.error = err.message; console.error( 'Error getting transactions for account "' + account.path + '": ' + err.message); } else { for (var k in data) { results[k] = data[k]; } } nextAccount(null, results); }); }, function(err, results) { pageFilenameBase = null; logout(function() { nextBank(null, { bank : bank.bankName, data : results }); }); }); }); }, function(err, results) { console.error('Writing file: ' + argv.output); fs.writeFileSync(argv.output, JSON.stringifyAligned(results)); var exitTimer = setTimeout(function() { console.error('Browser did not close normally, exiting'); process.exit(0); }, 500); exitTimer.unref(); });