dinero 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 5127bb025bcedb230c70da3c94fb2c79d51d2d2e
4
+ data.tar.gz: df996cbf0a393e271f07c7157b9532e7352b75ee
5
+ SHA512:
6
+ metadata.gz: c7c9e8640651381ce48fbfa84be999b9c60ae87b4e2bd533c93c6bd1abeac2ddd5f2ddf4ba1c430d485240f898d2b6fc255c2ccb96972a422440222799b35791
7
+ data.tar.gz: ce83fea40a4f76ac91c9fdea94092e7e28508604679be3bde318b5c3ea1c00989cc9f37d915e86795a23b7076089e94ad3e91df216f54ed4d81a836d9caa66b6
data/.gitignore ADDED
@@ -0,0 +1,9 @@
1
+ .DS_Store
2
+ .bundle/
3
+ log/*.log
4
+ log/*.png
5
+ pkg/
6
+ spec/config/banks.yml
7
+ *.gem
8
+ coverage/
9
+ spec/fixtures/vcr_cassettes/*.yml
data/Gemfile ADDED
@@ -0,0 +1,2 @@
1
+ source "https://rubygems.org"
2
+ gemspec
data/Gemfile.lock ADDED
@@ -0,0 +1,84 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ dinero (0.0.1)
5
+ nokogiri (~> 1.6.6)
6
+ selenium-webdriver (~> 2.45.0)
7
+
8
+ GEM
9
+ remote: https://rubygems.org/
10
+ specs:
11
+ addressable (2.3.8)
12
+ byebug (4.0.5)
13
+ columnize (= 0.9.0)
14
+ childprocess (0.5.6)
15
+ ffi (~> 1.0, >= 1.0.11)
16
+ coderay (1.1.0)
17
+ columnize (0.9.0)
18
+ crack (0.4.2)
19
+ safe_yaml (~> 1.0.0)
20
+ diff-lcs (1.2.5)
21
+ docile (1.1.5)
22
+ ffi (1.9.8)
23
+ json (1.8.2)
24
+ method_source (0.8.2)
25
+ mini_portile (0.6.2)
26
+ multi_json (1.11.0)
27
+ nokogiri (1.6.6.2)
28
+ mini_portile (~> 0.6.0)
29
+ pry (0.10.1)
30
+ coderay (~> 1.1.0)
31
+ method_source (~> 0.8.1)
32
+ slop (~> 3.4)
33
+ pry-byebug (3.1.0)
34
+ byebug (~> 4.0)
35
+ pry (~> 0.10)
36
+ rake (10.4.2)
37
+ rspec (3.2.0)
38
+ rspec-core (~> 3.2.0)
39
+ rspec-expectations (~> 3.2.0)
40
+ rspec-mocks (~> 3.2.0)
41
+ rspec-core (3.2.3)
42
+ rspec-support (~> 3.2.0)
43
+ rspec-expectations (3.2.1)
44
+ diff-lcs (>= 1.2.0, < 2.0)
45
+ rspec-support (~> 3.2.0)
46
+ rspec-its (1.2.0)
47
+ rspec-core (>= 3.0.0)
48
+ rspec-expectations (>= 3.0.0)
49
+ rspec-mocks (3.2.1)
50
+ diff-lcs (>= 1.2.0, < 2.0)
51
+ rspec-support (~> 3.2.0)
52
+ rspec-support (3.2.2)
53
+ rubyzip (1.1.7)
54
+ safe_yaml (1.0.4)
55
+ selenium-webdriver (2.45.0)
56
+ childprocess (~> 0.5)
57
+ multi_json (~> 1.0)
58
+ rubyzip (~> 1.0)
59
+ websocket (~> 1.0)
60
+ simplecov (0.10.0)
61
+ docile (~> 1.1.0)
62
+ json (~> 1.8)
63
+ simplecov-html (~> 0.10.0)
64
+ simplecov-html (0.10.0)
65
+ slop (3.6.0)
66
+ vcr (2.9.3)
67
+ webmock (1.21.0)
68
+ addressable (>= 2.3.6)
69
+ crack (>= 0.3.2)
70
+ websocket (1.2.2)
71
+
72
+ PLATFORMS
73
+ ruby
74
+
75
+ DEPENDENCIES
76
+ bundler (~> 1.6)
77
+ dinero!
78
+ pry-byebug (~> 3.1.0)
79
+ rake
80
+ rspec (~> 3.2.0)
81
+ rspec-its (~> 1.2.0)
82
+ simplecov (~> 0.10.0)
83
+ vcr (~> 2.9.3)
84
+ webmock (~> 1.21.0)
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright 2015 Michael Lang
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,208 @@
1
+ # Dinero
2
+ Dinero makes logging into all your online banking websites trivial for retrieving accounts, balances, and transactions.
3
+
4
+ Banks are rightly concerned about security, anti-phishing, and general brute force attacks on their sites, so they have developed a number of creative ways of protecting access to their sites. Some techniques include:
5
+
6
+ * Asking for only username on first page, then password on the next page.
7
+ * Multiple redirects.
8
+ * Asking security questions when browser cookies aren't set.
9
+ * loading login forms with JavaScript or requiring JS is enabled.
10
+ * loading login forms inside of IFRAME
11
+ * Randomizing code behind the PIN pad that you must click.
12
+
13
+ So much trickery requires something more than just Mechanize or RestClient. Dinero uses Selenium and PhantomJS to drive the data collection. So, you'll need to install Selenium before you can begin using Dinero. If you're on a mac:
14
+
15
+ brew install selenium-server-standalone
16
+
17
+ And then:
18
+
19
+ gem install dinero
20
+
21
+ ## The Vision
22
+
23
+ Much like ActiveRecord sought to standardize the API for accessing and modeling domain data in the DBMS without having to drop down to raw SQL and as ActiveMerchant seeks to standardize payment processing to a common set of API's, Dinero aims to standardize access to bank accounts. To that end, I have started implementing all the banks I have accounts with.
24
+
25
+ ## Project Status
26
+
27
+ The following banks are implemented:
28
+
29
+ * Capital One - https://capitalone.com (only U.S. credit card logins -- there's also banking, loans, investing, business, and Canada/UK credit cards)
30
+ * Capital One 360 - https://capitalone360.com (formerly Ing Direct).
31
+
32
+ Currently, only Accounts balances are essentially implemented. The following properties are available on each Account:
33
+
34
+ * account_type -- one of :bank, :brokerage, :credit_card
35
+ * name -- the name of the account (e.g. "Checking 360 - primary", "Worldview MasterCard")
36
+ * number -- the account number in-as-much as it's displayed
37
+ * balance -- the balance on the account. For :credit_card, this is outstanding balance
38
+ * available -- the amount available to you. For :credit_card, difference between your credit limit and balance
39
+
40
+ ## How to Use
41
+
42
+ You'll find at least one example in the examples folder called 'get_balances.' This example can take a bank_name, username, and password and print out balances to the console something like this:
43
+
44
+ ~~~ bash
45
+ >> bundle exec ruby examples/get_balances.rb --bank capital_one_360 --user scrooge
46
+ enter password:
47
+ Retrieving your bank account information...
48
+ +--------------------------------------------------------------------------------+
49
+ | name | number | balance | available |
50
+ +--------------------------------------------------------------------------------+
51
+ | 360 Checking - primary | 735546410 | $ 111543.07 | $ 111210.61 |
52
+ | MONEY - SK's Money | 112335590 | $ 302.39 | $ 302.39 |
53
+ | 360 Savings - Rainy Day Fund | 12341232 | $ 12144.45 | $ 12144.45 |
54
+ | SB Individual | 0903959692 | $ 10191.23 | $ 10191.23 |
55
+ | Visa Platinum | ....1165 | $ 87.10 | $ 19912.90 |
56
+ | World MasterCard | ....2978 | $ 192.66 | $ 19807.34 |
57
+ +--------------------------------------------------------------------------------+
58
+ ~~~
59
+
60
+ Don't worry, all those numbers are made up. ;-)
61
+
62
+ So, yeah, with more banks, we can collect more info. The above shows banking accounts, brokerage account, and credit card accounts.
63
+
64
+ To use the gem inside your app:
65
+
66
+ ~~~
67
+ require 'dinero'
68
+
69
+ bank_info = CapitalOne360.new(username: @username, password: @password))
70
+ bank_info.accounts.each do |acct|
71
+ puts [acct.name, acct.number, acct.account_type, acct.balance, acct.available].join("\t")
72
+ ~~~
73
+
74
+ If you have a really slow Bank site or Internet connection, try passing ```timeout: 15``` when initializing a Bank class.
75
+
76
+ ## Contribute!
77
+
78
+ I'm planning to continue implementing more banks, but I can use your help since I don't have access to all the world's banks.
79
+
80
+ I plan to implement the following banks:
81
+
82
+ * Wells Fargo (Loans)
83
+ * Scottrade (brokerage, IRA, and Bank Account)
84
+ * San Diego County Credit Union
85
+ * Georgia's Own Credit Union
86
+ * South State Bank
87
+
88
+ I know I'll need to do those fun security questions for unregistered browsers on some of these. I haven't quite decided on how to structure this, but at least the Bank class has an open-ended options hash parameter to accommodate additional fields being passed in.
89
+
90
+ If you want to add a new bank, here's how:
91
+
92
+ # Pick one of the existing banks that most closely follows the login pattern of your chosen bank and model your effort after it.
93
+ # Set up a new class in the lib/banks folder.
94
+ # Set up rspec specs in the spec/banks folder.
95
+ # Set up a spec/config/banks.yml file with your credentials (don't commit to the repo! -- it's .gitignore'd)
96
+
97
+ Here's an example banks.yml file:
98
+
99
+ ~~~ yaml
100
+ capital_one_360:
101
+ username: mickeymouse
102
+ password: moosamoosamickeymouse
103
+ account_types:
104
+ - :bank
105
+ - :brokerage
106
+ - :credit_card
107
+ accounts: 3
108
+
109
+ capital_one:
110
+ username: mickeymouse
111
+ password: moosamoosamickeymouse
112
+ account_types:
113
+ - :credit_card
114
+ accounts: 2
115
+ ~~~
116
+
117
+ The bank rspecs are wrapped with ```if bank_configured? :capital_one_360``` if block that allows the spec to run or not, so if the first thing you did was 'rspec' and saw 0 examples, that means you don't have a banks.yml file, yet -- or it's incorrectly configured.
118
+
119
+ Once you have the basic structure in place, then implement the following methods for your Bank class:
120
+
121
+ ### Inherit from the Base class
122
+
123
+ Be sure your Bank class is inherited from the Bank::Base class.
124
+
125
+ ### #login!
126
+
127
+ The #login! method expects to navigate to the login URL and then key in account credentials and finish with the user fully authenticated on the site. I found it best to browse the website in Firefox and Inspect the elements I wanted to key data into (user and password fields). You may have to switch to a frame if the login form is inside an IFRAME. You may have to wait until the login form is presented if it's delay-loaded via JavaScript. These are the two principal challenges I encountered. Some banks split user account and password credentials into two screens. Use a Private/Incognito Window as the Selenium environment will be without cookies so your browser experience should be, too.
128
+
129
+ ### #post_username!
130
+ The #login! calls post_username! after navigating to the login URL. If your bank only prompts for User account here, key it and post the form. If password and username are entered on this screen, just key the username and then implement the form submit in the #post_password! method. It should look something like this:
131
+
132
+ ~~~ ruby
133
+ def post_username!
134
+ begin
135
+ wait.until { connection.find_element(id: "Signin").displayed? }
136
+ rescue
137
+ connection.save_screenshot('log/capital_one_360_signin_failed.png')
138
+ raise
139
+ end
140
+
141
+ signin_form = connection.find_element(id: "Signin")
142
+ username_field = connection.find_element(id: "ACNID")
143
+ raise "Sign in Form not reached!" unless username_field && signin_form
144
+
145
+ username_field.send_keys username
146
+ signin_form.submit
147
+ end
148
+ ~~~
149
+
150
+ ### #post_password!
151
+ Here, key the password and submit the form. You may have to get the handle on the button and call the button.click method instead of simply calling form.submit as some banks put JavaScript here to make sure the button's being clicked rather than automated submittals via scripts. The implementation should look something like this:
152
+
153
+ ~~~ ruby
154
+ def post_password!
155
+ begin
156
+ wait.until { connection.find_element(id: "PasswordForm").displayed? }
157
+ rescue
158
+ connection.save_screenshot('log/capital_one_360_password_failed.png')
159
+ raise
160
+ end
161
+
162
+ password_form = connection.find_element(id: "PasswordForm")
163
+ password_field = connection.find_element(id: "currentPassword_TLNPI")
164
+ submit_button = connection.find_element :css, ".bluebutton > a:nth-child(1)"
165
+ raise "Password Form not reached!" unless password_field && password_form
166
+
167
+ password_field.send_keys password
168
+ submit_button.click
169
+ end
170
+ ~~~
171
+
172
+ ## Tips to getting logged in successfully.
173
+
174
+ ```binding.pry``` is your friend. Set up the very basic rspec spec to trigger the login process and stick binding.pry at the point where you're having trouble. Then use connection.page_source and connection.find_element, etc. to work your way through successfully grabbing controls, keying data, and posting the form. Compare what connection.page_source prints out to what you get when viewing source in your browser. Take screenshots with connection.save_screenshot('somefilename') to get a visual cue on what's really going on.
175
+
176
+ ### #accounts_summary_document
177
+
178
+ Once you're successfully logging in, the expectation is that we'll get the list of accounts from the page along with name, number, and balances. Almost all banks drop you in at an accounts summary page with enough information to gather the basic data we're interested in. So, the next task after logging in is to implement accounts_summary_document so that you parse this page's contents into a Nokogiri document. Once you can successfully construct the Nokogiri document, you can capture the above login chain of events through #accounts_summary_document via the VCR gem and you can rapidly evolve the #accounts solution through TDD. All the fun Bank tricks will be neatly captured for near instant playback, which is a boon since these banking sites can take upwards of 45 seconds to go from Login page to Accounts Summary page. Be aware that if you have to touch the connection object again after getting your accounts_summary_document, you'll see spurious errors from VCR, so it's best to be self-reliant on the Nokogiri document once you can retrieve the accounts summaries.
179
+
180
+ ### #accounts
181
+
182
+ This method returns an Array of Account objects. How you get from HTML page to an Array of Accounts is largely dependent on what the website is feeding you and some of these pages can be quite *ugly*, but fortunately, go years without any significant changes. A typical extraction from HTML to Accounts might look like this:
183
+
184
+ ~~~ ruby
185
+ # extract account data from the account summary page
186
+ def accounts
187
+ return @accounts if @accounts
188
+
189
+ # find the bricklet articles, which contains the balance data
190
+ articles = accounts_summary_document.xpath("//article").
191
+ select{|a| a.attributes["class"].value == "bricklet"}
192
+
193
+ @accounts = articles.map do |article|
194
+ prefix = article.attributes["id"].value.gsub("_bricklet", '')
195
+ name = article.xpath(".//a[@class='product_desc_link']").text
196
+ number = article.xpath(".//span[@id='#{prefix}_number']").text
197
+ balance = article.xpath(".//span[@id='#{prefix}_current_balance_amount']").text
198
+ credit = article.xpath(".//div[@id='#{prefix}_available_credit_amount']").text.split("\n").first
199
+ Account.new(:credit_card, name, number, balance, credit)
200
+ end
201
+ end
202
+ ~~~
203
+
204
+ Try to structure your specs so that likely scenarios of others who have accounts will also pass. In other words, if there's something you really want to test, like number of accounts retrieved, or types of accounts retrieved, place those as new options in the banks.yml file. Reference those in the let(:option { ... } blocks at the top of the specs. See capital_one_spec and capital_one_360_spec for examples.
205
+
206
+ Once you've implemented and tested, send me your PR. Don't check in your banks.yml nor your vcr_cassettes classes. Others who need to fix will just have to get their own credentials working -- at least for now. At some point, it would be nice to figure out how to version the cassettes without risking accidental inclusion of sensitive credentials. I'm thinking a before commit hook on git that checks the banks.yml file against the cassettes before making a commit or something along those lines.
207
+
208
+ Happy Banking!
data/Rakefile ADDED
@@ -0,0 +1,24 @@
1
+ begin
2
+ require 'bundler/setup'
3
+ rescue LoadError
4
+ puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
5
+ end
6
+
7
+ require 'rdoc/task'
8
+ require 'rspec/core'
9
+ require 'rspec/core/rake_task'
10
+
11
+ RDoc::Task.new(:rdoc) do |rdoc|
12
+ rdoc.rdoc_dir = 'rdoc'
13
+ rdoc.title = 'Dinero'
14
+ rdoc.options << '--line-numbers'
15
+ rdoc.rdoc_files.include('README.rdoc')
16
+ rdoc.rdoc_files.include('lib/**/*.rb')
17
+ end
18
+
19
+ Bundler::GemHelper.install_tasks
20
+
21
+ desc "Run all specs in spec directory (excluding plugin specs)"
22
+ RSpec::Core::RakeTask.new(:spec)
23
+
24
+ task :default => :spec
data/dinero.gemspec ADDED
@@ -0,0 +1,33 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'dinero/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "dinero"
8
+ spec.version = Dinero::VERSION
9
+ spec.authors = ["Michael Lang"]
10
+ spec.email = ["mwlang@cybrains.net"]
11
+ spec.homepage = "https://github.com/mwlang/dinero"
12
+ spec.summary = "Dinero automates the process of logging into banking and financial websites to collect account balances."
13
+ spec.description = "Dinero automates the process of logging into banking and financial websites to collect account balances. For now, only account balances are collected. Planned to download transactions and download eStatements in the future"
14
+ spec.license = "MIT"
15
+
16
+ spec.files = `git ls-files -z`.split("\x0")
17
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
18
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
19
+ spec.require_paths = ["lib"]
20
+
21
+ spec.add_development_dependency "bundler", "~> 1.6"
22
+ spec.add_development_dependency "rake"
23
+
24
+ spec.add_development_dependency "rspec", "~> 3.2.0"
25
+ spec.add_development_dependency "rspec-its", "~> 1.2.0"
26
+ spec.add_development_dependency "vcr", "~> 2.9.3"
27
+ spec.add_development_dependency "webmock", "~> 1.21.0"
28
+ spec.add_development_dependency "simplecov", "~> 0.10.0"
29
+ spec.add_development_dependency "pry-byebug", "~> 3.1.0"
30
+
31
+ spec.add_dependency 'selenium-webdriver', "~> 2.45.0"
32
+ spec.add_dependency "nokogiri", "~> 1.6.6"
33
+ end
@@ -0,0 +1,48 @@
1
+ require_relative '../lib/dinero'
2
+
3
+ args = ARGV.dup
4
+ while !args.empty? do
5
+ option = args.shift
6
+ if option =~ /\A\-\-/
7
+ value = args.shift
8
+ end
9
+ case option
10
+ when "--bank" then @bank = value
11
+ when "--user" then @username = value
12
+ when "--password" then @password = value
13
+ end
14
+ end
15
+
16
+ def camelize term
17
+ string = term.to_s.sub(/^[a-z\d]*/) { $&.capitalize }
18
+ string.gsub!(/(?:_|(\/))([a-z\d]*)/i) { "#{$1}#{$2.capitalize}" }
19
+ string.gsub!(/\//, '::')
20
+ string
21
+ end
22
+
23
+ def show_balances bank
24
+ puts "Retrieving your bank account information..."
25
+ bank.accounts
26
+
27
+ puts "+" + "-" * 80 + "+"
28
+ puts "| %35s | %12s | %11s | %11s |" % %w(name number balance available)
29
+ puts "+" + "-" * 80 + "+"
30
+ bank.accounts.each do |acct|
31
+ puts "| %35s | %12s | $%10.2f | $%10.2f |" % [acct.name, acct.number, acct.balance, acct.available]
32
+ end
33
+ puts "+" + "-" * 80 + "+"
34
+ end
35
+
36
+ if @bank && @username
37
+ (print "enter password: "; @password = STDIN.noecho(&:gets).chomp; puts) unless @password
38
+ bank_class = eval("Dinero::Bank::#{camelize(@bank)}")
39
+ show_balances(bank_class.new(username: @username, password: @password))
40
+ else
41
+ puts <<-USAGE
42
+ usage: bundle exec ruby examples/get_balances.rb --bank <bank_name> --user <login_account_name> [--password <login_password>]
43
+
44
+ * bank_name needs to match one of the class_names supported in the lib/banks folder.
45
+ * if password omitted, you'll be prompted to supply one.
46
+
47
+ USAGE
48
+ end
@@ -0,0 +1,14 @@
1
+ module Dinero
2
+ class Account
3
+ attr_reader :account_type, :name, :name_other, :number, :balance, :available
4
+ def initialize account_type, name, number, balance, available
5
+ @account_type = account_type
6
+ name_parts = name.split("\n")
7
+ @name = name_parts.shift
8
+ @name_other = name_parts.join("\n")
9
+ @number = number
10
+ @balance = balance.scan(/[\d|\-|\.]+/).join.to_f
11
+ @available = available.scan(/[\d|\-|\.]+/).join.to_f
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,67 @@
1
+ module Dinero
2
+ module Bank
3
+ class CapitalOne < Base
4
+ LOGIN_URL = "https://servicing.capitalone.com/c1/Login.aspx"
5
+ ACCOUNTS_SUMMARY_PATH = "/accounts"
6
+ CONNECTION_TIMEOUT = 10
7
+
8
+ def default_options
9
+ { timeout: CONNECTION_TIMEOUT, login_url: LOGIN_URL }
10
+ end
11
+
12
+ def post_username!
13
+ connection.switch_to.frame "loginframe"
14
+ wait.until { connection.find_element(id: "uname") }
15
+ username_field = connection.find_element(id: "uname")
16
+ username_field.send_keys username
17
+ end
18
+
19
+ def post_password!
20
+ signin_form = connection.find_element(name: "login")
21
+ password_field = signin_form.find_element(id: "cofisso_ti_passw")
22
+ login_btn = signin_form.find_element(id: "cofisso_btn_login")
23
+
24
+ password_field.send_keys password
25
+ login_btn.click
26
+ end
27
+
28
+ def post_credentials!
29
+ post_username!
30
+ post_password!
31
+ end
32
+
33
+ def after_successful_login
34
+ # the subdomain frequently changes, so capture the actual URL
35
+ # so we can return to the page if necessary.
36
+ @accounts_summary_url = connection.current_url
37
+ end
38
+
39
+ def on_accounts_summary_page?
40
+ URI(connection.current_url).path == ACCOUNTS_SUMMARY_PATH
41
+ end
42
+
43
+ def goto_accounts_summary_page
44
+ return if authenticated? && on_accounts_summary_page?
45
+ authenticated? ? connection.navigate.to(@accounts_summary_url) : login!
46
+ end
47
+
48
+ # extract account data from the account summary page
49
+ def accounts
50
+ return @accounts if @accounts
51
+
52
+ # find the bricklet articles, which contains the balance data
53
+ articles = accounts_summary_document.xpath("//article").
54
+ select{|a| a.attributes["class"].value == "bricklet"}
55
+
56
+ @accounts = articles.map do |article|
57
+ prefix = article.attributes["id"].value.gsub("_bricklet", '')
58
+ name = article.xpath(".//a[@class='product_desc_link']").text
59
+ number = article.xpath(".//span[@id='#{prefix}_number']").text
60
+ balance = article.xpath(".//span[@id='#{prefix}_current_balance_amount']").text
61
+ credit = article.xpath(".//div[@id='#{prefix}_available_credit_amount']").text.split("\n").first
62
+ Account.new(:credit_card, name, number, balance, credit)
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,120 @@
1
+ module Dinero
2
+ module Bank
3
+ class CapitalOne360 < Base
4
+ LOGIN_URL = "https://secure.capitalone360.com/myaccount/banking/login.vm"
5
+ ACCOUNTS_SUMMARY_URL = "https://secure.capitalone360.com/myaccount/banking/account_summary.vm"
6
+
7
+ def default_options
8
+ { login_url: LOGIN_URL }
9
+ end
10
+
11
+ def post_username!
12
+ begin
13
+ wait.until { connection.find_element(id: "Signin").displayed? }
14
+ rescue
15
+ connection.save_screenshot('log/capital_one_360_signin_failed.png')
16
+ raise
17
+ end
18
+
19
+ signin_form = connection.find_element(id: "Signin")
20
+ username_field = connection.find_element(id: "ACNID")
21
+ raise "Sign in Form not reached!" unless username_field && signin_form
22
+
23
+ username_field.send_keys username
24
+ signin_form.submit
25
+ end
26
+
27
+ def post_password!
28
+ begin
29
+ wait.until { connection.find_element(id: "PasswordForm").displayed? }
30
+ rescue
31
+ connection.save_screenshot('log/capital_one_360_password_failed.png')
32
+ raise
33
+ end
34
+
35
+ password_form = connection.find_element(id: "PasswordForm")
36
+ password_field = connection.find_element(id: "currentPassword_TLNPI")
37
+ submit_button = connection.find_element :css, ".bluebutton > a:nth-child(1)"
38
+ raise "Password Form not reached!" unless password_field && password_form
39
+
40
+ password_field.send_keys password
41
+ submit_button.click
42
+ end
43
+
44
+ def accounts_summary_page_fully_loaded?
45
+ tables = connection.find_elements css: 'table'
46
+ !(tables.empty? or tables.detect{|t| t.text =~ /\sLoading/})
47
+ end
48
+
49
+ def on_accounts_summary_page?
50
+ connection.current_url == ACCOUNTS_SUMMARY_URL
51
+ end
52
+
53
+ def goto_accounts_summary_page
54
+ return if authenticated? && on_accounts_summary_page?
55
+ authenticated? ? connection.navigate.to(ACCOUNTS_SUMMARY_URL) : login!
56
+ wait.until { accounts_summary_page_fully_loaded? }
57
+ end
58
+
59
+ def balance_row? row
60
+ row[1] =~ /Total/
61
+ end
62
+
63
+ def promo_table? table
64
+ table.empty? or table[0].empty? or table[0][0].empty?
65
+ end
66
+
67
+ def decipher_account_type title
68
+ return :credit_card if title =~ /Credit Cards/
69
+ return :brokerage if title =~ /ShareBuilder/
70
+ return :bank if title =~ /Checking/
71
+ return :unknown
72
+ end
73
+
74
+ def sanitize value
75
+ return unless value
76
+ value.split("\u00A0").first.strip
77
+ end
78
+
79
+ # extract account data from the account summary page
80
+ def accounts
81
+ return @accounts if @accounts
82
+ @accounts = []
83
+
84
+ # lots of spaces, tabs and the #00A0 characters, so extract
85
+ # text with this extraneous junk suppressed.
86
+ tables = accounts_summary_document.xpath("//table")
87
+ account_tables = tables.map do |table|
88
+ rows = table.xpath(".//tr").map{|row| row.xpath(".//td|.//th").
89
+ map{|cell| cell.text.strip.gsub(/\s+|\t/, " ")}}
90
+ end.reject{|table| promo_table? table }
91
+
92
+ # Turn tablular data into Account classes
93
+ account_tables.map do |table|
94
+
95
+ # the header row tells us what kind of account we're looking at
96
+ header = table.shift
97
+ account_type = decipher_account_type header[0]
98
+ has_account_number = header[1] =~ /Account/
99
+
100
+ # ignore balance rows at bottom of tables
101
+ rows = table.reject{|row| balance_row? row }
102
+
103
+ # turn those rows into accounts
104
+ rows.each do |row|
105
+ name = sanitize(row.shift)
106
+ number = (has_account_number ? sanitize(row.shift) : nil)
107
+ if number.nil? || name =~ /(\.{4})(\d+)\Z/
108
+ number = name.match(/(\.{4})(\d+)\Z/).captures.join
109
+ name = name.gsub(number,'')
110
+ end
111
+ balance = row.shift
112
+ available = row.shift || balance
113
+ @accounts << Account.new(account_type, name, number, balance, available)
114
+ end
115
+ end
116
+ return @accounts
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,74 @@
1
+ module Dinero
2
+ module Bank
3
+ DEFAULT_TIMEOUT = 5
4
+
5
+ class Base
6
+ attr_reader :username, :password
7
+ attr_reader :timeout, :login_url
8
+
9
+ def initialize options
10
+ opts = default_options.merge options
11
+
12
+ @username = opts[:username]
13
+ @password = opts[:password]
14
+ @login_url = opts[:login_url]
15
+ @timeout = opts[:timeout] || DEFAULT_TIMEOUT
16
+ @authenticated = false
17
+ validate!
18
+ end
19
+
20
+ def validate!
21
+ raise "Must supply :username" if @username.to_s.empty?
22
+ raise "Must supply :password" if @password.to_s.empty?
23
+ raise "Must have a :login_url" if @login_url.to_s.empty?
24
+ end
25
+
26
+ def default_options
27
+ {}
28
+ end
29
+
30
+ def establish_connection
31
+ Selenium::WebDriver.for :phantomjs
32
+ end
33
+
34
+ def connection
35
+ @connection ||= establish_connection
36
+ end
37
+
38
+ def authenticated?
39
+ !!@authenticated
40
+ end
41
+
42
+ def wait
43
+ @wait ||= Selenium::WebDriver::Wait.new(:timeout => timeout)
44
+ end
45
+
46
+ def accounts_summary_document
47
+ return @accounts_summary_document if @accounts_summary_document
48
+
49
+ goto_accounts_summary_page
50
+ @accounts_summary_document = Nokogiri::HTML connection.page_source
51
+ end
52
+
53
+ def after_successful_login
54
+ # NOP
55
+ end
56
+
57
+ def login!
58
+ return if authenticated?
59
+ begin
60
+ connection.navigate.to login_url
61
+ post_username!
62
+ post_password!
63
+ wait.until { on_accounts_summary_page? }
64
+ after_successful_login
65
+ @authenticated = true
66
+ rescue
67
+ connection.save_screenshot('log/#{self.to_s.downcase}_login_failure.png')
68
+ raise
69
+ end
70
+ end
71
+
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,3 @@
1
+ module Dinero
2
+ VERSION = "0.0.1"
3
+ end
data/lib/dinero.rb ADDED
@@ -0,0 +1,10 @@
1
+ require 'selenium-webdriver'
2
+ require 'nokogiri'
3
+
4
+ require_relative 'dinero/version'
5
+
6
+ require_relative 'dinero/banks'
7
+ require_relative 'dinero/banks/capital_one'
8
+ require_relative 'dinero/banks/capital_one_360'
9
+
10
+ require_relative 'dinero/account'
data/log/.keep ADDED
File without changes
@@ -0,0 +1,41 @@
1
+ require 'spec_helper'
2
+
3
+ if bank_configured? :capital_one_360
4
+
5
+ RSpec.describe Dinero::Bank::CapitalOne360 do
6
+ let(:bank_configuration) { bank_configurations[:capital_one_360] }
7
+ let(:account_types) { bank_configuration[:account_types].sort }
8
+
9
+ before(:all) do
10
+ VCR.use_cassette("accounts_capital_one_360") do
11
+ @bank = Dinero::Bank::CapitalOne360.new(bank_configurations[:capital_one_360])
12
+ @bank.accounts
13
+ end
14
+ end
15
+
16
+ it "has expected timeout" do
17
+ expect(@bank.timeout).to eq Dinero::Bank::DEFAULT_TIMEOUT
18
+ end
19
+
20
+ it "retrieves accounts" do
21
+ expect(@bank.accounts).to_not be_empty
22
+ end
23
+
24
+ it "extracts account names" do
25
+ expect(@bank.accounts.map(&:name)).to include /Checking|Savings/
26
+ end
27
+
28
+ it "does not include junk data in the names" do
29
+ @bank.accounts.each{|acct| expect(acct.name).to_not include "Opens a new window"}
30
+ end
31
+
32
+ it "extracts account numbers" do
33
+ expect(@bank.accounts.map(&:number).select{|s| s.to_s.scan /\A\d+\Z/}).to_not be_empty
34
+ end
35
+
36
+ it "sets account types" do
37
+ expect(@bank.accounts.map(&:account_type).uniq.sort).to eq account_types
38
+ end
39
+ end
40
+
41
+ end
@@ -0,0 +1,51 @@
1
+ require 'spec_helper'
2
+
3
+ if bank_configured? :capital_one
4
+
5
+ RSpec.describe Dinero::Bank::CapitalOne do
6
+ let(:bank_configuration) { bank_configurations[:capital_one] }
7
+ let(:account_types) { bank_configuration[:account_types].sort }
8
+ let(:accounts) { bank_configuration[:accounts] }
9
+
10
+ before(:all) do
11
+ VCR.use_cassette("accounts_capital_one") do
12
+ @bank = Dinero::Bank::CapitalOne.new(bank_configurations[:capital_one])
13
+ @bank.accounts
14
+ end
15
+ end
16
+
17
+ it "has expected timeout" do
18
+ expect(@bank.timeout).to eq Dinero::Bank::CapitalOne::CONNECTION_TIMEOUT
19
+ end
20
+
21
+ it "authenticates" do
22
+ @bank.login!
23
+ expect(@bank.authenticated?).to eq true
24
+ end
25
+
26
+ it "retrieves accounts_summary_document" do
27
+ expect(@bank.accounts_summary_document).to be_kind_of Nokogiri::HTML::Document
28
+ end
29
+
30
+ it "has article sections" do
31
+ expect(@bank.accounts_summary_document.xpath("//article").size).to eq accounts + 1
32
+ end
33
+
34
+ it "gets expected accounts" do
35
+ expect(@bank.accounts.size).to eq accounts
36
+ end
37
+
38
+ it "extracts account names" do
39
+ expect(@bank.accounts.map(&:name)).to include /MasterCard|Visa/
40
+ end
41
+
42
+ it "extracts account numbers" do
43
+ expect(@bank.accounts.map(&:number).select{|s| s.to_s.scan /\A[\.|\d]+\Z/}).to_not be_empty
44
+ end
45
+
46
+ it "sets account types" do
47
+ expect(@bank.accounts.map(&:account_type).uniq).to eq account_types
48
+ end
49
+ end
50
+
51
+ end
File without changes
@@ -0,0 +1,34 @@
1
+ require 'simplecov'
2
+
3
+ SimpleCov.profiles.define 'dinero' do
4
+ add_filter '/spec/'
5
+ end
6
+
7
+ SimpleCov.start 'dinero'
8
+
9
+ require 'rspec'
10
+ require 'rspec/its'
11
+ require 'vcr'
12
+
13
+ VCR.configure do |config|
14
+ config.cassette_library_dir = "spec/fixtures/vcr_cassettes"
15
+ config.hook_into :webmock
16
+ config.debug_logger = File.open('log/vcr.log', 'w')
17
+ config.ignore_request do |request|
18
+ URI(request.uri).path == "/shutdown"
19
+ end
20
+ end
21
+
22
+ require_relative '../lib/dinero'
23
+
24
+ RSpec.configure do |config|
25
+ config.expect_with :rspec do |expectations|
26
+ expectations.include_chain_clauses_in_custom_matcher_descriptions = true
27
+ end
28
+
29
+ config.mock_with :rspec do |mocks|
30
+ mocks.verify_partial_doubles = true
31
+ end
32
+ end
33
+
34
+ Dir["./spec/support/**/*.rb"].sort.each { |f| require f }
@@ -0,0 +1,18 @@
1
+ require 'yaml'
2
+
3
+ def symbolized_keys hash
4
+ hash.keys.each do |key|
5
+ hash[(key.to_sym rescue key) || key] = hash.delete(key)
6
+ end
7
+ hash.each_pair{|k,v| hash[k] = symbolized_keys(v) if v.is_a?(Hash)}
8
+ return hash
9
+ end
10
+
11
+ def bank_configurations
12
+ config_path = File.expand_path(File.join(File.dirname(__FILE__), '..', 'config'))
13
+ @bank_configurations ||= symbolized_keys YAML::load_file(File.join(config_path, 'banks.yml'))
14
+ end
15
+
16
+ def bank_configured? bank
17
+ !!bank_configurations[bank.to_sym]
18
+ end
metadata ADDED
@@ -0,0 +1,212 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: dinero
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Michael Lang
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2015-05-09 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bundler
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.6'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.6'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: 3.2.0
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: 3.2.0
55
+ - !ruby/object:Gem::Dependency
56
+ name: rspec-its
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: 1.2.0
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: 1.2.0
69
+ - !ruby/object:Gem::Dependency
70
+ name: vcr
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: 2.9.3
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: 2.9.3
83
+ - !ruby/object:Gem::Dependency
84
+ name: webmock
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: 1.21.0
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: 1.21.0
97
+ - !ruby/object:Gem::Dependency
98
+ name: simplecov
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: 0.10.0
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: 0.10.0
111
+ - !ruby/object:Gem::Dependency
112
+ name: pry-byebug
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: 3.1.0
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: 3.1.0
125
+ - !ruby/object:Gem::Dependency
126
+ name: selenium-webdriver
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - "~>"
130
+ - !ruby/object:Gem::Version
131
+ version: 2.45.0
132
+ type: :runtime
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - "~>"
137
+ - !ruby/object:Gem::Version
138
+ version: 2.45.0
139
+ - !ruby/object:Gem::Dependency
140
+ name: nokogiri
141
+ requirement: !ruby/object:Gem::Requirement
142
+ requirements:
143
+ - - "~>"
144
+ - !ruby/object:Gem::Version
145
+ version: 1.6.6
146
+ type: :runtime
147
+ prerelease: false
148
+ version_requirements: !ruby/object:Gem::Requirement
149
+ requirements:
150
+ - - "~>"
151
+ - !ruby/object:Gem::Version
152
+ version: 1.6.6
153
+ description: Dinero automates the process of logging into banking and financial websites
154
+ to collect account balances. For now, only account balances are collected. Planned
155
+ to download transactions and download eStatements in the future
156
+ email:
157
+ - mwlang@cybrains.net
158
+ executables: []
159
+ extensions: []
160
+ extra_rdoc_files: []
161
+ files:
162
+ - ".gitignore"
163
+ - Gemfile
164
+ - Gemfile.lock
165
+ - MIT-LICENSE
166
+ - README.md
167
+ - Rakefile
168
+ - dinero.gemspec
169
+ - examples/get_balances.rb
170
+ - lib/dinero.rb
171
+ - lib/dinero/account.rb
172
+ - lib/dinero/banks.rb
173
+ - lib/dinero/banks/capital_one.rb
174
+ - lib/dinero/banks/capital_one_360.rb
175
+ - lib/dinero/version.rb
176
+ - log/.keep
177
+ - spec/banks/capital_one_360_spec.rb
178
+ - spec/banks/capital_one_spec.rb
179
+ - spec/fixtures/vcr_cassettes/.keep
180
+ - spec/spec_helper.rb
181
+ - spec/support/config.rb
182
+ homepage: https://github.com/mwlang/dinero
183
+ licenses:
184
+ - MIT
185
+ metadata: {}
186
+ post_install_message:
187
+ rdoc_options: []
188
+ require_paths:
189
+ - lib
190
+ required_ruby_version: !ruby/object:Gem::Requirement
191
+ requirements:
192
+ - - ">="
193
+ - !ruby/object:Gem::Version
194
+ version: '0'
195
+ required_rubygems_version: !ruby/object:Gem::Requirement
196
+ requirements:
197
+ - - ">="
198
+ - !ruby/object:Gem::Version
199
+ version: '0'
200
+ requirements: []
201
+ rubyforge_project:
202
+ rubygems_version: 2.4.6
203
+ signing_key:
204
+ specification_version: 4
205
+ summary: Dinero automates the process of logging into banking and financial websites
206
+ to collect account balances.
207
+ test_files:
208
+ - spec/banks/capital_one_360_spec.rb
209
+ - spec/banks/capital_one_spec.rb
210
+ - spec/fixtures/vcr_cassettes/.keep
211
+ - spec/spec_helper.rb
212
+ - spec/support/config.rb