dinero 0.0.1 → 0.0.3
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.rspec +2 -0
- data/Gemfile.lock +21 -18
- data/README.md +40 -15
- data/dinero.gemspec +1 -1
- data/examples/get_balances.rb +4 -4
- data/lib/dinero/account.rb +4 -3
- data/lib/dinero/banks/capital_one.rb +25 -21
- data/lib/dinero/banks/capital_one_360.rb +54 -40
- data/lib/dinero/banks/south_state_bank.rb +99 -0
- data/lib/dinero/banks.rb +49 -16
- data/lib/dinero/version.rb +2 -2
- data/lib/dinero.rb +1 -0
- data/spec/banks/capital_one_360_spec.rb +13 -13
- data/spec/banks/capital_one_spec.rb +24 -15
- data/spec/banks/south_state_bank_spec.rb +67 -0
- metadata +9 -5
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 54db7e4b1d206d902c99a0bc1240c2e9f9c69cde
|
4
|
+
data.tar.gz: 0e5c793a6055e095edd3849a91ddd7368fca5e48
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 2794d5f6d5338f69d586e9a1b7ba146a6d597b6e6eeb52d4cc1df3e207200db5591888cd3965723b2f0423be30fbbb1617e7ca97f2c3ec33ed82fcde8ed406ac
|
7
|
+
data.tar.gz: cce7bb0830d8644a4539d728dc882cca84e5ff7f082283f77027479b5fb32e4547a808684e6c52f880782808befaaec2a2a4cc2943f2a97f26a4f7dcbdcf4b9c
|
data/.rspec
ADDED
data/Gemfile.lock
CHANGED
@@ -1,39 +1,40 @@
|
|
1
1
|
PATH
|
2
2
|
remote: .
|
3
3
|
specs:
|
4
|
-
dinero (0.0.
|
4
|
+
dinero (0.0.3)
|
5
5
|
nokogiri (~> 1.6.6)
|
6
|
-
selenium-webdriver (~>
|
6
|
+
selenium-webdriver (~> 3.0)
|
7
7
|
|
8
8
|
GEM
|
9
9
|
remote: https://rubygems.org/
|
10
10
|
specs:
|
11
|
-
addressable (2.
|
11
|
+
addressable (2.5.0)
|
12
|
+
public_suffix (~> 2.0, >= 2.0.2)
|
12
13
|
byebug (4.0.5)
|
13
14
|
columnize (= 0.9.0)
|
14
|
-
childprocess (0.5.
|
15
|
+
childprocess (0.5.9)
|
15
16
|
ffi (~> 1.0, >= 1.0.11)
|
16
|
-
coderay (1.1.
|
17
|
+
coderay (1.1.1)
|
17
18
|
columnize (0.9.0)
|
18
|
-
crack (0.4.
|
19
|
+
crack (0.4.3)
|
19
20
|
safe_yaml (~> 1.0.0)
|
20
21
|
diff-lcs (1.2.5)
|
21
22
|
docile (1.1.5)
|
22
|
-
ffi (1.9.
|
23
|
-
json (1.8.
|
23
|
+
ffi (1.9.14)
|
24
|
+
json (1.8.3)
|
24
25
|
method_source (0.8.2)
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
pry (0.10.1)
|
26
|
+
mini_portile2 (2.1.0)
|
27
|
+
nokogiri (1.6.8.1)
|
28
|
+
mini_portile2 (~> 2.1.0)
|
29
|
+
pry (0.10.4)
|
30
30
|
coderay (~> 1.1.0)
|
31
31
|
method_source (~> 0.8.1)
|
32
32
|
slop (~> 3.4)
|
33
33
|
pry-byebug (3.1.0)
|
34
34
|
byebug (~> 4.0)
|
35
35
|
pry (~> 0.10)
|
36
|
-
|
36
|
+
public_suffix (2.0.4)
|
37
|
+
rake (11.3.0)
|
37
38
|
rspec (3.2.0)
|
38
39
|
rspec-core (~> 3.2.0)
|
39
40
|
rspec-expectations (~> 3.2.0)
|
@@ -50,11 +51,10 @@ GEM
|
|
50
51
|
diff-lcs (>= 1.2.0, < 2.0)
|
51
52
|
rspec-support (~> 3.2.0)
|
52
53
|
rspec-support (3.2.2)
|
53
|
-
rubyzip (1.
|
54
|
+
rubyzip (1.2.0)
|
54
55
|
safe_yaml (1.0.4)
|
55
|
-
selenium-webdriver (
|
56
|
+
selenium-webdriver (3.0.3)
|
56
57
|
childprocess (~> 0.5)
|
57
|
-
multi_json (~> 1.0)
|
58
58
|
rubyzip (~> 1.0)
|
59
59
|
websocket (~> 1.0)
|
60
60
|
simplecov (0.10.0)
|
@@ -67,7 +67,7 @@ GEM
|
|
67
67
|
webmock (1.21.0)
|
68
68
|
addressable (>= 2.3.6)
|
69
69
|
crack (>= 0.3.2)
|
70
|
-
websocket (1.2.
|
70
|
+
websocket (1.2.3)
|
71
71
|
|
72
72
|
PLATFORMS
|
73
73
|
ruby
|
@@ -82,3 +82,6 @@ DEPENDENCIES
|
|
82
82
|
simplecov (~> 0.10.0)
|
83
83
|
vcr (~> 2.9.3)
|
84
84
|
webmock (~> 1.21.0)
|
85
|
+
|
86
|
+
BUNDLED WITH
|
87
|
+
1.12.5
|
data/README.md
CHANGED
@@ -13,11 +13,11 @@ Banks are rightly concerned about security, anti-phishing, and general brute for
|
|
13
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
14
|
|
15
15
|
brew install selenium-server-standalone
|
16
|
-
|
17
|
-
And then:
|
16
|
+
|
17
|
+
And then:
|
18
18
|
|
19
19
|
gem install dinero
|
20
|
-
|
20
|
+
|
21
21
|
## The Vision
|
22
22
|
|
23
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.
|
@@ -28,8 +28,9 @@ The following banks are implemented:
|
|
28
28
|
|
29
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
30
|
* Capital One 360 - https://capitalone360.com (formerly Ing Direct).
|
31
|
+
* South State Bank - https://www.southstatebank.com/
|
31
32
|
|
32
|
-
Currently, only Accounts balances are
|
33
|
+
Currently, only Accounts balances are implemented. The following properties are available on each Account:
|
33
34
|
|
34
35
|
* account_type -- one of :bank, :brokerage, :credit_card
|
35
36
|
* name -- the name of the account (e.g. "Checking 360 - primary", "Worldview MasterCard")
|
@@ -39,7 +40,7 @@ Currently, only Accounts balances are essentially implemented. The following pr
|
|
39
40
|
|
40
41
|
## How to Use
|
41
42
|
|
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
|
+
You'll find at least one example in the examples folder called 'get_balances.rb' This example can take a bank_name, username, and password and print out balances to the console something like this:
|
43
44
|
|
44
45
|
~~~ bash
|
45
46
|
>> bundle exec ruby examples/get_balances.rb --bank capital_one_360 --user scrooge
|
@@ -63,16 +64,29 @@ So, yeah, with more banks, we can collect more info. The above shows banking ac
|
|
63
64
|
|
64
65
|
To use the gem inside your app:
|
65
66
|
|
66
|
-
~~~
|
67
|
+
~~~ ruby
|
67
68
|
require 'dinero'
|
68
69
|
|
69
|
-
bank_info = CapitalOne360.new(username: @username, password: @password))
|
70
|
+
bank_info = Dinero::Bank::CapitalOne360.new(username: @username, password: @password))
|
70
71
|
bank_info.accounts.each do |acct|
|
71
|
-
puts [acct.name, acct.number, acct.account_type, acct.balance, acct.available].join("\t")
|
72
|
+
puts [acct.name, acct.number, acct.account_type, acct.balance, acct.available].join("\t")
|
73
|
+
end
|
72
74
|
~~~
|
73
75
|
|
74
76
|
If you have a really slow Bank site or Internet connection, try passing ```timeout: 15``` when initializing a Bank class.
|
75
77
|
|
78
|
+
For banks that ask security questions on new computers (such as South State Bank), supply an array of question/answer hashes as "security_questions" when instantiating the Bank object. For example:
|
79
|
+
|
80
|
+
~~~ ruby
|
81
|
+
answers = [
|
82
|
+
{"question" => "What is your favorite hobby?", "answer"=>"ruby"},
|
83
|
+
{"question" => "What is your father's middle name?", "answer"=>"smith"},
|
84
|
+
{"question" => "What was your first job?", "answer" => "student"}
|
85
|
+
]
|
86
|
+
bank_info = Dinero::Bank::CapitalOne360.new(username: @username, password: @password, security_questions: answers))
|
87
|
+
# ...
|
88
|
+
~~~
|
89
|
+
|
76
90
|
## Contribute!
|
77
91
|
|
78
92
|
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.
|
@@ -83,10 +97,7 @@ I plan to implement the following banks:
|
|
83
97
|
* Scottrade (brokerage, IRA, and Bank Account)
|
84
98
|
* San Diego County Credit Union
|
85
99
|
* Georgia's Own Credit Union
|
86
|
-
* South State Bank
|
87
100
|
|
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
101
|
If you want to add a new bank, here's how:
|
91
102
|
|
92
103
|
# Pick one of the existing banks that most closely follows the login pattern of your chosen bank and model your effort after it.
|
@@ -100,18 +111,32 @@ Here's an example banks.yml file:
|
|
100
111
|
capital_one_360:
|
101
112
|
username: mickeymouse
|
102
113
|
password: moosamoosamickeymouse
|
103
|
-
account_types:
|
114
|
+
account_types:
|
104
115
|
- :bank
|
105
116
|
- :brokerage
|
106
117
|
- :credit_card
|
107
118
|
accounts: 3
|
108
|
-
|
119
|
+
|
109
120
|
capital_one:
|
110
121
|
username: mickeymouse
|
111
122
|
password: moosamoosamickeymouse
|
112
|
-
account_types:
|
123
|
+
account_types:
|
113
124
|
- :credit_card
|
114
125
|
accounts: 2
|
126
|
+
|
127
|
+
south_state_bank:
|
128
|
+
username: mickeymouse
|
129
|
+
password: moosamoosamickeymouse
|
130
|
+
account_types:
|
131
|
+
- :bank
|
132
|
+
accounts: 3
|
133
|
+
security_questions:
|
134
|
+
- question: "What is your favorite hobby?"
|
135
|
+
answer: ruby
|
136
|
+
- question: "What is your father's middle name?"
|
137
|
+
answer: smith
|
138
|
+
- question: "What was your first job?"
|
139
|
+
answer: student
|
115
140
|
~~~
|
116
141
|
|
117
142
|
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.
|
@@ -147,7 +172,7 @@ def post_username!
|
|
147
172
|
end
|
148
173
|
~~~
|
149
174
|
|
150
|
-
### #post_password!
|
175
|
+
### #post_password!
|
151
176
|
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
177
|
|
153
178
|
~~~ ruby
|
data/dinero.gemspec
CHANGED
@@ -28,6 +28,6 @@ Gem::Specification.new do |spec|
|
|
28
28
|
spec.add_development_dependency "simplecov", "~> 0.10.0"
|
29
29
|
spec.add_development_dependency "pry-byebug", "~> 3.1.0"
|
30
30
|
|
31
|
-
spec.add_dependency 'selenium-webdriver', "~>
|
31
|
+
spec.add_dependency 'selenium-webdriver', "~> 3.0"
|
32
32
|
spec.add_dependency "nokogiri", "~> 1.6.6"
|
33
33
|
end
|
data/examples/get_balances.rb
CHANGED
@@ -6,7 +6,7 @@ while !args.empty? do
|
|
6
6
|
if option =~ /\A\-\-/
|
7
7
|
value = args.shift
|
8
8
|
end
|
9
|
-
case option
|
9
|
+
case option
|
10
10
|
when "--bank" then @bank = value
|
11
11
|
when "--user" then @username = value
|
12
12
|
when "--password" then @password = value
|
@@ -42,7 +42,7 @@ else
|
|
42
42
|
usage: bundle exec ruby examples/get_balances.rb --bank <bank_name> --user <login_account_name> [--password <login_password>]
|
43
43
|
|
44
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
|
-
|
45
|
+
* if password omitted, you'll be prompted to supply one.
|
46
|
+
|
47
47
|
USAGE
|
48
|
-
end
|
48
|
+
end
|
data/lib/dinero/account.rb
CHANGED
@@ -1,4 +1,5 @@
|
|
1
1
|
module Dinero
|
2
|
+
NUMERIC_REGEXP = /[\d|\-|\.]+/
|
2
3
|
class Account
|
3
4
|
attr_reader :account_type, :name, :name_other, :number, :balance, :available
|
4
5
|
def initialize account_type, name, number, balance, available
|
@@ -7,8 +8,8 @@ module Dinero
|
|
7
8
|
@name = name_parts.shift
|
8
9
|
@name_other = name_parts.join("\n")
|
9
10
|
@number = number
|
10
|
-
@balance = balance.scan(
|
11
|
-
@available = available.scan(
|
11
|
+
@balance = balance.scan(NUMERIC_REGEXP).join.to_f
|
12
|
+
@available = available.scan(NUMERIC_REGEXP).join.to_f
|
12
13
|
end
|
13
14
|
end
|
14
|
-
end
|
15
|
+
end
|
@@ -1,54 +1,58 @@
|
|
1
1
|
module Dinero
|
2
2
|
module Bank
|
3
3
|
class CapitalOne < Base
|
4
|
-
LOGIN_URL = "https://
|
5
|
-
ACCOUNTS_SUMMARY_PATH = "/accounts"
|
6
|
-
CONNECTION_TIMEOUT =
|
4
|
+
LOGIN_URL = "https://verified.capitalone.com/sic-ui/#/esignin?Product=Card"
|
5
|
+
ACCOUNTS_SUMMARY_PATH = "/accounts/"
|
6
|
+
CONNECTION_TIMEOUT = 30
|
7
7
|
|
8
8
|
def default_options
|
9
9
|
{ timeout: CONNECTION_TIMEOUT, login_url: LOGIN_URL }
|
10
10
|
end
|
11
11
|
|
12
12
|
def post_username!
|
13
|
-
connection.
|
14
|
-
|
15
|
-
username_field =
|
13
|
+
wait.until { connection.find_element(id: "id-signin-form") }
|
14
|
+
@signin_form = connection.find_element(id: "id-signin-form")
|
15
|
+
username_field = @signin_form.find_element(id: "username")
|
16
16
|
username_field.send_keys username
|
17
17
|
end
|
18
|
-
|
18
|
+
|
19
19
|
def post_password!
|
20
|
-
|
21
|
-
password_field = signin_form.find_element(id: "cofisso_ti_passw")
|
22
|
-
login_btn = signin_form.find_element(id: "cofisso_btn_login")
|
23
|
-
|
20
|
+
password_field = @signin_form.find_element(id: "password")
|
24
21
|
password_field.send_keys password
|
22
|
+
|
23
|
+
login_btn = @signin_form.find_element(id: "id-signin-submit")
|
25
24
|
login_btn.click
|
26
25
|
end
|
27
|
-
|
26
|
+
|
28
27
|
def post_credentials!
|
29
28
|
post_username!
|
30
29
|
post_password!
|
31
30
|
end
|
32
|
-
|
31
|
+
|
33
32
|
def after_successful_login
|
34
|
-
# the subdomain frequently changes, so capture the actual URL
|
33
|
+
# the subdomain frequently changes, so capture the actual URL
|
35
34
|
# so we can return to the page if necessary.
|
36
35
|
@accounts_summary_url = connection.current_url
|
37
36
|
end
|
38
|
-
|
37
|
+
|
39
38
|
def on_accounts_summary_page?
|
40
39
|
URI(connection.current_url).path == ACCOUNTS_SUMMARY_PATH
|
41
40
|
end
|
42
|
-
|
41
|
+
|
43
42
|
def goto_accounts_summary_page
|
44
43
|
return if authenticated? && on_accounts_summary_page?
|
45
44
|
authenticated? ? connection.navigate.to(@accounts_summary_url) : login!
|
45
|
+
wait.until { connection.find_element(id: "main_content") }
|
46
|
+
end
|
47
|
+
|
48
|
+
def first_numeric value
|
49
|
+
value.split("\n").reject{|r| r.empty?}.first
|
46
50
|
end
|
47
|
-
|
51
|
+
|
48
52
|
# extract account data from the account summary page
|
49
53
|
def accounts
|
50
54
|
return @accounts if @accounts
|
51
|
-
|
55
|
+
|
52
56
|
# find the bricklet articles, which contains the balance data
|
53
57
|
articles = accounts_summary_document.xpath("//article").
|
54
58
|
select{|a| a.attributes["class"].value == "bricklet"}
|
@@ -58,10 +62,10 @@ module Dinero
|
|
58
62
|
name = article.xpath(".//a[@class='product_desc_link']").text
|
59
63
|
number = article.xpath(".//span[@id='#{prefix}_number']").text
|
60
64
|
balance = article.xpath(".//span[@id='#{prefix}_current_balance_amount']").text
|
61
|
-
|
62
|
-
Account.new(:credit_card, name, number, balance,
|
65
|
+
available = first_numeric(article.xpath(".//div[@id='#{prefix}_available_credit_amount']").text)
|
66
|
+
Account.new(:credit_card, name, number, balance, available)
|
63
67
|
end
|
64
68
|
end
|
65
69
|
end
|
66
70
|
end
|
67
|
-
end
|
71
|
+
end
|
@@ -3,7 +3,7 @@ module Dinero
|
|
3
3
|
class CapitalOne360 < Base
|
4
4
|
LOGIN_URL = "https://secure.capitalone360.com/myaccount/banking/login.vm"
|
5
5
|
ACCOUNTS_SUMMARY_URL = "https://secure.capitalone360.com/myaccount/banking/account_summary.vm"
|
6
|
-
|
6
|
+
|
7
7
|
def default_options
|
8
8
|
{ login_url: LOGIN_URL }
|
9
9
|
end
|
@@ -19,11 +19,11 @@ module Dinero
|
|
19
19
|
signin_form = connection.find_element(id: "Signin")
|
20
20
|
username_field = connection.find_element(id: "ACNID")
|
21
21
|
raise "Sign in Form not reached!" unless username_field && signin_form
|
22
|
-
|
22
|
+
|
23
23
|
username_field.send_keys username
|
24
24
|
signin_form.submit
|
25
25
|
end
|
26
|
-
|
26
|
+
|
27
27
|
def post_password!
|
28
28
|
begin
|
29
29
|
wait.until { connection.find_element(id: "PasswordForm").displayed? }
|
@@ -40,12 +40,19 @@ module Dinero
|
|
40
40
|
password_field.send_keys password
|
41
41
|
submit_button.click
|
42
42
|
end
|
43
|
-
|
43
|
+
|
44
|
+
def post_credentials!
|
45
|
+
post_username!
|
46
|
+
post_password!
|
47
|
+
end
|
48
|
+
|
44
49
|
def accounts_summary_page_fully_loaded?
|
45
50
|
tables = connection.find_elements css: 'table'
|
46
51
|
!(tables.empty? or tables.detect{|t| t.text =~ /\sLoading/})
|
52
|
+
rescue Selenium::WebDriver::Error::StaleElementReferenceError => error
|
53
|
+
false
|
47
54
|
end
|
48
|
-
|
55
|
+
|
49
56
|
def on_accounts_summary_page?
|
50
57
|
connection.current_url == ACCOUNTS_SUMMARY_URL
|
51
58
|
end
|
@@ -55,18 +62,18 @@ module Dinero
|
|
55
62
|
authenticated? ? connection.navigate.to(ACCOUNTS_SUMMARY_URL) : login!
|
56
63
|
wait.until { accounts_summary_page_fully_loaded? }
|
57
64
|
end
|
58
|
-
|
65
|
+
|
59
66
|
def balance_row? row
|
60
67
|
row[1] =~ /Total/
|
61
68
|
end
|
62
|
-
|
69
|
+
|
63
70
|
def promo_table? table
|
64
71
|
table.empty? or table[0].empty? or table[0][0].empty?
|
65
72
|
end
|
66
73
|
|
67
74
|
def decipher_account_type title
|
68
75
|
return :credit_card if title =~ /Credit Cards/
|
69
|
-
return :brokerage if title =~ /
|
76
|
+
return :brokerage if title =~ /Investing/
|
70
77
|
return :bank if title =~ /Checking/
|
71
78
|
return :unknown
|
72
79
|
end
|
@@ -75,46 +82,53 @@ module Dinero
|
|
75
82
|
return unless value
|
76
83
|
value.split("\u00A0").first.strip
|
77
84
|
end
|
78
|
-
|
85
|
+
|
79
86
|
# extract account data from the account summary page
|
80
87
|
def accounts
|
81
88
|
return @accounts if @accounts
|
82
89
|
@accounts = []
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
map{|
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
number
|
109
|
-
|
90
|
+
|
91
|
+
begin
|
92
|
+
# lots of spaces, tabs and the #00A0 characters, so extract
|
93
|
+
# text with this extraneous junk suppressed.
|
94
|
+
tables = accounts_summary_document.xpath("//table")
|
95
|
+
account_tables = tables.map do |table|
|
96
|
+
rows = table.xpath(".//tr").map{|row| row.xpath(".//td|.//th").
|
97
|
+
map{|cell| cell.text.strip.gsub(/\s+|\t/, " ")}}
|
98
|
+
end.reject{|table| promo_table? table }
|
99
|
+
|
100
|
+
# Turn tablular data into Account classes
|
101
|
+
account_tables.map do |table|
|
102
|
+
|
103
|
+
# the header row tells us what kind of account we're looking at
|
104
|
+
header = table.shift
|
105
|
+
account_type = decipher_account_type header[0]
|
106
|
+
has_account_number = header[1] =~ /Account/
|
107
|
+
|
108
|
+
# ignore balance rows at bottom of tables
|
109
|
+
rows = table.reject{|row| balance_row? row }
|
110
|
+
|
111
|
+
# turn those rows into accounts
|
112
|
+
rows.each do |row|
|
113
|
+
name = sanitize(row.shift)
|
114
|
+
number = (has_account_number ? sanitize(row.shift) : nil)
|
115
|
+
if number.nil? || name =~ /(\.{4})(\d+)\Z/
|
116
|
+
number = name.match(/(\.{4})(\d+)\Z/).captures.join
|
117
|
+
name = name.gsub(number,'')
|
118
|
+
end
|
119
|
+
balance = row.shift
|
120
|
+
available = row.shift || balance
|
121
|
+
@accounts << Account.new(account_type, name, number, balance, available)
|
110
122
|
end
|
111
|
-
balance = row.shift
|
112
|
-
available = row.shift || balance
|
113
|
-
@accounts << Account.new(account_type, name, number, balance, available)
|
114
123
|
end
|
124
|
+
rescue Exception => error
|
125
|
+
connection.save_screenshot('log/accounts.png')
|
126
|
+
error.backtrace.each { |line| puts line }
|
127
|
+
raise
|
115
128
|
end
|
129
|
+
|
116
130
|
return @accounts
|
117
131
|
end
|
118
132
|
end
|
119
133
|
end
|
120
|
-
end
|
134
|
+
end
|
@@ -0,0 +1,99 @@
|
|
1
|
+
module Dinero
|
2
|
+
module Bank
|
3
|
+
class SouthStateBank < Base
|
4
|
+
LOGIN_URL = "https://www.southstatebank.com/"
|
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
|
+
screenshot_on_error do
|
14
|
+
wait.until { connection.find_element(id: "desktop-splash-login") }
|
15
|
+
login_form = connection.find_element(id: "desktop_hero_form_online_banking")
|
16
|
+
username_field = login_form.find_element(id: "desktop_hero_input_online_banking")
|
17
|
+
username_field.send_keys username
|
18
|
+
|
19
|
+
submit_button = login_form.find_element(xpath: ".//input[@type='submit']")
|
20
|
+
submit_button.click
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def find_answer question
|
25
|
+
if q = security_questions.detect{ |qa| qa["question"] == question }
|
26
|
+
return q["answer"]
|
27
|
+
else
|
28
|
+
raise "Unknown security question: #{question.inspect}"
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def post_security_answer!
|
33
|
+
screenshot_on_error do
|
34
|
+
wait.until { connection.find_element(id: "nav2t") }
|
35
|
+
logon_form = connection.find_element(id: "Logon")
|
36
|
+
question_text = logon_form.find_element(xpath: ".//table/tbody/tr/td").text
|
37
|
+
answer = find_answer question_text
|
38
|
+
|
39
|
+
answer_field = logon_form.find_element(id: "QuestionAnswer")
|
40
|
+
answer_field.send_keys answer
|
41
|
+
|
42
|
+
submit_button = logon_form.find_element(id:"Submit")
|
43
|
+
submit_button.click
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def post_password!
|
48
|
+
wait.until { connection.find_element(id: "DisplayPassword") }
|
49
|
+
|
50
|
+
password_field = connection.find_element(id: "DisplayPassword")
|
51
|
+
password_field.send_keys password
|
52
|
+
|
53
|
+
login_btn = connection.find_element(id: "Submit")
|
54
|
+
login_btn.click
|
55
|
+
end
|
56
|
+
|
57
|
+
def post_credentials!
|
58
|
+
post_username!
|
59
|
+
post_security_answer!
|
60
|
+
post_password!
|
61
|
+
end
|
62
|
+
|
63
|
+
def after_successful_login
|
64
|
+
# the subdomain frequently changes, so capture the actual URL
|
65
|
+
# so we can return to the page if necessary.
|
66
|
+
@accounts_summary_url = connection.current_url
|
67
|
+
end
|
68
|
+
|
69
|
+
def on_accounts_summary_page?
|
70
|
+
connection.page_source =~ /List of Accounts/
|
71
|
+
end
|
72
|
+
|
73
|
+
def goto_accounts_summary_page
|
74
|
+
return if authenticated? && on_accounts_summary_page?
|
75
|
+
authenticated? ? connection.navigate.to(@accounts_summary_url) : login!
|
76
|
+
end
|
77
|
+
|
78
|
+
def account_table_rows
|
79
|
+
accounts_summary_document.xpath("//ul[@class='AccountList-Accounts']/li/table/tbody/tr")
|
80
|
+
end
|
81
|
+
|
82
|
+
# extract account data from the account summary page
|
83
|
+
def accounts
|
84
|
+
return @accounts if @accounts
|
85
|
+
|
86
|
+
# find the bricklet articles, which contains the balance data
|
87
|
+
@accounts = account_table_rows.map do |row|
|
88
|
+
data = row.xpath(".//td").map(&:text)
|
89
|
+
number = data.shift
|
90
|
+
name = data.shift
|
91
|
+
balance = data.pop.scan(NUMERIC_REGEXP).join
|
92
|
+
available = data.pop.scan(NUMERIC_REGEXP).join
|
93
|
+
available = balance if available.empty?
|
94
|
+
Account.new(:bank, name, number, balance, available)
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
data/lib/dinero/banks.rb
CHANGED
@@ -3,15 +3,16 @@ module Dinero
|
|
3
3
|
DEFAULT_TIMEOUT = 5
|
4
4
|
|
5
5
|
class Base
|
6
|
-
attr_reader :username, :password
|
6
|
+
attr_reader :username, :password, :security_questions
|
7
7
|
attr_reader :timeout, :login_url
|
8
|
-
|
8
|
+
|
9
9
|
def initialize options
|
10
10
|
opts = default_options.merge options
|
11
|
-
|
11
|
+
|
12
12
|
@username = opts[:username]
|
13
13
|
@password = opts[:password]
|
14
14
|
@login_url = opts[:login_url]
|
15
|
+
@security_questions = opts[:security_questions] || []
|
15
16
|
@timeout = opts[:timeout] || DEFAULT_TIMEOUT
|
16
17
|
@authenticated = false
|
17
18
|
validate!
|
@@ -22,13 +23,21 @@ module Dinero
|
|
22
23
|
raise "Must supply :password" if @password.to_s.empty?
|
23
24
|
raise "Must have a :login_url" if @login_url.to_s.empty?
|
24
25
|
end
|
25
|
-
|
26
|
+
|
26
27
|
def default_options
|
27
28
|
{}
|
28
29
|
end
|
29
|
-
|
30
|
+
|
30
31
|
def establish_connection
|
31
|
-
Selenium::WebDriver.
|
32
|
+
capabilities = Selenium::WebDriver::Remote::Capabilities.phantomjs(
|
33
|
+
'acceptSslCerts' => true,
|
34
|
+
'phantomjs.page.settings.userAgent' => 'Mozilla/5.0 (X11; Linux x86_64; rv:31.0) Gecko/20100101 Firefox/33.0',
|
35
|
+
'service_args' => ['--ignore-ssl-errors=true']
|
36
|
+
)
|
37
|
+
|
38
|
+
driver = Selenium::WebDriver.for :phantomjs, :desired_capabilities => capabilities
|
39
|
+
driver.manage.window.size = Selenium::WebDriver::Dimension.new(1640, 768)
|
40
|
+
driver
|
32
41
|
end
|
33
42
|
|
34
43
|
def connection
|
@@ -42,7 +51,7 @@ module Dinero
|
|
42
51
|
def wait
|
43
52
|
@wait ||= Selenium::WebDriver::Wait.new(:timeout => timeout)
|
44
53
|
end
|
45
|
-
|
54
|
+
|
46
55
|
def accounts_summary_document
|
47
56
|
return @accounts_summary_document if @accounts_summary_document
|
48
57
|
|
@@ -51,24 +60,48 @@ module Dinero
|
|
51
60
|
end
|
52
61
|
|
53
62
|
def after_successful_login
|
54
|
-
# NOP
|
63
|
+
# NOP
|
64
|
+
end
|
65
|
+
|
66
|
+
def class_name
|
67
|
+
self.class.to_s.downcase.gsub("dinero::bank::", '')
|
68
|
+
end
|
69
|
+
|
70
|
+
def snap filename
|
71
|
+
filename = filename + '.png' unless filename =~ /\.png$/
|
72
|
+
connection.save_screenshot "log/#{filename}"
|
73
|
+
end
|
74
|
+
|
75
|
+
def screenshot_on_error name = nil
|
76
|
+
begin
|
77
|
+
yield
|
78
|
+
rescue
|
79
|
+
unless name
|
80
|
+
class_name, method_name = caller.first.match(/(\w+)\.rb\:\d+\:in\s\`([^\']+)/).captures
|
81
|
+
name = "#{class_name}_#{method_name.gsub(/\W/, '')}"
|
82
|
+
end
|
83
|
+
snap "#{name}_error" unless @captured_error
|
84
|
+
@captured_error = true
|
85
|
+
raise
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
def goto_login_page
|
90
|
+
connection.navigate.to login_url
|
91
|
+
snap "#{class_name}_login_page.png"
|
55
92
|
end
|
56
93
|
|
57
94
|
def login!
|
58
95
|
return if authenticated?
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
post_password!
|
96
|
+
screenshot_on_error do
|
97
|
+
goto_login_page
|
98
|
+
post_credentials!
|
63
99
|
wait.until { on_accounts_summary_page? }
|
64
100
|
after_successful_login
|
65
101
|
@authenticated = true
|
66
|
-
rescue
|
67
|
-
connection.save_screenshot('log/#{self.to_s.downcase}_login_failure.png')
|
68
|
-
raise
|
69
102
|
end
|
70
103
|
end
|
71
104
|
|
72
105
|
end
|
73
106
|
end
|
74
|
-
end
|
107
|
+
end
|
data/lib/dinero/version.rb
CHANGED
@@ -1,3 +1,3 @@
|
|
1
1
|
module Dinero
|
2
|
-
VERSION = "0.0.
|
3
|
-
end
|
2
|
+
VERSION = "0.0.3"
|
3
|
+
end
|
data/lib/dinero.rb
CHANGED
@@ -5,14 +5,14 @@ if bank_configured? :capital_one_360
|
|
5
5
|
RSpec.describe Dinero::Bank::CapitalOne360 do
|
6
6
|
let(:bank_configuration) { bank_configurations[:capital_one_360] }
|
7
7
|
let(:account_types) { bank_configuration[:account_types].sort }
|
8
|
-
|
8
|
+
|
9
9
|
before(:all) do
|
10
|
-
VCR.use_cassette("accounts_capital_one_360") do
|
10
|
+
VCR.use_cassette("accounts_capital_one_360", record: :new_episodes) do
|
11
11
|
@bank = Dinero::Bank::CapitalOne360.new(bank_configurations[:capital_one_360])
|
12
12
|
@bank.accounts
|
13
13
|
end
|
14
14
|
end
|
15
|
-
|
15
|
+
|
16
16
|
it "has expected timeout" do
|
17
17
|
expect(@bank.timeout).to eq Dinero::Bank::DEFAULT_TIMEOUT
|
18
18
|
end
|
@@ -20,22 +20,22 @@ if bank_configured? :capital_one_360
|
|
20
20
|
it "retrieves accounts" do
|
21
21
|
expect(@bank.accounts).to_not be_empty
|
22
22
|
end
|
23
|
-
|
24
|
-
it "extracts account names" do
|
23
|
+
|
24
|
+
it "extracts account names" do
|
25
25
|
expect(@bank.accounts.map(&:name)).to include /Checking|Savings/
|
26
26
|
end
|
27
|
-
|
28
|
-
it "does not include junk data in the names" do
|
27
|
+
|
28
|
+
it "does not include junk data in the names" do
|
29
29
|
@bank.accounts.each{|acct| expect(acct.name).to_not include "Opens a new window"}
|
30
30
|
end
|
31
|
-
|
32
|
-
it "extracts account numbers" do
|
31
|
+
|
32
|
+
it "extracts account numbers" do
|
33
33
|
expect(@bank.accounts.map(&:number).select{|s| s.to_s.scan /\A\d+\Z/}).to_not be_empty
|
34
34
|
end
|
35
|
-
|
36
|
-
it "sets account types" do
|
35
|
+
|
36
|
+
it "sets account types" do
|
37
37
|
expect(@bank.accounts.map(&:account_type).uniq.sort).to eq account_types
|
38
38
|
end
|
39
39
|
end
|
40
|
-
|
41
|
-
end
|
40
|
+
|
41
|
+
end
|
@@ -1,14 +1,14 @@
|
|
1
1
|
require 'spec_helper'
|
2
2
|
|
3
|
-
if bank_configured? :capital_one
|
3
|
+
if bank_configured? :capital_one
|
4
4
|
|
5
5
|
RSpec.describe Dinero::Bank::CapitalOne do
|
6
6
|
let(:bank_configuration) { bank_configurations[:capital_one] }
|
7
7
|
let(:account_types) { bank_configuration[:account_types].sort }
|
8
8
|
let(:accounts) { bank_configuration[:accounts] }
|
9
|
-
|
9
|
+
|
10
10
|
before(:all) do
|
11
|
-
VCR.use_cassette("accounts_capital_one") do
|
11
|
+
VCR.use_cassette("accounts_capital_one", record: :new_episodes) do
|
12
12
|
@bank = Dinero::Bank::CapitalOne.new(bank_configurations[:capital_one])
|
13
13
|
@bank.accounts
|
14
14
|
end
|
@@ -17,16 +17,16 @@ if bank_configured? :capital_one
|
|
17
17
|
it "has expected timeout" do
|
18
18
|
expect(@bank.timeout).to eq Dinero::Bank::CapitalOne::CONNECTION_TIMEOUT
|
19
19
|
end
|
20
|
-
|
21
|
-
it "authenticates" do
|
20
|
+
|
21
|
+
it "authenticates" do
|
22
22
|
@bank.login!
|
23
23
|
expect(@bank.authenticated?).to eq true
|
24
24
|
end
|
25
|
-
|
25
|
+
|
26
26
|
it "retrieves accounts_summary_document" do
|
27
27
|
expect(@bank.accounts_summary_document).to be_kind_of Nokogiri::HTML::Document
|
28
28
|
end
|
29
|
-
|
29
|
+
|
30
30
|
it "has article sections" do
|
31
31
|
expect(@bank.accounts_summary_document.xpath("//article").size).to eq accounts + 1
|
32
32
|
end
|
@@ -34,18 +34,27 @@ if bank_configured? :capital_one
|
|
34
34
|
it "gets expected accounts" do
|
35
35
|
expect(@bank.accounts.size).to eq accounts
|
36
36
|
end
|
37
|
-
|
38
|
-
it "extracts account names" do
|
37
|
+
|
38
|
+
it "extracts account names" do
|
39
39
|
expect(@bank.accounts.map(&:name)).to include /MasterCard|Visa/
|
40
40
|
end
|
41
|
-
|
42
|
-
it "extracts account numbers" do
|
41
|
+
|
42
|
+
it "extracts account numbers" do
|
43
43
|
expect(@bank.accounts.map(&:number).select{|s| s.to_s.scan /\A[\.|\d]+\Z/}).to_not be_empty
|
44
44
|
end
|
45
|
-
|
46
|
-
it "
|
45
|
+
|
46
|
+
it "expects balances to be greater than zero" do
|
47
|
+
expect(@bank.accounts.map(&:balance).any?(&:zero?)).to eq false
|
48
|
+
end
|
49
|
+
|
50
|
+
it "expects availables to be greater than zero" do
|
51
|
+
expect(@bank.accounts.map(&:available).any?(&:zero?)).to eq false
|
52
|
+
end
|
53
|
+
|
54
|
+
|
55
|
+
it "sets account types" do
|
47
56
|
expect(@bank.accounts.map(&:account_type).uniq).to eq account_types
|
48
57
|
end
|
49
58
|
end
|
50
|
-
|
51
|
-
end
|
59
|
+
|
60
|
+
end
|
@@ -0,0 +1,67 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
if bank_configured? :south_state_bank
|
4
|
+
|
5
|
+
RSpec.describe Dinero::Bank::SouthStateBank do
|
6
|
+
let(:bank_configuration) { bank_configurations[:south_state_bank] }
|
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_south_state_bank", record: :new_episodes) do
|
12
|
+
@bank = Dinero::Bank::SouthStateBank.new(bank_configurations[:south_state_bank])
|
13
|
+
@bank.accounts
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
it "has security questions" do
|
18
|
+
expect(@bank.security_questions.count).to eq 3
|
19
|
+
end
|
20
|
+
|
21
|
+
it "finds favorite hobby answer" do
|
22
|
+
expect(@bank.find_answer("What is your favorite hobby?")).to eq "tennis"
|
23
|
+
end
|
24
|
+
|
25
|
+
it "posts credentials" do
|
26
|
+
expect(@bank.authenticated?).to be true
|
27
|
+
end
|
28
|
+
|
29
|
+
it "authenticates" do
|
30
|
+
@bank.login!
|
31
|
+
expect(@bank.authenticated?).to eq true
|
32
|
+
end
|
33
|
+
|
34
|
+
it "retrieves accounts_summary_document" do
|
35
|
+
expect(@bank.accounts_summary_document).to be_kind_of Nokogiri::HTML::Document
|
36
|
+
end
|
37
|
+
|
38
|
+
it "has account line items" do
|
39
|
+
expect(@bank.account_table_rows.size).to eq 3
|
40
|
+
end
|
41
|
+
|
42
|
+
it "gets expected accounts" do
|
43
|
+
expect(@bank.accounts.size).to eq accounts
|
44
|
+
end
|
45
|
+
|
46
|
+
it "extracts account names" do
|
47
|
+
expect(@bank.accounts.map(&:name)).to include "Joint Acct"
|
48
|
+
end
|
49
|
+
|
50
|
+
it "extracts account numbers" do
|
51
|
+
expect(@bank.accounts.map(&:number).first).to start_with "******"
|
52
|
+
end
|
53
|
+
|
54
|
+
it "expects balances to be greater than zero" do
|
55
|
+
expect(@bank.accounts.map(&:balance).any?(&:zero?)).to eq false
|
56
|
+
end
|
57
|
+
|
58
|
+
it "expects availables to be greater than zero" do
|
59
|
+
expect(@bank.accounts.map(&:available).any?(&:zero?)).to eq false
|
60
|
+
end
|
61
|
+
|
62
|
+
it "sets account types" do
|
63
|
+
expect(@bank.accounts.map(&:account_type).uniq).to eq account_types
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: dinero
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.0.
|
4
|
+
version: 0.0.3
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Michael Lang
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2016-11-30 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: bundler
|
@@ -128,14 +128,14 @@ dependencies:
|
|
128
128
|
requirements:
|
129
129
|
- - "~>"
|
130
130
|
- !ruby/object:Gem::Version
|
131
|
-
version:
|
131
|
+
version: '3.0'
|
132
132
|
type: :runtime
|
133
133
|
prerelease: false
|
134
134
|
version_requirements: !ruby/object:Gem::Requirement
|
135
135
|
requirements:
|
136
136
|
- - "~>"
|
137
137
|
- !ruby/object:Gem::Version
|
138
|
-
version:
|
138
|
+
version: '3.0'
|
139
139
|
- !ruby/object:Gem::Dependency
|
140
140
|
name: nokogiri
|
141
141
|
requirement: !ruby/object:Gem::Requirement
|
@@ -160,6 +160,7 @@ extensions: []
|
|
160
160
|
extra_rdoc_files: []
|
161
161
|
files:
|
162
162
|
- ".gitignore"
|
163
|
+
- ".rspec"
|
163
164
|
- Gemfile
|
164
165
|
- Gemfile.lock
|
165
166
|
- MIT-LICENSE
|
@@ -172,10 +173,12 @@ files:
|
|
172
173
|
- lib/dinero/banks.rb
|
173
174
|
- lib/dinero/banks/capital_one.rb
|
174
175
|
- lib/dinero/banks/capital_one_360.rb
|
176
|
+
- lib/dinero/banks/south_state_bank.rb
|
175
177
|
- lib/dinero/version.rb
|
176
178
|
- log/.keep
|
177
179
|
- spec/banks/capital_one_360_spec.rb
|
178
180
|
- spec/banks/capital_one_spec.rb
|
181
|
+
- spec/banks/south_state_bank_spec.rb
|
179
182
|
- spec/fixtures/vcr_cassettes/.keep
|
180
183
|
- spec/spec_helper.rb
|
181
184
|
- spec/support/config.rb
|
@@ -199,7 +202,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
199
202
|
version: '0'
|
200
203
|
requirements: []
|
201
204
|
rubyforge_project:
|
202
|
-
rubygems_version: 2.
|
205
|
+
rubygems_version: 2.5.1
|
203
206
|
signing_key:
|
204
207
|
specification_version: 4
|
205
208
|
summary: Dinero automates the process of logging into banking and financial websites
|
@@ -207,6 +210,7 @@ summary: Dinero automates the process of logging into banking and financial webs
|
|
207
210
|
test_files:
|
208
211
|
- spec/banks/capital_one_360_spec.rb
|
209
212
|
- spec/banks/capital_one_spec.rb
|
213
|
+
- spec/banks/south_state_bank_spec.rb
|
210
214
|
- spec/fixtures/vcr_cassettes/.keep
|
211
215
|
- spec/spec_helper.rb
|
212
216
|
- spec/support/config.rb
|