diff --git a/README.markdown b/README.markdown index f586701..3ce9aa0 100644 --- a/README.markdown +++ b/README.markdown @@ -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: @@ -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 => #, + :description => "4371 20OCT14 , NATIONAL LOTTERY , INTE , WATFORD GB", + :amount => -10.0 + }, + [ 1] { + :date => #, + :description => "4371 17OCT14 , KATZENJAMMERS , LONDON GB", + :amount => -10.3 + } + ] + ## Purpose @@ -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. \ No newline at end of file diff --git a/bin/nw b/bin/nw index f45a2c3..31ce15d 100755 --- a/bin/nw +++ b/bin/nw @@ -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 + + Commands: + summary + Displays an account summary and recent transactions + transactions + 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 \ No newline at end of file diff --git a/lib/natwest.rb b/lib/natwest.rb index 601fbea..46b31b7 100644 --- a/lib/natwest.rb +++ b/lib/natwest.rb @@ -1,5 +1,8 @@ # coding: utf-8 require 'mechanize' +require 'time' + +require 'awesome_print' module Kernel def assert(condition, message) @@ -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 @@ -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 @@ -31,24 +33,18 @@ 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 @@ -56,7 +52,7 @@ 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]} @@ -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 @@ -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))] @@ -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