banker
Version:
Downloads transactions and balances from online banking websites using Zombie.js.
377 lines (309 loc) • 12.6 kB
JavaScript
var async = require('async'),
BankSession = require('./base'),
moment = require('moment'),
util = require('util'),
utils = require('../utils');
function RegionsSession(config) {
var self = this;
BankSession.call(this, config);
// I had an absolutely terrible time trying to get this bank to work with
// JavaScript enabled. Luckily, the website seems to support running
// without JavaScript, for the most part.
self.browser.features =
self.browser.features.replace(/\bscripts\b/, 'no-scripts');
self.loginUrl = 'https://securebank.regions.com/login.aspx';
self.afterLoginUrl = 'https://securebank.regions.com/LoginIntercept.aspx';
self.accountsUrl = 'https://securebank.regions.com/balances/AccountSummary.aspx';
self.logoutUrl = 'https://securebank.regions.com/logout.aspx';
self.waitOptions = {
duration : 10000
};
self.waitOptions2 = {
duration : 20000
};
}
util.inherits(RegionsSession, BankSession);
RegionsSession.prototype.info = function() {
return {
description : 'Regions Bank (https://www.regions.com/)',
configItems : {
bankName : '"regions"',
username : 'Online banking username',
password : 'Online banking password',
securityQuestions : 'Hash of security questions and answers'
},
accountItems : {
description : 'Link text and account number on Accounts page (e.g. "LIFEGREEN CHECKING******1234")'
}
};
};
RegionsSession.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('#txtOnlineID', self.config.username);
self.browser.fill('#txtPassword', self.config.password);
} catch (err) {
cb(err);
return;
}
self.browser.once('request', function(request) {
// The server really wants to see btnSignon.x and btnSignon.y
// parameters.
request.params['btnSignon.x'] = 20;
request.params['btnSignon.y'] = 5;
delete request.params['btnSignon'];
});
self.browser.pressButton('#btnSignon', function() {
if (!self.pageLoaded('afterLogin0', cb)) return;
// TODO: Why does Zombie.js not follow this redirect?
self.browser.visit(self.afterLoginUrl, function() {
var question = self.browser.text('#lblChallengeQuestion');
if (question) {
if (!self.pageLoaded('securityQuestion', cb)) return;
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('#txtAnswer', answer);
self.browser.pressButton('#btnSubmit', function() {
// self.browser.visit(self.afterLoginUrl, function() {
self._loggedIn(cb);
// });
});
} else {
cb(new Error('Missing answer to security question: ' + question));
return;
}
} else {
cb(new Error('Missing config.securityQuestions'));
return;
}
} else {
self._loggedIn(cb);
}
});
});
});
};
RegionsSession.prototype._loggedIn = function(cb) {
var self = this;
if (!self.pageLoaded('afterLogin', cb)) return;
var link = null;
try {
link = self.browser.link('#abHeader_lnkLogOut a');
} catch (err) { }
if (!link || !link.href) {
cb(new Error('Cannot find LOG OUT link.'));
return;
}
self.loggedIn = true;
self.log('logged in');
cb(null);
};
RegionsSession.prototype.getTransactions = function(account, cb) {
var self = this;
self.log('visiting accounts page');
self.browser.visit(self.accountsUrl, self.waitOptions, function() {
if (!self.pageLoaded('accounts', cb)) return;
var link = null,
links1 = self.browser.querySelectorAll('#dgCheckingAccounts td[align=Left]'),
links2 = self.browser.querySelectorAll( '#dgSavingsAccounts td[align=Left]'),
links = [].slice.call(links1).concat([].slice.call(links2));
for (var i = 0; i < links.length; i++) {
if (self.browser.text(links[i]) == account.description) {
link = links[i];
break;
}
}
try {
link = link.querySelector('a');
} catch (err) {
link = null;
}
if (!link) {
cb(new Error('Cannot find account with description "' + account.description + '".'));
return;
}
self.log('clicking account link (description "' + account.description + '")');
self.browser.click(link);
self.browser.wait(self.waitOptions, function() {
if (!self.pageLoaded('account', cb)) return;
if (account.minDate || account.maxDate) {
var minDateSel = '#calFromDate_txtDate',
maxDateSel = '#calToDate_txtDate';
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.browser.once('request', function(request) {
// The server really wants to see imgbtnView.x and
// imgbtnView.y parameters.
request.params['imgbtnView.x'] = 20;
request.params['imgbtnView.y'] = 5;
delete request.params['imgbtnView'];
});
self.log('getting data from ' + minDate + ' to ' + maxDate);
self.browser.click('#imgbtnView');
self.browser.wait(self.waitOptions2, function() {
if (!self.pageLoaded('accountExpanded', cb)) return;
self._scrapeTransactions(cb);
});
} else {
self._scrapeTransactions(cb);
}
});
});
};
RegionsSession.prototype._scrapeTransactions = function(cb) {
var self = this;
var data = {
transactions : [],
balances : {}
};
var cells = self.browser.querySelectorAll('#pnlAcctSummary td');
for (var i = 0; i < cells.length; i++) {
switch (self.browser.text(cells[i])) {
case 'Available Balance':
data.balances.available = self.getBalance(cells[i + 1]);
break;
case 'Posted Balance':
data.balances.actual = self.getBalance(cells[i + 1]);
break;
}
}
// Scrape transaction data directly from the account page instead of using
// the export function, because the account page has an additional Type
// column which tells if the transaction is a transfer etc., and the export
// function doesn't seem to work with JavaScript disabled anyway.
self.log('scraping transactions');
var table = self.browser.query('#dgTrxHistory'),
headerCells = table.querySelectorAll('tr[class^=header] td'),
columnIndices = {},
dataRows = table.querySelectorAll('tr.item, tr.alternatingItem');
for (var i = 0; i < headerCells.length; i++) {
columnIndices[self.browser.text(headerCells[i])] = i;
}
var ok = true;
[
'Date',
'Type',
'Description/Category',
'Debit (-)',
'Credit (+)',
'Posted Balance'
].forEach(function(columnName) {
if (ok && typeof columnIndices[columnName] == 'undefined') {
cb(new Error(
'Could not find column "' + columnName + '" on account page.'));
ok = false;
}
});
if (!ok) {
return;
}
for (var i = 0; i < dataRows.length; i++) {
cells = dataRows[i].querySelectorAll('td');
if (i == 0) {
if (dataRows[i].id == 'trNoTrxHistory') {
// No activity in the current date range for this account
break;
}
data.balances.fromList = self.getBalance(cells[columnIndices['Posted Balance']]);
}
var transaction = {
date : self.browser.text(cells[columnIndices['Date']]),
type : self.browser.text(cells[columnIndices['Type']]),
description : self.browser.text(cells[columnIndices['Description/Category']]),
amount : (
self.browser.text(cells[columnIndices['Credit (+)']]) ||
('-' + self.browser.text(cells[columnIndices['Debit (-)']]))
)
};
transaction.date = moment(transaction.date, 'MM/DD/YYYY');
transaction.amount = utils.decimal(transaction.amount);
try {
var imageCode = cells[columnIndices['Description/Category']]
.querySelector('a')
.getAttribute('onclick');
if (/CheckImage\.ashx/.test(imageCode)) {
transaction.imageLink = imageCode.split("'")[1];
}
} catch (err) { }
data.transactions.push(transaction);
}
var imageIndex = 0;
async.eachSeries(data.transactions, function(transaction, next) {
if (transaction.imageLink) {
self.log(util.format(
'getting images for transaction: %s on %s',
transaction.description,
transaction.date.format('M/D/YYYY')))
transaction.images = {};
self.enableImages(function(request, response) {
if (response.statusCode == 200) {
var imageData = 'data:'
+ response.headers['content-type'].split(';')[0]
+ ';base64,' + response.body.toString('base64');
transaction.images.Image = imageData;
}
}, function(disableImages) {
self.browser.visit(transaction.imageLink, function() {
if (!self.pageLoaded('image' + (++imageIndex), cb)) return;
disableImages();
});
}, function() {
delete transaction.imageLink;
next();
});
} else {
// No images for this transaction
// TODO: Some transactions have PDFs attached - example onclick attribute:
// javascript:openPDFWindow("06/05/2014","OD ","1","","False",
// "https://securebank.regions.com/balances/NSF.ashx");return false;
next();
}
}, function(err) {
self._processData(data, cb);
});
};
RegionsSession.prototype._processData = function(data, cb) {
var self = this;
data.transactions.forEach(function(transaction) {
transaction.sourceId = self.makeTransactionId(transaction);
});
cb(null, data);
};
RegionsSession.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) {
self.loggedIn = false;
self.log('logged out');
err = null;
}
cb(err);
});
};
module.exports = RegionsSession;