banker
Version:
Downloads transactions and balances from online banking websites using Zombie.js.
343 lines (283 loc) • 11.7 kB
JavaScript
var BankSession = require('./base'),
moment = require('moment'),
qif = require('../parsers/qif'),
url = require('url'),
util = require('util');
function AvadianSession(config) {
var self = this;
BankSession.call(this, config);
self.loginUrl = 'https://www.avadiancu.com/';
self.waitOptions = {
duration : 10000
};
self.patchJSDOM();
}
util.inherits(AvadianSession, BankSession);
AvadianSession.prototype.patchJSDOM = function() {
var self = this;
// Avadian loads jQuery 1.4.2, which does a querySelectorAll('.TEST') call
// early in the DOM load process:
//
// https://github.com/jquery/jquery/blob/1.4.2/jquery.js#L3519
//
// This occasionally causes a segfault. Since jQuery is just looking for a
// non-empty result here, return a dummy value.
//
// There's a similar issue here, with getElementsByClassName:
//
// https://github.com/jquery/jquery/blob/1.4.2/jquery.js#L3552
function patch(window) {
// This works for querySelectorAll but not getElementsByClassName
// TODO: Why??
// var jsdom = require('../../node_modules/zombie/node_modules/jsdom'),
// html = jsdom.dom.level3.html;
// var proto = html.HTMLElement.prototype;
var proto = window.document.createElement('div').__proto__;
if (proto && proto.querySelectorAll && proto.getElementsByClassName) {
var patched = false;
if (!proto.querySelectorAllOld) {
proto.querySelectorAllOld = proto.querySelectorAll;
proto.querySelectorAll = function(selector) {
// Note - Just patching over the method seems to be enough
// to prevent the crash - we don't even have to intercept
// the problematic call. However, we've already come this
// far, so might as well go ahead and do it.
if (this.toString() == '[ DIV ]' && selector == '.TEST') {
return "please don't crash";
} else {
return proto.querySelectorAllOld.apply(this, arguments);
}
};
patched = true;
}
if (!proto.getElementsByClassNameOld) {
proto.getElementsByClassNameOld = proto.getElementsByClassName;
proto.getElementsByClassName = function(className) {
if (this.toString() == '[ DIV ]' && className == 'e') {
return "please don't crash";
} else {
return proto.getElementsByClassNameOld.apply(this, arguments);
}
};
patched = true;
}
if (patched) {
self.log('patched jsdom');
}
self.browser.removeListener('opened', patch);
} else {
self.log('warning - not patching jsdom');
}
}
self.browser.on('opened', patch);
};
AvadianSession.prototype.info = function() {
return {
description : 'Avadian Credit Union (https://www.avadiancu.com/)',
configItems : {
bankName : '"avadian"',
username : 'Online banking username',
password : 'Online banking password'
},
accountItems : {
description : 'Account text on Download History page (without balance)',
name : 'Link text on Account Summary page'
}
};
};
AvadianSession.prototype.login = function(cb) {
var self = this;
if (self.loggedIn) {
self.log('already logged in');
cb(null);
return;
}
self.log('visiting login page');
self.browser.visit(self.loginUrl, function() {
if (!self.pageLoaded('login', cb)) return;
self.log('sending username and password');
try {
self.browser.fill('#HBUSERNAME', self.config.username);
self.browser.fill('#PASSWORD' , self.config.password);
self.browser.click('#MCWSUBMIT');
} catch (err) {
return cb(err);
}
self.browser.wait(self.waitOptions, function() {
if (!self.pageLoaded('afterLogin', cb)) return;
var link = self.browser.link('Log Out');
if (!link || !link.href) {
cb(new Error(self.browser.alertMessage || 'Cannot find Log Out link.'));
return;
}
var arr = self.browser.evaluate('m1mn1');
if (arr[0] != 'Account Summary') {
cb(new Error('Failed to get Account Summary URL.'));
return;
}
self.accountsUrl = url.resolve(self.browser.location.href, arr[1]);
arr = self.browser.evaluate('m1mn1_10');
if (arr[0] != 'Download History') {
cb(new Error('Failed to get Download History URL.'));
return;
}
self.exportUrl = url.resolve(self.browser.location.href, arr[1]);
self.loggedIn = true;
self.log('logged in');
self.logoutUrl = url.resolve(self.browser.location.href, link.href);
cb(null);
});
});
};
AvadianSession.prototype.getTransactions = function(account, cb) {
var self = this;
var data = {
transactions : [],
balances : {}
};
self.log('visiting accounts page');
self.browser.visit(self.accountsUrl, function() {
if (!self.pageLoaded('accounts', cb)) return;
self.log('getting balances for account "' + account.name + '"');
var table = self.browser.query('#shares'),
headerCells = table.querySelectorAll('th'),
columnIndices = {},
dataRows = table.querySelectorAll('tr.lightRow, tr.darkRow');
for (var i = 0; i < headerCells.length; i++) {
columnIndices[self.browser.text(headerCells[i])] = i;
}
var ok = true;
[
'Deposits',
'Available',
'Balance'
].forEach(function(columnName) {
if (ok && typeof columnIndices[columnName] == 'undefined') {
cb(new Error(
'Could not find column "' + columnName + '" on accounts page.'));
ok = false;
}
});
if (!ok) {
return;
}
ok = false;
for (var i = 0; i < dataRows.length; i++) {
var cells = dataRows[i].querySelectorAll('td');
if (self.browser.text(cells[columnIndices['Deposits']]) == account.name) {
ok = true;
data.balances.available = self.getBalance(cells[columnIndices['Available']]);
data.balances.actual = self.getBalance(cells[columnIndices['Balance']]);
}
}
if (!ok) {
cb(new Error(
'Could not find account with name "' + account.name + '" on accounts page.'));
return;
}
self.log('visiting export page');
self.browser.visit(self.exportUrl, function() {
if (!self.pageLoaded('export', cb)) return;
var options = self.browser.querySelectorAll('#SLID option'),
option = null;
for (var i = 0; i < options.length; i++) {
var text = self.browser.text(options[i]),
desc = account.description;
if (text.substring(0, desc.length) == desc) {
option = options[i];
break;
}
}
if (!option) {
cb(new Error(
'Cannot find export option for account "' + account.description + '".'));
return;
}
self.browser.selectOption(option);
if (!account.minDate) {
// default is 1-day export, which is probably not what we want
account.minDate = moment().subtract(30, 'days').format('MM/DD/YY');
}
var minDateSel = '#DATE1',
maxDateSel = '#DATE2';
if (account.minDate) {
self.browser.fill(minDateSel, account.minDate);
}
if (account.maxDate) {
self.browser.fill(maxDateSel, account.maxDate);
}
var minDate = self.browser.query(minDateSel).value,
maxDate = self.browser.query(maxDateSel).value;
self.log('getting data from ' + minDate + ' to ' + maxDate);
self.browser.choose('input[name=QIFOFXCHOICE][value=QIF]');
self.log('getting QIF');
self.browser.pressButton('#MCWSUBMIT', function() {
if (!self.pageLoaded('qif', cb)) return;
qif.parse(self.browser.response.body, function(err, transactions) {
if (err) {
cb(err);
} else {
data.transactions = transactions;
self._processData(data, cb);
}
});
});
});
});
};
AvadianSession.prototype._processData = function(data, cb) {
var self = this;
var transactions = [];
data.transactions.forEach(function(tx) {
if (tx.memo == '' && tx.description == 'Withdrawal Point of Sale ') {
// Sometimes incomplete transactions get included in the QIF
// download, and they are updated with the full data later. Throw
// them out so they're not double-counted on the next update.
// (Really, the text value above was in the memo field, but the QIF
// parser moves it to the description field).
} else {
// This transaction looks OK.
tx.sourceId = self.makeTransactionId(tx);
transactions.push(tx);
}
});
data.transactions = transactions;
cb(null, data);
};
AvadianSession.prototype.logout = function(cb) {
var self = this;
if (!cb) cb = function() { };
if (!self.loggedIn) {
self.log('may not be logged in');
}
if (!self.logoutUrl) {
cb(new Error('Logout URL is not known.'));
return;
}
self.log('visiting logout page');
self.browser.errors = [];
// self.browser.silent = false;
// self.browser.debug = true;
self.browser.visit(self.logoutUrl, self.waitOptions, function() {
// A whole bunch of stuff happens here, and we get here before all the
// redirects finish. To debug, uncomment the two statements above and
// run with DEBUG=zombie.
setTimeout(function() {
// This always seems to fail due to the following error:
// Called run() after dispose(). Error: Called run() after dispose().
// at sandbox.run (.../jsdom/node_modules/contextify/lib/contextify.js:21:19)
// at DOMWindow.window._evaluate (.../zombie/lib/zombie/window.js:187:25)
// if (!self.pageLoaded('logout', cb)) return;
var err = self.browser.error;
if (!err ||
err.message == 'XPathResult is not defined' ||
err.message == 'Called run() after dispose().') {
self.loggedIn = false;
self.log('logged out');
err = null;
}
cb(err);
}, 5000);
});
};
module.exports = AvadianSession;