Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 36 additions & 11 deletions README.markdown
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ information, balance, and recent transactions.

## Usage

$ nw
Get a quick summary:

$ nw summary
> Please enter your customer number:
> Please enter your PIN:
> Please enter your password:
Expand All @@ -21,14 +23,36 @@ information, balance, and recent transactions.
Cash Withdrawal (LLOYDS BANK 15JAN)
18 Jan 2010: -£20.00
Cash Withdrawal (LLOYDS BANK 17JAN)
19 Jan 2010: -£45.00
OnLine Transaction (CALL REF.NO. 1234 LOANSHARK FP 19/01/10 10)
19 Jan 2010: -£49.99
Debit Card Transaction (1234 18JAN10 EXAMPLE.COM 0800 123 4567 GB)
20 Jan 2010: +£2,000.00
Automated Credit
20 Jan 2010: +£38.83
Automated Credit
...

Get transactions for one account between 2 dates:

$ nw transactions 2013-08-01 2014-10-26 123
Transactions for account ending 123, between 2013-08-01 and 2014-10-26
Date Description Amount
2014-10-21 4371 20OCT14 , NATIONAL LOTTERY , INTE , WATFORD GB -10.00
2014-10-20 4371 17OCT14 , KATZENJAMMERS , LONDON GB -10.30
2014-10-20 MOBILE PAYMENT , FROM 07123456789 50.00
2014-10-17 HSBC 17OCT -30.00
...

Hooking into the ./lib/natwest.rb methods is very easy: the transactions method
for example returns an array of transactions, each one a hash of the date, description
and amount:

[
[ 0] {
:date => #<Date: 2014-10-21 ((2456952j,0s,0n),+0s,2299161j)>,
:description => "4371 20OCT14 , NATIONAL LOTTERY , INTE , WATFORD GB",
:amount => -10.0
},
[ 1] {
:date => #<Date: 2014-10-20 ((2456951j,0s,0n),+0s,2299161j)>,
:description => "4371 17OCT14 , KATZENJAMMERS , LONDON GB",
:amount => -10.3
}
]


## Purpose

Expand Down Expand Up @@ -77,5 +101,6 @@ bad idea.
## Bugs

This utility relies on screen-scraping multiple pages of horrendous HTML.
Further, it has only been tested with one account. Feel free to report errors,
preferably with the HTML, appropriately sanitised, on which it fails.
Further, it has only been tested with one online account (with one current account
and one credit card). Feel free to report errors, preferably with the HTML,
appropriately sanitised, on which it fails.
96 changes: 67 additions & 29 deletions bin/nw
Original file line number Diff line number Diff line change
Expand Up @@ -2,42 +2,80 @@
$LOAD_PATH.unshift File.join(File.dirname(__FILE__), '..', 'lib')
require 'natwest'
require 'highline/import'
require 'yaml'

CONFIG = File.expand_path("~/.natwest.yaml")
def credential_load
config = File.expand_path("~/.natwest.yaml")

if File.exists?(CONFIG)
if File.world_readable?(CONFIG) or not File.owned?(CONFIG)
mode = File.stat(CONFIG).mode.to_s(8)
$stderr.puts "#{CONFIG}: Insecure permissions: #{mode}"
if File.exists?(config)
if File.world_readable?(config) or not File.owned?(config)
mode = File.stat(config).mode.to_s(8)
$stderr.puts "#{config}: Insecure permissions: #{mode}"
end
end
end

credentials = YAML.load(File.read(CONFIG)) rescue {}
credentials = YAML.load(File.read(config)) rescue {}

['customer number', 'PIN', 'password'].each do |credential|
key = credential.tr(' ','_').downcase.to_sym
next if credentials.key?(key)
unless $stdin.tty? and $stdout.tty?
$stderr.puts "Can't prompt for credentials; STDIN or STDOUT is not a TTY"
exit(1)
end
credentials[key] = ask("Please enter your #{credential}:") do |q|
q.echo = false
['customer number', 'PIN', 'password'].each do |credential|
key = credential.tr(' ','_').downcase.to_sym
next if credentials.key?(key)
unless $stdin.tty? and $stdout.tty?
$stderr.puts "Can't prompt for credentials; STDIN or STDOUT is not a TTY"
exit(1)
end
credentials[key] = ask("Please enter your #{credential}:") do |q|
q.echo = "*"
end
end

return credentials
end

Natwest::Customer.new.tap do |nw|
nw.login credentials
nw.accounts.each do |acc|
puts '###'
puts "#{acc.name} [#{acc.number}; #{acc.sort_code}] " +
"balance: #{acc.balance}; available: #{acc.available}"
puts "\nRecent Transactions:"
acc.transactions.each do |trans|
amount = trans[:credit] ? "+#{trans[:credit]}" : "-#{trans[:debit]}"
puts "#{trans[:date]}: #{amount}"
puts "\t" + trans[:details]
action = ARGV[0]

if action == "summary"
credentials = credential_load
Natwest::Customer.new.tap do |nw|
nw.login credentials
nw.accounts.each do |acc|
puts '###'
puts "#{acc.name} [#{acc.number}; #{acc.sort_code}] " +
"balance: #{acc.balance}; available: #{acc.available}"
puts "\nRecent Transactions:"
acc.transactions.each do |trans|
amount = trans[:credit] ? "+#{trans[:credit]}" : "-#{trans[:debit]}"
puts "#{trans[:date]}: #{amount}"
puts "\t" + trans[:details]
end
puts
end
puts
end
end
elsif action == "transactions"
credentials = credential_load
Natwest::Customer.new.tap do |nw|
nw.login credentials
transactions = nw.transactions(ARGV[1], ARGV[2], ARGV[3])
puts "Transactions for account ending #{ARGV[3]}, between #{ARGV[1]} and #{ARGV[2]}"
puts "Date Description Amount"
transactions.each do |t|
puts "#{t[:date]} #{sprintf('%-60.60s',t[:description])} #{sprintf('%9.2f', t[:amount])}"
end
end
else
puts "This is a rudimentary API for Natwest Online banking.
https://github.com/laycat/natwest

Usage:
nw <command>

Commands:
summary
Displays an account summary and recent transactions
transactions <start date> <end date> <account>
Gets transactions between two dates for an account. Dates are parsed by ruby
(so can be any format parsable by Date.parse), account is the 3 last digits
of the account or credit card number.

For more information see the README:
https://github.com/laycat/natwest/blob/master/README.markdown"
end
103 changes: 87 additions & 16 deletions lib/natwest.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# coding: utf-8
require 'mechanize'
require 'time'

require 'awesome_print'

module Kernel
def assert(condition, message)
Expand All @@ -9,7 +12,7 @@ def assert(condition, message)

module Natwest
URL = 'https://nwolb.com/'

module Login
attr_reader :ua, :pin
attr_accessor :password, :pin, :customer_number
Expand All @@ -22,7 +25,6 @@ def login(credentials)
credentials.each_pair{|name, value| send("#{name}=".to_sym, value)}
enter_customer_number
enter_pin_and_password
confirm_last_login
@logged_in = true
end

Expand All @@ -31,32 +33,26 @@ def enter_customer_number
login_form = ua.get(URL).frames.first.click.forms.first
login_form['ctl00$mainContent$LI5TABA$DBID_edit'] = customer_number
self.page = login_form.submit
assert(page.title.include?('PIN and Password details'),
assert(page.title.include?('PIN and password details'),
"Got '#{page.title}' instead of PIN/Password prompt")
end

def enter_pin_and_password
expected = expected('PIN','number') + expected('Password','character')
self.page = page.forms.first.tap do |form|
('A'..'F').map do |letter|
"ctl00$mainContent$LI6PPE#{letter}_edit"
('A'..'F').map do |letter|
"ctl00$mainContent$Tab1$LI6PPE#{letter}_edit"
end.zip(expected).each {|field, value| form[field] = value}
end.submit
assert(page.title.include?('Last log in confirmation'),
"Got '#{page.title}' instead of last login confirmation")
end

def confirm_last_login
self.page = page.forms.first.submit
assert(page.title.include?('Accounts summary'),
assert(page.title.include?('Account summary'),
"Got '#{page.title}' instead of accounts summary")
end

def expected(credential, type)
page.body.
scan(/Enter the (\d+)[a-z]{2} #{type}/).
flatten.map{|i| i.to_i - 1}.tap do |indices|
assert(indices.uniq.size == 3,
assert(indices.uniq.size == 3,
"Unexpected #{credential} characters requested")
characters = [*send(credential.downcase.to_sym).to_s.chars]
indices.map! {|i| characters[i]}
Expand All @@ -70,7 +66,11 @@ class Customer
attr_accessor :page

def initialize
@ua = Mechanize.new {|ua| ua.user_agent_alias = 'Windows IE 7'}
@ua = Mechanize.new

ua.user_agent_alias = 'Windows IE 7'
ua.verify_mode = 0
ua.pluggable_parser.default = Mechanize::Download
end

def accounts
Expand All @@ -81,7 +81,7 @@ def accounts
acc.sort_code = meta.at('span.SortCode').inner_text.gsub(/[^\d-]/,'')
acc.balance = meta.css('td')[-2].inner_text
acc.available = meta.css('td')[-1].inner_text
acc.transactions =
acc.transactions =
statement.css('table.InnerAccountTable > tbody > tr').map do |tr|
transaction = Hash[[:date, :details, :credit, :debit].
zip((cells = tr.css('td')).map(&:inner_text))]
Expand All @@ -93,9 +93,80 @@ def accounts
end
end
end

def transactions(start_date, end_date, account)
# TODO check end_date >= start_date?
start_date = Date.parse(start_date)
end_date = Date.parse(end_date)

transactions = []

this_end_date = end_date
this_start_date = [end_date - 364, start_date].max

while this_start_date <= this_end_date
self.page = page.link_with(text: 'Statements').click
assert(page.title.include?('Statements'),
"Got '#{page.title}' instead of Statements")

form = page.form_with(action: 'StatementsLandingPageA.aspx')
button = form.button_with(value: 'Search transactions')
self.page = form.submit(button)
assert(page.title.include?('Transaction search - Select account and period'),
"Got '#{page.title}' instead of Transaction search")

self.page = page.link_with(text: 'view transactions between two dates.').click
assert(page.title.include?('Transaction search - Select account and dates'),
"Got '#{page.title}' instead of Transaction search - Select account and dates")

form = page.form_with(action: 'TransactionSearchSpecificDates.aspx')
form.field_with(name: 'ctl00$mainContent$TS2DEA_day').value = this_start_date.day
form.field_with(name: 'ctl00$mainContent$TS2DEA_month').value = this_start_date.month
form.field_with(name: 'ctl00$mainContent$TS2DEA_year').value = this_start_date.year
form.field_with(name: 'ctl00$mainContent$TS2DEB_day').value = this_end_date.day
form.field_with(name: 'ctl00$mainContent$TS2DEB_month').value = this_end_date.month
form.field_with(name: 'ctl00$mainContent$TS2DEB_year').value = this_end_date.year
form.field_with(name: 'ctl00$mainContent$TS2ACCDDA').option_with(text: /(.*?)#{account}/).select
self.page = form.click_button
assert(page.title.include?('Transaction search details'),
"Got '#{page.title}' instead of Transaction search details")

search_form = page.form_with(action: 'TransactionSearchSpecificDates.aspx')
search_button = search_form.button_with(value: 'Search')
self.page = search_form.submit(search_button)
assert(page.title.include?('Transaction search results'),
"Got '#{page.title}' instead of Transaction search results")

if !page.link_with(text: 'All').nil?
self.page = page.link_with(text: 'All').click
end

transaction_table = page.search('table.ItemTable')

transaction_header = transaction_table.search('th > a').map { |th| th.inner_text }

transaction_table.search('tbody > tr').each do |tr|
values = tr.search('td').map{ |td| td.inner_text }
tr = Hash[transaction_header.zip values.map{|v| v == ' - ' ? '0' : v}]
transaction = {}
transaction[:date] = Date.parse(tr['Posting date'] || tr['Date'])
transaction[:description] = tr['Description']
transaction[:amount] = tr['Paid in'].gsub(/,/,'').to_f - tr['Paid out'].gsub(/,/,'').to_f

transactions << transaction
end
this_end_date = this_start_date - 1
this_start_date = [this_end_date - 364, start_date].max
end

#transactions.reverse!
return transactions

end

end

class Account
class Account
attr_accessor :name, :number, :sort_code, :balance, :available, :transactions
end
end