banker
Version:
Downloads transactions and balances from online banking websites using Zombie.js.
368 lines (334 loc) • 11.8 kB
JavaScript
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();
});