banker
Version:
Downloads transactions and balances from online banking websites using Zombie.js.
377 lines (319 loc) • 14.6 kB
JavaScript
var async = require('async'),
BankSession = require('./base'),
fs = require('fs'),
path = require('path'),
qif = require('../parsers/qif'),
util = require('util');
// Zombie.js / jsdom don't populate the document.forms and document[formName]
// properties. The Fiserv JavaScript code uses these properties pretty
// extensively.
var populateDocumentForms = fs.readFileSync(
path.join(
__dirname, '..', 'browser', 'populate-document-forms.js'
)).toString();
function FiservOldSession(config) {
var self = this;
BankSession.call(self, config);
var urlBase = 'https://www.netit.financial-net.com/' + self.config.bankName + '/cgi-bin/ebs';
self.url1 = urlBase + '?OLB_CMD-SMN-126';
self.url2 = 'https://www.ea.netit.financial-net.com/passmark/signin.do?&detect=5';
self.mainUrl = urlBase + '?OLB_CMD-SHB-233&CMD=OLB_CMD-SHB-041';
self.logoutUrl = urlBase + '?OLB_CMD-SMN-127';
// Selectors for the button to load more transactions and to export in QIF format
self.moreButtonSel = ['form[name=more]' , 'input[name=OLB_CMD-EXP-115]'].join(' ');
self.exportButtonSel = ['form[name=form1]', 'input[name=OLB_CMD-EXP-116]'].join(' ');
}
util.inherits(FiservOldSession, BankSession);
FiservOldSession.prototype.info = function() {
return {
description : 'Credit unions handled by the Fiserv software (https://www.netit.financial-net.com/* and possibly others)',
configItems : {
driver : '"fiserv-old"',
bankName : 'Bank name (appears in URL as https://www.netit.financial-net.com/BANKNAME/cgi-bin/...)',
username : 'Online banking username',
password : 'Online banking password',
securityQuestions : 'Hash of security questions and answers',
passmarkName : 'The "alt" attribute of your passmark image (optional; will be validated if given)'
},
accountItems : {
code : 'The account code (*1234=56) listed in the Account column'
}
};
};
FiservOldSession.prototype.login = function(cb) {
var self = this;
if (self.loggedIn) {
self.log('already logged in');
cb(null);
return;
}
self.log('visiting url 1');
self.browser.visit(self.url1, function() {
if (!self.pageLoaded('url1', cb)) return;
var field = self.browser.field('LOGNID');
if (!field) {
cb(new Error('Could not find login ID field.'));
return;
}
self.log('sending username');
self.browser.fill('LOGNID', self.config.username);
self.browser.pressButton('input[type=submit]', function() {
if (!self.pageLoaded('afterLogin', cb)) return;
self.log('visiting url 2');
self.browser.visit(self.url2, function() {
if (!self.pageLoaded('url2', cb)) return;
var question = self.browser.text('.mod-hdr');
if (question) {
self.log('got security question: ' + question);
if (self.config.securityQuestions) {
var answer = self.config.securityQuestions[question];
if (answer) {
self.log('answering security question');
self.browser.fill('answer1', answer);
self.browser.pressButton('input[type=submit]', function() {
if (!self.pageLoaded('afterQuestion', cb)) return;
self._enterPassword(cb);
});
} else {
cb(new Error('Missing answer to security question: ' + question));
return;
}
} else {
cb(new Error('Missing config.securityQuestions'));
return;
}
} else {
self._enterPassword(cb);
}
});
});
});
};
FiservOldSession.prototype._enterPassword = function(cb) {
var self = this;
var field = self.browser.field('PARAM_PASSWORD');
if (!field) {
cb(new Error('Could not find password field.'));
return;
}
if (self.config.passmarkName) {
self.log('checking passmark image');
var passmark = self.browser.query('img.passmarkImage');
if (!passmark || passmark.alt !== self.config.passmarkName) {
cb(new Error('Invalid passmark name: expected "' + self.config.passmarkName
+ '" but found "' + (passmark ? passmark.alt : '') + '"'));
return;
}
}
self.log('sending password');
self.browser.fill('PARAM_PASSWORD', self.config.password);
self.browser.alertMessage = null;
self.browser.pressButton('input[type=submit]', function() {
if (!self.pageLoaded('afterPassword', cb)) return;
self.log('visiting main page');
self.browser.visit(self.mainUrl, function() {
if (!self.pageLoaded('main', cb)) return;
var link = null;
try {
link = self.browser.link('EXIT');
} catch (err) { }
if (!link || !link.href) {
cb(new Error(self.browser.alertMessage || 'Cannot find EXIT link.'));
return;
}
self.loggedIn = true;
self.log('logged in');
cb(null);
});
});
};
FiservOldSession.prototype.getTransactions = function(account, cb) {
var self = this;
self.log('visiting main page');
self.browser.visit(self.mainUrl, function() {
if (!self.pageLoaded('mainForAccount', cb)) return;
self.browser.evaluate(populateDocumentForms);
var link = null,
links = self.browser.querySelectorAll('td.SUMTABL a');
for (var i = 0; i < links.length; i++) {
if (self.browser.text(links[i]) == account.code) {
link = links[i];
break;
}
}
if (!link) {
cb(new Error('Cannot find account with code "' + account.code + '".'));
return;
}
self.log('clicking account link (code "' + account.code + '")');
self.browser.evaluate(link.href.replace(/^javascript:/, ''));
self.browser.wait(function() {
if (!self.pageLoaded('account', cb)) return;
var status = self.browser.text('form[name=form1] font.body6');
if (status == 'Error') {
cb(new Error(
self.browser.text('form[name=form1] td.CENTER') || 'Unknown error on account page.'));
return;
}
var link = null;
try {
link = self.browser.link('Export');
} catch (err) { }
if (!link) {
cb(new Error('Cannot find Export link on account page.'));
return;
}
var data = {
checkImages : {},
balances : {}
};
var headers = self.browser.querySelectorAll('th.SUMTABR'),
cells = self.browser.querySelectorAll('td.SUMTABR');
for (var i = 0; i < headers.length; i++) {
if (self.browser.text(headers[i]) == 'Balance') {
data.balances.fromList = self.getBalance(cells[i]);
break;
}
}
var sel = 'table[summary="Provides additional details about this account."] td';
cells = self.browser.querySelectorAll(sel);
for (var i = 0; i < cells.length; i++) {
switch (self.browser.text(cells[i])) {
case 'Actual Balance':
data.balances.actual = self.getBalance(cells[i + 1]);
break;
case 'Avail Balance':
data.balances.available = self.getBalance(cells[i + 1]);
break;
}
}
var checkLinks = self.browser.querySelectorAll('a[target=FrontCheckImage]');
async.eachSeries(checkLinks, function(checkLink, next) {
var checkNum = self.browser.text(checkLink);
if (data.checkImages[checkNum]) {
self.log('already got images for check #' + checkNum);
next(null);
} else {
self.log('getting images for check #' + checkNum);
data.checkImages[checkNum] = {};
self.enableImages(function(request, response) {
if (response.statusCode == 200) {
var imageData = 'data:'
+ response.headers['content-type'].split(';')[0]
+ ';base64,' + response.body.toString('base64');
if (/[?&]F_B=F(&|$)/.test(response.url)) {
data.checkImages[checkNum]['Check front'] = imageData;
} else if (/[?&]F_B=B(&|$)/.test(response.url)) {
data.checkImages[checkNum]['Check back'] = imageData;
} else {
self.log('unrecognized check image: ' + response.url);
}
}
}, function(disableImages) {
self.browser.click(checkLink, function() {
if (!self.pageLoaded('check' + checkNum, cb)) return;
// the check page opens in a new window
self.browser.close('FrontCheckImage');
disableImages();
});
}, function() {
next();
});
}
}, function(err) {
self.log('clicking Export link');
self.browser.click(link, function() {
if (!self.pageLoaded('accountExport', cb)) return;
var moreIndex = 1;
function loadMoreOrGetQIF(i) {
if (i > 0) {
self.log('getting more transactions');
self.browser.pressButton(self.moreButtonSel, function() {
if (!self.pageLoaded('more' + (moreIndex++), cb)) return;
loadMoreOrGetQIF(i - 1);
});
} else {
self.browser.evaluate(populateDocumentForms);
self.browser.evaluate('document.form1.allbox.checked = true; CheckAll();');
self.log('getting QIF');
var button = null;
try {
button = self.browser.querySelector(self.exportButtonSel);
} catch (err) {
cb(new Error(
'Cannot find Export button.'));
return;
}
self.browser.pressButton(button, 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);
}
});
});
}
}
// Loading more transactions does not currently work, because
// the markup for the relevant form is so bad that Zombie.js
// can't parse it properly. It's structured as follows:
//
// table > form > tr > th > input
//
// Zombie.js parses this as an empty form element with the
// input elements outside of the form element. To get around
// this, we'd probably have to find all input elements under
// the td or table that contains the form[name=more] element,
// and do the POST manually.
//loadMoreOrGetQIF(+(account.loadMoreTransactions || 0));
loadMoreOrGetQIF(0);
});
});
});
});
};
FiservOldSession.prototype._processData = function(data, cb) {
var self = this;
data.transactions.forEach(function(transaction) {
if (/id:|ref:|t(x|rans(action)?) *id/i.test(transaction.memo)) {
transaction.sourceId = transaction.memo;
delete transaction.memo;
} else {
transaction.sourceId = self.makeTransactionId(transaction);
}
var match = transaction.description.match(/^SHARE DRAFT #\s*([0-9]+)\s*$/);
if (match && data.checkImages[match[1]]) {
transaction.images = data.checkImages[match[1]];
delete data.checkImages[match[1]];
}
});
var keys = Object.keys(data.checkImages);
if (keys.length) {
self.log('check images with unmatched transactions: ' + keys.join(', '));
}
delete data.checkImages;
cb(null, data);
};
FiservOldSession.prototype.logout = function(cb) {
var self = this;
if (!cb) cb = function() { };
if (!self.loggedIn) {
self.log('may not be logged in');
}
self.log('visiting logout page');
self.browser.errors = [];
self.browser.visit(self.logoutUrl, function() {
if (!self.pageLoaded('logout', cb)) return;
var err = self.browser.error;
if (!err || err.message == "Cannot read property 'PARAM_PASSWORD' of undefined") {
self.loggedIn = false;
self.log('logged out');
err = null;
}
cb(err);
});
};
module.exports = FiservOldSession;