)?!){ syntax_items[$1.to_i] }
66 | end
67 | stat = File.stat(src)
68 | created = stat.ctime
69 | modified = stat.mtime
70 |
71 | $stdout << template.result(binding)
72 |
--------------------------------------------------------------------------------
/spec/transaction_spec.rb:
--------------------------------------------------------------------------------
1 | require File.expand_path(File.dirname(__FILE__) + '/spec_helper')
2 |
3 | require 'bankjob.rb'
4 | include Bankjob
5 |
6 | describe Transaction do
7 | before(:each) do
8 | @tx1 = Transaction.new()
9 | @tx1.date = "30-7-2008"
10 | @tx1.value_date = "20080731145906"
11 | @tx1.raw_description = "Some tax thing 10493"
12 | @tx1.amount = "-2,40"
13 | @tx1.new_balance = "1.087,43"
14 |
15 | @tx1_copy = Transaction.new()
16 | @tx1_copy.date = "30-7-2008"
17 | @tx1_copy.value_date = "20080731145906"
18 | @tx1_copy.raw_description = "Some tax thing 10493"
19 | @tx1_copy.amount = "-2,40"
20 | @tx1_copy.new_balance = "1.087,43"
21 |
22 | @tx1_dup = @tx1.dup
23 |
24 | @tx2 = Transaction.new()
25 | @tx2.date = "0080729000000"
26 | @tx2.value_date = "20080731145906"
27 | @tx2.raw_description = "Interest payment"
28 | @tx2.amount = "-59,94"
29 | @tx2.new_balance = "1.089,83"
30 | end
31 |
32 | it "should generate the same ofx_id as its copy" do
33 | puts "tx1: #{@tx1.to_s}\n-----"
34 | puts "tx1_copy: #{@tx1.to_s}"
35 | @tx1.ofx_id.should == @tx1_copy.ofx_id
36 | puts "#{@tx1.ofx_id} == #{@tx1_copy.ofx_id}"
37 | end
38 |
39 | it "should generate the same ofx_id as its duplicate" do
40 | @tx1.ofx_id.should == @tx1_dup.ofx_id
41 | end
42 |
43 |
44 | it "should be == to its duplicate" do
45 | @tx1.should == @tx1_dup
46 | end
47 |
48 | it "should be == to its identical copy" do
49 | @tx1.should == @tx1_copy
50 | end
51 |
52 | it "should not == a different transaction" do
53 | @tx1.should_not == @tx2
54 | end
55 |
56 | it "should be eql to its duplicate (necessary for merging)" do
57 | @tx1.should eql(@tx1_dup)
58 | end
59 |
60 | it "should not be equal to its duplicate" do
61 | @tx1.should_not equal(@tx1_dup)
62 | end
63 |
64 | it "should be === to its duplicate" do
65 | @tx1.should === @tx1_dup
66 | end
67 |
68 | it "should have the same hash as its duplicate" do
69 | @tx1.hash.should == @tx1_dup.hash
70 | end
71 |
72 | it "should convert 1,000,000.32 to 1000000.32 when decimal separator is ." do
73 | Bankjob.string_to_float("1,000,000.32", ".").should == 1000000.32
74 | end
75 |
76 | it "should convert 1.000.000,32 to 1000000.32 when decimal separator is ," do
77 | Bankjob.string_to_float("1.000.000,32", ",").should == 1000000.32
78 | end
79 |
80 | end
81 |
82 |
--------------------------------------------------------------------------------
/website/stylesheets/screen.css:
--------------------------------------------------------------------------------
1 | body {
2 | background-color: #8DBD82;
3 | font-family: "Georgia", sans-serif;
4 | font-size: 16px;
5 | line-height: 1.6em;
6 | padding: 1.6em 0 0 0;
7 | color: #333;
8 | }
9 | h1, h2, h3, h4, h5, h6 {
10 | color: #444;
11 | }
12 | h1 {
13 | font-family: sans-serif;
14 | font-weight: normal;
15 | font-size: 4em;
16 | line-height: 0.8em;
17 | letter-spacing: -0.1ex;
18 | margin: 5px;
19 | }
20 | li {
21 | padding: 0;
22 | margin: 0;
23 | list-style-type: square;
24 | }
25 | a {
26 | color: #5E5AFF;
27 | background-color: #A1DDB1;
28 | font-weight: normal;
29 | text-decoration: underline;
30 | }
31 | blockquote {
32 | font-size: 90%;
33 | font-style: italic;
34 | border-left: 1px solid #111;
35 | padding-left: 1em;
36 | }
37 | .caps {
38 | font-size: 80%;
39 | }
40 |
41 | #main {
42 | width: 55em;
43 | padding: 0;
44 | margin: 0 auto;
45 | }
46 | .coda {
47 | text-align: right;
48 | color: #77f;
49 | font-size: smaller;
50 | }
51 |
52 | table {
53 | font-size: 90%;
54 | line-height: 1.4em;
55 | color: #ff8;
56 | background-color: #111;
57 | padding: 2px 10px 2px 10px;
58 | border-style: dashed;
59 | }
60 |
61 | th {
62 | color: #fff;
63 | }
64 |
65 | td {
66 | padding: 2px 10px 2px 10px;
67 | }
68 |
69 | .success {
70 | color: #0CC52B;
71 | }
72 |
73 | .failed {
74 | color: #E90A1B;
75 | }
76 |
77 | .unknown {
78 | color: #995000;
79 | }
80 | pre, code {
81 | font-family: monospace;
82 | font-size: 90%;
83 | line-height: 1.4em;
84 | color: #ff8;
85 | background-color: #111;
86 | width: 40em;
87 | padding: 2px 10px 2px 10px;
88 | }
89 | .comment { color: #aaa; font-style: italic; }
90 | .keyword { color: #eff; font-weight: bold; }
91 | .punct { color: #eee; font-weight: bold; }
92 | .symbol { color: #0bb; }
93 | .string { color: #6b4; }
94 | .ident { color: #ff8; }
95 | .constant { color: #66f; }
96 | .regex { color: #ec6; }
97 | .number { color: #F99; }
98 | .expr { color: #227; }
99 |
100 | .sidebar {
101 | float: right;
102 | }
103 |
104 | #version {
105 | width: 217px;
106 | text-align: right;
107 | font-family: sans-serif;
108 | font-weight: normal;
109 | color: #141331;
110 | padding: 15px 20px 10px 20px;
111 | margin: 0 auto;
112 | margin-top: 15px;
113 | background-color: #9A5535;
114 | border: 3px solid #7E393E;
115 | }
116 |
117 | #version .numbers {
118 | display: block;
119 | font-size: 4em;
120 | line-height: 0.8em;
121 | letter-spacing: -0.1ex;
122 | margin-bottom: 15px;
123 | }
124 |
125 | #version p {
126 | text-decoration: none;
127 | color: #F1F4FF;
128 | background-color: #9A5535;
129 | margin: 0;
130 | padding: 0;
131 | }
132 |
133 | #version a {
134 | text-decoration: none;
135 | color: #F1F4FF;
136 | background-color: #9A5535;
137 | }
138 |
139 | .clickable {
140 | cursor: pointer;
141 | cursor: hand;
142 | }
143 |
144 | #twitter_search {
145 | margin: 40px 0 10px 15px;
146 | color: #F1F4FF;
147 | background-color: #9A5535;
148 | border: 3px solid #7E393E;
149 | }
150 |
151 | #twitter_search h3 {
152 | color: #F1F4FF;
153 | margin-bottom: 0px;
154 | }
155 |
156 | #twitter_search center b {
157 | display: none;
158 | }
159 |
160 |
--------------------------------------------------------------------------------
/spec/statement_spec.rb:
--------------------------------------------------------------------------------
1 | require File.expand_path(File.dirname(__FILE__) + '/spec_helper')
2 | #require File.expand_path(File.dirname(__FILE__) + '/../lib/bankjob.rb')
3 |
4 | include Bankjob
5 |
6 | # Test the Statement merging in particular
7 | describe Statement do
8 | before(:each) do
9 |
10 | @tx1 = Transaction.new(",")
11 | @tx1.date = "20080730000000"
12 | @tx1.value_date = "20080731145906"
13 | @tx1.raw_description = "1 Stamp duty 001"
14 | @tx1.amount = "-2,40"
15 | @tx1.new_balance = "1.087,43"
16 |
17 |
18 | @tx2 = Transaction.new(",")
19 | @tx2.date = "0080729000000"
20 | @tx2.value_date = "20080731145906"
21 | @tx2.raw_description = "2 Interest payment 001"
22 | @tx2.amount = "-59,94"
23 | @tx2.new_balance = "1.089,83"
24 |
25 |
26 | @tx3 = Transaction.new(",")
27 | @tx3.date = "20080208000000"
28 | @tx3.value_date = "20080731145906"
29 | @tx3.raw_description = "3 Load payment 001"
30 | @tx3.amount = "-256,13"
31 | @tx3.new_balance = "1.149,77"
32 |
33 |
34 | @tx4 = Transaction.new(",")
35 | @tx4.date = "20080207000000"
36 | @tx4.value_date = "20080731145906"
37 | @tx4.raw_description = "4 Transfer to bank 2"
38 | @tx4.amount = "-1.000,00"
39 | @tx4.new_balance = "1.405,90"
40 |
41 |
42 | @tx5 = Transaction.new(",")
43 | @tx5.date = "20080209000000"
44 | @tx5.value_date = "20080731145906"
45 | @tx5.raw_description = "5 Internet payment 838"
46 | @tx5.amount = "-32,07"
47 | @tx5.new_balance = "1.405,90"
48 |
49 | # the lot
50 | @s12345 = Statement.new
51 | @s12345.transactions = [ @tx1.dup, @tx2.dup, @tx3.dup, @tx4.dup, @tx5.dup]
52 |
53 | # first 2
54 | @s12 = Statement.new
55 | @s12.transactions = [ @tx1.dup, @tx2.dup]
56 |
57 | # middle 1
58 | @s3 = Statement.new
59 | @s3.transactions = [ @tx3.dup]
60 |
61 | # last 2
62 | @s45 = Statement.new
63 | @s45.transactions = [ @tx4.dup, @tx5.dup]
64 |
65 | # first 3
66 | @s123 = Statement.new
67 | @s123.transactions = [ @tx1.dup, @tx2.dup, @tx3.dup]
68 |
69 | # last 4, overlaps with 23 of s123
70 | @s2345 = Statement.new
71 | @s2345.transactions = [ @tx2.dup, @tx3.dup, @tx4.dup, @tx5.dup]
72 |
73 | # 2nd and last - overlaps non-contiguously with s123
74 | @s25 = Statement.new
75 | @s25.transactions = [ @tx2.dup, @tx5.dup]
76 |
77 | end
78 |
79 | it "should merge consecutive satements properly" do
80 | @s123.merge(@s45).should == @s12345
81 | end
82 |
83 | it "should merge overlapping statments properly" do
84 | #@s123.merge(@s2345).transactions.each { |tx| print "#{tx.to_s}, "}
85 | @s123.merge(@s2345).should == @s12345
86 | end
87 |
88 | it "should merge a statement with a duplicate of itself without changing it" do
89 | @s123.merge(@s123.dup).should == @s123
90 | end
91 |
92 |
93 | it "should merge non-contiguous with an error" do
94 | m = @s123.merge(@s25)
95 | m.transactions.each { |tx| print "#{tx.to_s}, "}
96 | end
97 |
98 | it "should read back a satement from csv as it was written" do
99 | csv = @s123.to_csv
100 | statement = Statement.new()
101 | statement.from_csv(csv, ",")
102 | statement.should == @s123
103 | end
104 |
105 | it "should read back and merge a statement with itself without change" do
106 | csv = @s123.to_csv
107 | statement = Statement.new()
108 | statement.from_csv(csv, ",")
109 | m = @s123.merge(statement)
110 | m.should == @s123
111 | end
112 |
113 | it "should write, read, merge and write a statement without changing it" do
114 | csv = @s123.to_csv
115 | statement = Statement.new()
116 | m = @s123.merge(statement)
117 | m_csv = m.to_csv
118 | m_csv.should == csv
119 | end
120 | end
121 |
122 |
--------------------------------------------------------------------------------
/README.rdoc:
--------------------------------------------------------------------------------
1 | = bankjob
2 |
3 | http://bankjob.rubyforge.org/
4 |
5 | == DESCRIPTION:
6 |
7 | Bankjob is a command-line ruby program for scraping online banking sites and producing statements in OFX (Open Fincancial Exchange) or CSV (Comma Separated Values) formats.
8 |
9 | Bankjob was created for people like me who want to get their bank data into a 3rd party application but whose bank does not support downloads in OFX format.
10 | It's also useful for keeping a permanent store of bank statements on your computer for reading in Excel (vs filing paper statements)
11 |
12 | == FEATURES:
13 |
14 | * Scrapes an online banking website to produce a bank statement
15 | * Stores bank statements locally in CSV files, which can be loaded directly in spreadsheets like Microsoft Excel
16 | * Stores bank statements locally in OFX files, which can be imported by many programs such as Quicken, MS Money, Gnu Cash and uploaded to some web applications
17 | * Built-in support for uploading to your Wesabe account (www.wesabe.com)
18 | * Supports coding of simple rules in ruby for modifying transaction details. E.g. automatically change "pment inst 3245003" to "paid home loan interest"
19 |
20 | == SYNOPSIS:
21 |
22 | bankjob --csv c:\bank\csv --scraper c:\bank\my_bpi_scraper.rb
23 | --scraper-args ""
24 | --wesabe "
25 | --log c:\bank\bankjob.log --debug
26 |
27 | I have this command in a .bat file which is launched daily by a scheduled task on my windows Media Center PC (which, since it's always on and connected to the internet, makes a useful home server)
28 |
29 | This one command will:
30 | * scrape my online banking website after logging in as me and navigating to the page with recent transactions
31 | * apply some rules, coded in the my_bpi_scraper.rb file that make the descriptions more readable
32 | * produce a statement in comma-separated-value format, keeping the original raw data as well as the new descriptions,
33 | storing that in a file with a name like "20090327-20090406.csv" in my local directory c:\bank\csv (a permanent record)
34 | * produce an OFX document with the same statement information
35 | * upload the OFX statement to my wesabe account
36 | * log debug-level details in bankjob.log
37 |
38 | == REQUIREMENTS:
39 |
40 | * Runs in ruby so you need to have ruby installed
41 | * Requires a scraper for your online bank site
42 | Some examples come packaged with Bankjob but you will probably need to write your own scraper in ruby.
43 | For help go to http://groups.google.com/group/bankjob, but read http://bankjob.rubyforge.org first.
44 |
45 | == INSTALL:
46 |
47 | Mac OSX (linux):
48 |
49 | sudo gem install bankjob
50 |
51 | Windows:
52 | gem install bankjob
53 |
54 | == LICENSE:
55 |
56 | (The MIT License)
57 |
58 | Copyright (c) 2009 rubarb.bankjob@gmail.com
59 |
60 | Permission is hereby granted, free of charge, to any person obtaining
61 | a copy of this software and associated documentation files (the
62 | 'Software'), to deal in the Software without restriction, including
63 | without limitation the rights to use, copy, modify, merge, publish,
64 | distribute, sublicense, and/or sell copies of the Software, and to
65 | permit persons to whom the Software is furnished to do so, subject to
66 | the following conditions:
67 |
68 | The above copyright notice and this permission notice shall be
69 | included in all copies or substantial portions of the Software.
70 |
71 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
72 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
73 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
74 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
75 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
76 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
77 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
78 |
--------------------------------------------------------------------------------
/lib/bankjob/payee.rb:
--------------------------------------------------------------------------------
1 |
2 | require 'rubygems'
3 | require 'builder'
4 | require 'digest/md5'
5 |
6 | module Bankjob
7 |
8 | ##
9 | # A Payee object represents an entity in a in a bank Transaction that receives a payment.
10 | #
11 | # A Scraper will create Payees while scraping web pages in an online banking site.
12 | # In many cases Payees will not be distinguished in the online bank site in which case
13 | # rules will have to be applied to separate the Payees
14 | #
15 | # A Payee object knows how to write itself as a record in a CSV
16 | # (Comma Separated Values) file using +to_csv+ or as an XML element in an
17 | # OFX (Open Financial eXchange http://www.ofx.net) file using +to_ofx+
18 | #
19 | class Payee
20 |
21 | # name of the payee
22 | # Translates to OFX element NAME
23 | attr_accessor :name
24 |
25 | # address of the payee
26 | # Translates to OFX element ADDR1
27 | #-- TODO Consider ADDR2,3
28 | attr_accessor :address
29 |
30 | # city in which the payee is located
31 | # Translates to OFX element CITY
32 | attr_accessor :city
33 |
34 | # state in which the payee is located
35 | # Translates to OFX element STATE
36 | attr_accessor :state
37 |
38 | # post code or zip in which the payee is located
39 | # Translates to OFX element POSTALCODE
40 | attr_accessor :postalcode
41 |
42 | # country in which the payee is located
43 | # Translates to OFX element COUNTRY
44 | attr_accessor :country
45 |
46 | # phone number of the payee
47 | # Translates to OFX element PHONE
48 | attr_accessor :phone
49 |
50 | ##
51 | # Generates a string representing this Payee as a single string for use
52 | # in a comma separated values column
53 | #
54 | def to_csv
55 | name
56 | end
57 |
58 | ##
59 | # Generates an XML string adhering to the OFX standard
60 | # (see Open Financial Exchange http://www.ofx.net)
61 | # representing a single Payee XML element.
62 | #
63 | # The schema for the OFX produced is
64 | #
65 | #
66 | #
67 | #
68 | # The OFX element "PAYEE" is of type "Payee"
69 | #
70 | #
71 | #
72 | #
73 | #
74 | #
75 | #
76 | #
77 | #
78 | #
79 | #
80 | #
81 | #
82 | #
83 | #
84 | #
85 | #
86 | #
87 | #
88 | def to_ofx
89 | buf = ""
90 | # Set margin=6 to indent it nicely within the output from Transaction.to_ofx
91 | x = Builder::XmlMarkup.new(:target => buf, :indent => 2, :margin=>6)
92 | x.PAYEE {
93 | x.NAME name
94 | x.ADDR1 address
95 | x.CITY city
96 | x.STATE state
97 | x.POSTALCODE postalcode
98 | x.COUNTRY country unless country.nil? # minOccurs="0" in schema (above)
99 | x.PHONE phone
100 | }
101 | return buf
102 | end
103 |
104 | ##
105 | # Produces the Payee as a row of comma separated values
106 | # (delegates to +to_csv+)
107 | #
108 | def to_s
109 | to_csv
110 | end
111 |
112 | end # class Payee
113 | end # module
114 |
115 |
--------------------------------------------------------------------------------
/scrapers/base_scraper.rb:
--------------------------------------------------------------------------------
1 | require 'rubygems'
2 | require 'mechanize'
3 | require 'hpricot'
4 | require 'bankjob'
5 |
6 | # Later versions of Mechanize no longer use Hpricot by default
7 | # but have an attribute we can set to use it
8 | begin
9 | WWW::Mechanize.html_parser = Hpricot
10 | rescue NoMethodError
11 | end
12 |
13 | include Bankjob
14 |
15 | ##
16 | # BaseScraper is a specific example of a Bankjob Scraper that can be used as a base
17 | # class for scrapers that follow a typical pattern.
18 | #
19 | # In fact, it does not add much functionality and you could just as readily subclass
20 | # the Scraper class as this class, but here is what it does add:
21 | # *+scraper_args+ attribute holds the array of args specified by the -scraper_args command line option
22 | # *+scrape_statement+ is implemented to use the --input command line option to specify a file for input
23 | # so that you can save a web-page to a file for debugging
24 | # *+scrape_statement+ instantiates a Mechanize agent and delegates to two other
25 | # simple methods that must be overridden in a subclass.
26 | #
27 | # Specifically +scrape_statement+ passes the Mechanize agent to +fetch_transactions_page+
28 | # then passes the resulting page to +parse_transactions_page. Subclasses must implement these two methods.
29 | # See the documentation for these methods for more details on how to implement them.
30 | # Note that failure to override either method will result in an exception.
31 | #
32 | class BaseScraper < Scraper
33 |
34 | # +scraper_args+ holds the array of arguments specified on the command line with
35 | # the -scraper_args option. It is not used here, but it is set in the scrape_statement
36 | # method so that you can access it in your subclass.
37 | attr_accessor :scraper_args
38 |
39 | # This rule goes last and sets the type of any transactions
40 | # that are still set to OTHER to be the generic CREDIT or DEBIT
41 | # depending on the real amount of the transaction
42 | # +prioirity+ set to -999 to ensure it's last
43 | transaction_rule(-999) do |tx|
44 | if (tx.type == Transaction::OTHER)
45 | if tx.real_amount > 0
46 | tx.type = Transaction::CREDIT
47 | elsif tx.real_amount < 0
48 | tx.type = Transaction::DEBIT
49 | end
50 | # else leave it as OTHER if it's exactly zero
51 | end
52 | end
53 |
54 | ##
55 | # Override +fetch_transactions_page+ to use the mechanize +agent+ to
56 | # load the page holding your bank statement on your online banking website.
57 | # By using agent.get(url) to fetch the page, the returned page will be
58 | # an Hpricot document ready for parsing.
59 | #
60 | # Typically you will need to log-in using a form on a login page first.
61 | # Your implementation may look something like this:
62 | #
63 | # # My online banking app has a logon page with a standard HTML form.
64 | # # by looking at the source of the page I see that the form is named
65 | # # 'MyLoginFormName' and the two text fields for user name and password
66 | # # are called 'USERNAME' and 'PASSWORD' respectively.
67 | # login_page = agent.get("http://mybankapp.com/login.html")
68 | # form = login_page.forms.name('MyLoginFormName').first
69 | # # Mechanize automatically makes constants for the form elements based on their names.
70 | # form.USERNAME = "me"
71 | # form.PASSWORD = "foo"
72 | # agent.submit(form)
73 | # sleep 3 #wait while the login takes effect
74 | #
75 | # # Now that I've logged in and waited a bit, navigate to the page that lists
76 | # # my recent transactions and return it
77 | # return agent.get("http://mybankapp.com/latesttransactions.html")
78 | #
79 | def fetch_transactions_page(agent)
80 | raise "You must override fetch_transactions_page in your subclass of BaseScraper " +
81 | "or just subclass Scraper instead and override scrape_statement"
82 | end
83 |
84 | ##
85 | # Override +parse_transactions_page+ to take the Hpricot document passed in
86 | # as +page+, parse it using Hpricot directives, and create a Statement object
87 | # holding a set of Transaction objects for it.
88 | #
89 | def parse_transactions_page(page)
90 | raise "You must override parse_transactions_page in your subclass of BaseScraper " +
91 | "or just subclass Scraper instead and override scrape_statement"
92 | end
93 |
94 | ##
95 | # Implements the one essential method of a scraper +scrape_statement+
96 | # by calling +fetch_transactions_page+ to get a web page holding a bank
97 | # statement followed by a call to +parse_transactions_page+ that returns
98 | # the +Statement+ object.
99 | #
100 | # Do not override this method in a subclass. (If you want to override it
101 | # you should be subclassing Scraper instead of this class)
102 | #
103 | # If the --input argument has been used to specify and input html file to
104 | # use, this will be parsed directly instead of calling +fetch_transaction_page+.
105 | # This allows for easy debugging without slow web-scraping (simply view
106 | # the page in a regular browser and use Save Page As to save a local copy
107 | # of it, then specify thiswith the --input command-line arg)
108 | #
109 | # +args+ holds the array of arguments specified on the command line with
110 | # the -scraper_args option. It is not used here, but it is set on an
111 | # attribute called scraper_args and is thus accessible in your subclass.
112 | #
113 | def scrape_statement(args)
114 | self.scraper_args = args
115 | if (not options.input.nil?) then
116 | # used for debugging - load the page from a file instead of the web
117 | logger.debug("Reading debug input html from #{options.input} instead of scraping the real website.")
118 | page = Hpricot(open(options.input))
119 | else
120 | # not debugging use the actual scraper
121 | # First create a mechanize agent: a sort of pretend web browser
122 | agent = WWW::Mechanize.new
123 | agent.user_agent_alias = 'Windows IE 6' # pretend that we're IE 6.0
124 |
125 | page = fetch_transactions_page(agent)
126 | end
127 | raise "BaseScraper failed to load the transactions page" if page.nil?
128 | # Now that we've feteched the page, parse it to get a statement
129 | statement = parse_transactions_page(page)
130 | return statement
131 | end
132 | end # BaseScraper
133 |
134 |
--------------------------------------------------------------------------------
/lib/bankjob/bankjob_runner.rb:
--------------------------------------------------------------------------------
1 | require 'rubygems'
2 | require 'logger'
3 | require 'bankjob.rb'
4 |
5 | module Bankjob
6 | class BankjobRunner
7 |
8 | # Runs the bankjob application, loading and running the
9 | # scraper specified in the command line args and generating
10 | # the output file.
11 | def run(options, stdout)
12 | logger = options.logger
13 |
14 | if options.wesabe_help
15 | Bankjob.wesabe_help(options.wesabe_args, logger)
16 | exit(0) # Wesabe help describes to the user how to use the wesabe options then quits
17 | end
18 |
19 | # Load the scraper object dynamically, then scrape the web
20 | # to get a new bank statement
21 | scraper = Scraper.load_scraper(options.scraper, options, logger)
22 |
23 | begin
24 | statement = scraper.scrape_statement(options.scraper_args)
25 | statement = Scraper.post_process_transactions(statement)
26 | rescue Exception => e
27 | logger.fatal(e)
28 | puts "Failed to scrape a statement successfully with #{options.scraper} due to: #{e.message}\n"
29 | puts "Use --debug --log bankjob.log then check the log for more details"
30 | exit (1)
31 | end
32 |
33 | # a lot of if's here but we allow for the user to generate ofx
34 | # and csv to files while simultaneously uploading to wesabe
35 |
36 | if options.csv
37 | if options.csv_out.nil?
38 | puts write_csv_doc([statement], true) # dump to console with header, no file specified
39 | else
40 | csv_file = file_name_from_option(options.csv_out, statement, "csv")
41 |
42 | # Output data as comma separated values possibly merging
43 | if File.file?(csv_file)
44 | # TODO until we fix merging csv files are appended
45 | open(csv_file, "a") do |f|
46 | f.puts(write_csv_doc([statement]))
47 | end
48 | logger.info("Statement is being appended as csv to #{csv_file}")
49 | #
50 | # TODO fix the merging then uncomment this
51 | # old_file_path = csv_file
52 | # # The file already exists, lets load it and merge with the new data
53 | # old_statement = scraper.create_statement()
54 | # old_statement.from_csv(old_file_path, scraper.decimal)
55 | # begin
56 | # old_statement.merge!(statement)
57 | # statement = old_statement
58 | # rescue Exception => e
59 | # # the merge failed, so leave the statement as the original and store it separately
60 | # output_file = output_file + "_#{date_range}_merge_failed"
61 | # logger.warn("Merge failed, storing new data in #{output_file} instead of appending it to #{old_file_path}")
62 | # logger.debug("Merge failed due to: #{e.message}")
63 | # end
64 | else
65 | open(csv_file, "w") do |f|
66 | f.puts(write_csv_doc([statement], true)) # true = write with header
67 | end
68 | logger.info("Statement is being written as csv to #{csv_file}")
69 | end
70 | end
71 | end # if csv
72 |
73 | # Create an ofx document and write it if necessary
74 | if (options.ofx or options.wesabe_upload)
75 | ofx_doc = write_ofx_doc([statement])
76 | end
77 |
78 | # Output ofx file
79 | if options.ofx
80 | if options.ofx_out.nil?
81 | puts ofx_doc # dump to console, no file specified
82 | else
83 | ofx_file = file_name_from_option(options.ofx_out, statement, "ofx")
84 | open(ofx_file, "w") do |f|
85 | f.puts(ofx_doc)
86 | end
87 | logger.info("Statement is being output as ofx to #{ofx_file}")
88 | end
89 | end
90 |
91 | # Upload to wesabe if requested
92 | if options.wesabe_upload
93 | begin
94 | Bankjob.wesabe_upload(options.wesabe_args, ofx_doc, logger)
95 | rescue Exception => e
96 | logger.fatal("Failed to upload to Wesabe")
97 | logger.fatal(e)
98 | puts "Failed to upload to Wesabe: #{e.message}\n"
99 | puts "Try bankjob --wesabe-help for help on this feature."
100 | exit(1)
101 | end
102 | end
103 | end # run
104 |
105 | ##
106 | # Generates an OFX document to a string that starts with the stanadard
107 | # OFX header and contains the XML for the specified +statements+
108 | #
109 | def write_ofx_doc(statements)
110 | ofx = generate_ofx2_header
111 | statements.each do |statement|
112 | ofx << statement.to_ofx
113 | end
114 | return ofx
115 | end
116 |
117 | ##
118 | # Generates a CSV document to a string containing the transactions in
119 | # all of the specified +statements+
120 | #
121 | def write_csv_doc(statements, header = false)
122 | csv = ""
123 | csv << Statement.csv_header if header
124 | statements.each do |statement|
125 | csv << statement.to_csv
126 | end
127 | return csv
128 | end
129 |
130 | ##
131 | # Generates the (XML) OFX2 header lines that allow the OFX 2.0 document
132 | # to be recognized.
133 | #
134 | # (Note that this is crucial for www.wesabe.com to accept the OFX
135 | # document in an upload)
136 | #
137 | def generate_ofx2_header
138 | return <<-EOF
139 |
140 |
141 | EOF
142 | end
143 |
144 | ##
145 | # Generates the (non-XML) OFX header lines that allow the OFX 1.0 document
146 | # to be recognized.
147 | #
148 | # (Note that this is crucial for www.wesabe.com to accept the OFX
149 | # document in an upload)
150 | #
151 | def generate_ofx_header
152 | return <<-EOF
153 | OFXHEADER:100
154 | DATA:OFXSGML
155 | VERSION:102
156 | SECURITY:NONE
157 | ENCODING:USASCII
158 | CHARSET:1252
159 | COMPRESSION:NONE
160 | OLDFILEUID:NONE
161 | NEWFILEUID:NONE
162 | EOF
163 | end
164 |
165 | ##
166 | # Takes a name or path for an output file and a Statement and if the file
167 | # path is a directory, creates a new file name based on the date range
168 | # of the statement and returns a path to that file.
169 | # If +output_file+ is not a directory it is returned as-is.
170 | #
171 | def file_name_from_option(output_file, statement, type)
172 | # if the output_file is a directory, we create a new file name
173 | if (output_file and File.directory?(output_file))
174 | # Create a date range string for the first and last transactions in the statement
175 | # This will looks something like: 20090130000000-20090214000000
176 | date_range = "#{Bankjob.date_time_to_ofx(statement.from_date)[0..7]}-#{Bankjob.date_time_to_ofx(statement.to_date)[0..7]}"
177 | filename = "#{date_range}.#{type}"
178 | output_file = File.join(output_file, filename)
179 | end
180 | # else we assume output_file is a file name/path already
181 | return output_file
182 | end
183 | end # class BankjobRunner
184 | end # module Bankjob
185 |
--------------------------------------------------------------------------------
/website/index.txt:
--------------------------------------------------------------------------------
1 | h1. bankjob
2 |
3 | Bankjob is a command line ruby program for scraping online banking sites and producing statements as local files in OFX ("Open Fincancial Exchange":http://www.ofx.net) or CSV (Comma Separated Values) formats.
4 |
5 | Bankjob was created for people like me who want to get their bank data into a 3rd party application but whose bank does not support downloads in OFX format.
6 | It's also useful for keeping a permanent store of bank statements on your computer for reading in Excel.
7 |
8 | h2. Installing
9 |
10 | Mac OSX / Linux:
11 |
sudo gem install bankjob
12 |
13 | Windows:
14 |
gem install bankjob
15 |
16 | h2. The basics
17 |
18 | * Scrapes an online banking website to produce a bank statement
19 | * Stores bank statements locally in CSV files, which can be loaded directly in spreadsheets like Microsoft Excel
20 | * Stores bank statements locally in OFX files, which can be imported by many programs such as Quicken, MS Money, Gnu Cash and uploaded to some web applications
21 | * Built-in support for uploading to your Wesabe account (www.wesabe.com)
22 | * Supports coding of simple rules in ruby for modifying transaction details. E.g. automatically change "pment inst 3245003" to "paid home loan interest"
23 |
24 | h2. Usage
25 |
26 |
32 |
33 | I have this command in a .bat file which is launched daily by a scheduled task on my windows Media Center PC (which, since it's always on and connected to the internet, makes a useful home server)
34 |
35 | This one command will:
36 | * scrape my online banking website after logging in as me and navigating to the page with recent transactions
37 | * apply some rules, coded in the my_bpi_scraper.rb file that make the descriptions more readable
38 | * produce a statement in comma-separated-value format, keeping the original raw data as well as the new descriptions,
39 | storing that in a file with a name like 20090327-20090406.csv in my local directory c:\bank\csv (serves as a sort of permanent 'audit' record)
40 | * produce an OFX document with the same statement information
41 | * upload the OFX statement to my wesabe account
42 | * log debug-level details in bankjob.log
43 |
44 | Use bankjob -h to see all of the options.
45 |
46 | h2. Wesabe
47 |
48 | Wesabe ("www.wesabe.com":www.wesabe.com) is a sort of free web.2.0-money-manager-financial-social-networking application that I'm using to track my bank acounts.
49 | I like it better than my bank's online banking apps because it:
50 | * shows me all my accounts at once
51 | * lets me track certain kinds of spending, with automatically applied tags and nice graphs
52 | * has an iphone-web-app (and a non-iphone one too)
53 | * even has a nifty dashboard widget for Mac OS X.
54 | * does much more than this but you should go to their site to find out more.
55 |
56 | At this point I should point out that Bankjob and its developers (okay developer - there's just me, I admit it. God it's lonely here) have no affiliation with Wesabe whatsoever. I just happen to use Wesabe and like what it has to offer.
57 | In fact I just happened to _want_ to use Wesabe, but couldn't because my bank doesn't offer OFX downloads and is not supported by the automatic updater. Hence I developed Bankjob to allow me to use Wesabe.
58 |
59 | I think there are a lot of other non-US would-be Wesabe users out there in the same boat: Wesabe and its brethren do not support our banks, and our banks don't make it any easier by supporting OFX downloads.
60 |
61 | There are similar services out there like Mint, Quicken Online, Yodlee and others (see one comparison "here":http://digg.com/business_finance/Late_2008_comparison_of_Wesabe_Mint_and_Quicken_Online), or just google them.
62 | The problem with most of these is that they offer even less support than Wesabe for banks outside of the US. Wesabe supports some non-US banks, but more importantly offers a public API which allows apps like Bankjob to upload accounts - among other things. (Actually I think Yodlee might have more support for non-US banks but still no public API - if your Bank is not officially supported you're out of luck)
63 |
64 | To use the Wesabe upload feature of Bankjob you need a Wesabe username and password. If you have more than one account with Wesabe you need to know the ID that Wesabe gives the account. This is easy to find out: if you have one a account it's "1" and you don't need to specify it. If you have more than one account, Bankjob will help you out. Use:
65 | bankjob --wesabe-help
66 | to see the general help information, but to test your Wesabe account, use
67 | bankjob --wesabe-help ""
68 | (you'll need to use the quotes, with a space between the username and password)
69 |
70 | With this command bankjob will list out all of your Wesabe accounts and tell you how to upload to them.
71 |
72 | I have only one account at the moment, and here's what happens when I use --wesabe-help (the text in angle brackets is added by me to protect my account, but otherwise this is real input/output)
73 |
74 |
75 | [me:~] bankjob --wesabe-help ""
76 | Connecting to Wesabe...
77 | You have 1 Wesabe accounts:
78 | Account Name:
79 | wesabe id: 1
80 | account no: 0001
81 | type: Checking
82 | balance: 2196.12
83 | bank:
84 | To upload to this account use:
85 | bankjob [other bankjob args] --wesabe " 1"
86 |
87 | Since you have one account you do not need to specify the id number, use:
88 | bankjob [other bankjob args] --wesabe ""
89 |
90 |
91 | Again, if in any doubt bankjob --wesabe-help should set you straight.
92 |
93 |
94 |
95 | h2. Rubydoc
96 |
97 | "http://bankjob.rubyforge.org/rdoc":http://bankjob.rubyforge.org/rdoc
98 |
99 | h2. Forum
100 |
101 | "http://groups.google.com/group/bankjob":http://groups.google.com/group/bankjob
102 |
103 | h2. Creating your own Scraper
104 |
105 | Unfortunately Bankjob isn't much good to you in its natural state unless you happen to bank with the same bank as me.
106 | To make it useful you need a scraper for your own online bank site. How hard that will be depends on how handy you are with "Ruby":http://www.ruby-lang.org programming and with using "Hpricot":http://wiki.github.com/why/hpricot/an-hpricot-showcase and "Mechanize":http://mechanize.rubyforge.org/mechanize
107 |
108 | You'll get some help from:
109 | * "The Mechanize docs":http://mechanize.rubyforge.org/mechanize
110 | * "The Hpricot wiki":http://wiki.github.com/why/hpricot/an-hpricot-showcase
111 | * The BpiScraper is included in the scrapers directory and is well documented as an example.
112 | * The BaseScraper is the superclass of BpiScraper. Using it as a base class for your own scraper is optional, but it saves you a little work by starting up Mechanize for you.
113 | * The Scraper class itself, which you _must_ subclass with your own Scraper, has more to say on creating your own scraper in its "rubydoc":http://bankjob.rubyforge.org/rdoc/classes/Bankjob/Scraper.html
114 | * Look for help on the "forum":http://groups.google.com/group/bankjob
115 | * Ask me directly "rhubarb":mailto:rhubarb.bankjob@gmail.com
116 |
117 | Warning: Don't upload or email anyone (including me) your private online banking information. If you create your own scraper, please share it, but remove any account details from it first.
118 |
119 | If and when you do develop a scraper for your banksite, send it to me (without any passwords or private banking info of course!) and I'll add it to the built-in scrapers.
120 |
121 | h2. How to submit patches
122 |
123 | You can fetch the source from github at:
124 |
125 | "http://github.com/rhubarb/bankjob/tree/master":http://github.com/rhubarb/bankjob/tree/master
126 |
127 | using:
128 |
git clone git://github.com/rhubarb/bankjob.git
129 |
130 | and submit any patches to the "forum":http://groups.google.com/group/bankjob.
131 |
132 | h2. License
133 |
134 | This code is free to use under the terms of the MIT license.
135 |
136 | As far as rhubarb is concerned, this means you can:
137 | - Use it in your private projects
138 | - Use it in your public projects
139 | - Use it in your commercial projects and make big bucks
140 | - Mulch it up and use it in your garden as fertilizer
141 | - Generally go crazy with it
142 |
143 | h2. Contact
144 |
145 | Comments are welcome as are feature requests and patches.
146 |
147 | Send an email to "rhubarb":mailto:rhubarb.bankjob@gmail.com
148 | Or check out the "forum":http://groups.google.com/group/bankjob
149 |
150 |
--------------------------------------------------------------------------------
/scrapers/bpi_scraper.rb:
--------------------------------------------------------------------------------
1 |
2 | require 'rubygems'
3 | require 'bankjob' # this require will pull in all the classes we need
4 | require 'base_scraper' # this defines scraper that BpiScraper extends
5 |
6 | include Bankjob # access the namespace of Bankjob
7 |
8 | ##
9 | # BpiScraper is a scraper tailored to the BPI bank in Portugal (www.bpinet.pt).
10 | # It takes advantage of the BaseScraper to create the mechanize agent,
11 | # then followins the basic recipe there of first loading the tranasctions page
12 | # then parsing it.
13 | #
14 | # In addition to actually working for the BPI online banking, this class serves
15 | # as an example of how to build your own scraper.
16 | #
17 | # BpiScraper expects the user name and password to be passed on the command line
18 | # using --scraper-args "user password" (with a space between them).
19 | # Optionally, the account number can also be specified with the 3rd argument so:
20 | # --scraper-args "user password 803030000001" causing that account to be selected
21 | # before scraping the statement
22 | #
23 | class BpiScraper < BaseScraper
24 |
25 | currency "EUR" # Set the currency as euros
26 | decimal "," # BPI statements use commas as separators - this is used by the real_amount method
27 | account_number "1234567" # override this with a real account number
28 | account_type Statement::CHECKING # this is the default anyway
29 |
30 | # This rule detects ATM withdrawals and modifies
31 | # the description and sets the the type
32 | transaction_rule do |tx|
33 | if (tx.real_amount < 0)
34 | if tx.raw_description =~ /LEV.*ATM ELEC\s+\d+\/\d+\s+/i
35 | tx.description = "Multibanco withdrawal at #{$'}"
36 | tx.type = Transaction::ATM
37 | end
38 | end
39 | end
40 |
41 | # This rule detects checque payments and modifies the description
42 | # and sets the type
43 | transaction_rule do |tx|
44 | if tx.raw_description =~ /CHEQUE\s+(\d+)/i
45 | cheque_number = $+ # $+ holds the last group of the match which is (\d+)
46 | # change the description but append $' in case there was trailing text after the cheque no
47 | tx.description = "Cheque ##{cheque_number} withdrawn #{$'}"
48 | tx.type = Transaction::CHECK
49 | tx.check_number = cheque_number
50 | end
51 | end
52 |
53 | # This rule goes last and sets the description of transactions
54 | # that haven't had their description to the raw description after
55 | # changing the words to have capital letters only on the first word.
56 | # (Note that +description+ will default to being the same as +raw_description+
57 | # anyway - this rule is only for making the all uppercase output less ugly)
58 | # The payee is also fixed in this way
59 | transaction_rule(-999) do |tx|
60 | if (tx.description == tx.raw_description)
61 | tx.description = Bankjob.capitalize_words(tx.raw_description)
62 | end
63 | end
64 |
65 | # Some constants for the URLs and main elements in the BPI bank app
66 | LOGIN_URL = 'https://www.bpinet.pt/'
67 | TRANSACTIONS_URL = 'https://www.bpinet.pt/areaInf/consultas/Movimentos/Movimentos.asp'
68 |
69 | ##
70 | # Uses the mechanize web +agent+ to fetch the page holding the most recent
71 | # bank transactions and returns it.
72 | # This overrides (implements) +fetch_transactions_page+ in BaseScraper
73 | #
74 | def fetch_transactions_page(agent)
75 | login(agent)
76 | logger.info("Logged in, now navigating to transactions on #{TRANSACTIONS_URL}.")
77 | transactions_page = agent.get(TRANSACTIONS_URL)
78 | if (transactions_page.nil?)
79 | raise "BPI Scraper failed to load the transactions page at #{TRANSACTIONS_URL}"
80 | end
81 |
82 | # If there is a third scraper arg, it is the account number and we use it
83 | # to select the account on the transactions page
84 | if (scraper_args and scraper_args.length > 2)
85 | account = scraper_args[2]
86 | # the account selector is the field 'contaCorrente' in the form 'form_mov'
87 | Bankjob.select_and_submit(transactions_page, 'form_mov', 'contaCorrente', account)
88 | sleep 1
89 | # refetch the transactions page after selecting the account
90 | transactions_page = agent.get(TRANSACTIONS_URL)
91 | end
92 |
93 | return transactions_page
94 | end
95 |
96 |
97 | ##
98 | # Parses the BPI page listing about a weeks worth of transactions
99 | # and creates a Transaction for each one, putting them together
100 | # in a Statement.
101 | # Overrides (implements) +parse_transactions_page+ in BaseScraper.
102 | #
103 | def parse_transactions_page(transactions_page)
104 | begin
105 | statement = create_statement
106 |
107 | account_number = get_account_number(transactions_page)
108 | statement.account_number = account_number unless account_number.nil?
109 |
110 | # Find the closing balance avaliable and accountable
111 | # Get from this:
112 | #
Saldo Disponível:
113 | #
1.751,31 EUR
114 | # to 1751,31
115 | # Commenting out balances for now to let the balance be taken from the
116 | # top-most transaction - this keeps balances in synch with actual transactions
117 | # and allows for statements created for past dates (the balance at the top of the
118 | # page is always the current one, not the one for the last transaction on that page)
119 | #available_cell = (transactions_page/"td").select { |ele| ele.inner_text =~ /^Saldo Dispon/ }.first.next_sibling
120 | #statement.closing_available = available_cell.inner_text.scan(/[\d.,]+/)[0].gsub(/\./,"")
121 | #account_balance_cell = (transactions_page/"td").select { |ele| ele.inner_text =~ /^Saldo Contab/ }.first.next_sibling
122 | #statement.closing_balance = account_balance_cell.inner_text.scan(/[\d.,]+/)[0].gsub(/\./,"")
123 |
124 | #transactions = []
125 |
126 | # find the first header with the CSS class "Laranja" as this will be the first
127 | # header in the transactions table
128 | header = (transactions_page/"td.Laranja").first
129 |
130 | # the table element is the grandparent element of this header (the row is the parent)
131 | table = header.parent.parent
132 |
133 | # each row with the valign attribute set to "top" holds a transaction
134 | rows = (table/"tr[@valign=top]")
135 | rows.each do |row|
136 | transaction = create_transaction # use the support method because it sets the separator
137 |
138 | # collect all of the table cells' inner html in an array (stripping leading/trailing spaces)
139 | data = (row/"td").collect{ |cell| cell.inner_html.strip }
140 |
141 | # the first (0th) column holds the date
142 | transaction.date = data[0]
143 |
144 | # the 2nd column holds the value date - but it's often empty
145 | # in which case we set it to nil
146 | vdate = data[1]
147 | if vdate.nil? or vdate.length == 0 or vdate.strip == " "
148 | transaction.value_date = nil
149 | else
150 | transaction.value_date = vdate
151 | end
152 |
153 | # the transaction raw_description is in the 3rd column
154 | transaction.raw_description = data[2]
155 |
156 | # the 4th column holds the transaction amount (with comma as decimal place)
157 | transaction.amount = data[3]
158 |
159 | # the new balance is in the last column
160 | transaction.new_balance=data[4]
161 |
162 | # add thew new transaction to the array
163 | statement.add_transaction(transaction)
164 | # break if $debug
165 | end
166 | rescue => exception
167 | msg = "Failed to parse the transactions page at due to exception: #{exception.message}\nCheck your user name and password."
168 | logger.fatal(msg);
169 | logger.debug(exception)
170 | logger.debug("Failed parsing transactions page:")
171 | logger.debug("--------------------------------")
172 | logger.debug(transactions_page) #.body
173 | logger.debug("--------------------------------")
174 | abort(msg)
175 | end
176 |
177 | # finish the statement to set the balances and dates
178 | # and to fake the times since the bpi web pages
179 | # don't hold the transaction times
180 | statement.finish(true, true) # most_recent_first, fake_times
181 |
182 | return statement
183 | end
184 |
185 | def get_account_number(transactions_page)
186 | # make sure the page is a mechanize page, not hpricot
187 | if transactions_page.kind_of?(Hpricot::Doc) then
188 | page = WWW::Mechanize::Page.new(nil, {'content-type'=>'text/html'},
189 | transactions_page.html, nil, nil)
190 | else
191 | page = transactions_page
192 | end
193 |
194 | # find the form for selecting an account -it's called 'form_mov'
195 | form_mov = page.form('form_mov')
196 | # the field for selecting the current account is in this form
197 | account_selector = form_mov.field('contaCorrente')
198 | # the selected account value is the account number but it has "|NR|" on the end so strip
199 | # everything that's not a number
200 | account_number = account_selector.value.gsub(/[^0-9]/,"")
201 | return account_number
202 | end
203 |
204 | ##
205 | # Logs into the BPI banking app by finding the form
206 | # setting the name and password and submitting it then
207 | # waits a bit.
208 | #
209 | def login(agent)
210 | logger.info("Logging in to #{LOGIN_URL}.")
211 | if (scraper_args)
212 | username, password = *scraper_args
213 | end
214 | raise "Login failed for BPI Scraper - pass user name and password using -scraper_args \"user pass\"" unless (username and password)
215 |
216 | # navigate to the login page
217 | login_page = agent.get(LOGIN_URL)
218 |
219 | # find login form - it's called 'signOn' - fill it out and submit it
220 | form = login_page.form('signOn')
221 |
222 | # username and password are taken from the commandline args, set them
223 | # on USERID and PASSWORD which are the element names that the web page
224 | # form uses to identify the form fields
225 | form.USERID = username
226 | form.PASSWORD = password
227 |
228 | # submit the form - same as the user hitting the Login button
229 | agent.submit(form)
230 | sleep 3 # wait while the login takes effect
231 | end
232 | end # class BpiScraper
233 |
234 |
235 |
--------------------------------------------------------------------------------
/lib/bankjob/support.rb:
--------------------------------------------------------------------------------
1 |
2 | require 'rubygems'
3 | require 'bankjob'
4 |
5 | module Bankjob
6 |
7 | ##
8 | # Takes a date-time as a string or as a Time or DateTime object and returns
9 | # it as either a Time object.
10 | #
11 | # This is useful in the setter method of a date attribute allowing the date
12 | # to be set as any type but stored internally as an object compatible with
13 | # conversion through +strftime()+
14 | # (Bankjob::Transaction uses this internally in the setter for +date+ for example
15 | #
16 | def self.create_date_time(date_time_raw)
17 | if (date_time_raw.is_a?(Time)) then
18 | # It's already a Time
19 | return date_time_raw
20 | elsif (date_time_raw.to_s.strip.empty?)
21 | # Nil or non dates are returned as nil
22 | return nil
23 | else
24 | # Assume it can be converted to a time
25 | return Time.parse(date_time_raw.to_s)
26 | end
27 | end
28 |
29 | ##
30 | # Takes a Time or DateTime and formats it in the correct format for OFX date elements.
31 | #
32 | # The OFX format is a string of digits in the format "YYMMDDHHMMSS".
33 | # For example, the 1st of February 2009 at 2:34PM and 56 second becomes "20090201143456"
34 | #
35 | # Note must use a Time, or DateTime, not a String, nor a Date.
36 | #
37 | def self.date_time_to_ofx(time)
38 | time.nil? ? "" : "#{time.strftime( '%Y%m%d%H%M%S' )}"
39 | end
40 |
41 | ##
42 | # Takes a Time or DateTime and formats in a suitable format for comma separated values files.
43 | # The format produced is suitable for loading into an Excel-like spreadsheet program
44 | # being automatically treated as a date.
45 | #
46 | # A string is returned with the format "YY-MM-DD HH:MM:SS".
47 | # For example, the 1st of February 2009 at 2:34PM and 56 second becomes "2009-02-01 14:34:56"
48 | #
49 | # Note must use a Time, or DateTime, not a String, nor a Date.
50 | #
51 | def self.date_time_to_csv(time)
52 | time.nil? ? "" : "#{time.strftime( '%Y-%m-%d %H:%M:%S' )}"
53 | end
54 |
55 | ##
56 | # Takes a string and capitalizes the first letter of every word
57 | # and forces the rest of the word to be lowercase.
58 | #
59 | # This is a utility method for use in scrapers to make descriptions
60 | # more readable.
61 | #
62 | def self.capitalize_words(message)
63 | message.downcase.gsub(/\b\w/){$&.upcase}
64 | end
65 |
66 | ##
67 | # converts a numeric +string+ to a float given the specified +decimal+
68 | # separator.
69 | #
70 | def self.string_to_float(string, decimal)
71 | return nil if string.nil?
72 | amt = string.gsub(/\s/, '')
73 | if (decimal == ',') # E.g. "1.000.030,99"
74 | amt.gsub!(/\./, '') # strip out . 1000s separator
75 | amt.gsub!(/,/, '.') # replace decimal , with .
76 | elsif (decimal == '.')
77 | amt.gsub!(/,/, '') # strip out comma 1000s separator
78 | end
79 | return amt.to_f
80 | end
81 |
82 | ##
83 | # Finds a selector field in a named +form+ in the given Mechanize +page+, selects
84 | # the suggested +label+
85 | def select_and_submit(page, form_name, select_name, selection)
86 | option = nil
87 | form = page.form(form_name)
88 | unless form.nil?
89 | selector = form.field(select_name)
90 | unless selector.nil?
91 | option = select_option(selector, selection)
92 | form.submit
93 | end
94 | end
95 | return option
96 | end
97 |
98 | ##
99 | # Given a Mechanize::Form:SelectList +selector+ will attempt to select the option
100 | # specified by +selection+.
101 | # This algorithm is used:
102 | # The first option with a label equal to the +selection+ is selected.
103 | # - if none is found then -
104 | # The first option with a value equal to the +selection+ is selected.
105 | # - if none is found then -
106 | # The first option with a label or value that equal to the +selection+ is selected
107 | # after removing non-alphanumeric characters from the label or value
108 | # - if none is found then -
109 | # The first option with a lable or value that _contains_ the +selection+
110 | #
111 | # If matching option is found, the #select is called on it.
112 | # If no option is found, nil is returned - otherwise the option is returned
113 | #
114 | def select_option(selector, selection)
115 | options = selector.options.select { |o| o.text == selection }
116 | options = selector.options.select { |o| o.value == selection } if options.empty?
117 | options = selector.options.select { |o| o.text.gsub(/[^a-zA-Z0-9]/,"") == selection } if options.empty?
118 | options = selector.options.select { |o| o.value.gsub(/[^a-zA-Z0-9]/,"") == selection } if options.empty?
119 | options = selector.options.select { |o| o.text.include?(selection) } if options.empty?
120 | options = selector.options.select { |o| o.value.include?(selection) } if options.empty?
121 |
122 | option = options.first
123 | option.select() unless option.nil?
124 | return option
125 | end
126 |
127 | ##
128 | # Uploads the given OFX document to the Wesabe account specified in the +wesabe_args+
129 | #
130 | def self.wesabe_upload(wesabe_args, ofx_doc, logger)
131 | if (wesabe_args.nil? or (wesabe_args.length < 2 and wesabe_args.length > 3))
132 | raise "Incorrect number of args for Wesabe (#{wesabe_args}), should be 2 or 3."
133 | else
134 | load_wesabe
135 | wuser, wpass, windex = *wesabe_args
136 | wesabe = Wesabe.new(wuser, wpass)
137 | num_accounts = wesabe.accounts.length
138 | if num_accounts == 0
139 | raise "The user \"#{wuser}\" has no Wesabe accounts. Create one at www.wesabe.com before attempting to upload a statement."
140 | elsif (not windex.nil? and (num_accounts < windex.to_i))
141 | raise "The user \"#{wuser}\" has only #{num_accounts} Wesabe accounts, but the account index #{windex} was specified."
142 | elsif windex.nil?
143 | if num_accounts > 1
144 | raise "The user \"#{wuser}\" has #{num_accounts} Wesabe accounts, so the account index must be specified in the WESABE_ARGS."
145 | else
146 | # we have only one account, no need to specify the index
147 | windex = 1
148 | end
149 | elsif windex.to_i == 0
150 | raise "The Wesabe account index must be between 1 and #{num_accounts}. #{windex} is not acceptable"
151 | end
152 | logger.debug("Attempting to upload statement to the ##{windex} Wesabe account for user #{wuser}...")
153 | # Get the account at the index (which is not necessarily the index in the array
154 | # so we use the account(index) method to get it
155 | account = wesabe.account(windex.to_i)
156 | uploader = account.new_upload
157 | uploader.statement = ofx_doc
158 | uploader.upload!
159 | logger.info("Uploaded statement to Wesabe account #{account.name}, the ##{windex} account for user #{wuser}, with the result: #{uploader.status}")
160 | end
161 | end
162 |
163 | ##
164 | # Helps the user determine how to upload to their Wesabe account.
165 | #
166 | # When used with no args, will give generic help information.
167 | # When used with Wesabe account and password, will log into Wesabe and list
168 | # the users accounts, and suggest command line args to upload to each account.
169 | #
170 | def self.wesabe_help(wesabe_args, logger)
171 | if (wesabe_args.nil? or wesabe_args.length != 2)
172 | puts <<-EOF
173 | Wesabe (http://www.wesabe.com) is an online bank account management tool (like Mint)
174 | that allows you to upload (in some cases automatically) your bank statements and
175 | automatically convert them into a more readable format to allow you to track
176 | your spending and much more. Wesabe comes with its own community attached.
177 |
178 | Bankjob has no affiliation with Wesabe, but allows you to upload the statements it
179 | generates to your Wesabe account automatically.
180 |
181 | To use Wesabe you need the Wesabe Ruby gem installed:
182 | See the gem at http://github.com/wesabe/wesabe-rubygem
183 | Install the gem with:
184 | $ sudo gem install -r --source http://gems.github.com/ wesabe-wesabe
185 | (on Windows, omit the "sudo")
186 |
187 | You also need your Wesabe login name and password, and, if you have
188 | more than one account on Wesabe, the id number of the account.
189 | This is not a real account number - it's simply a counter that Wesabe uses.
190 | If you have a single account it will be '1', if you have two accounts the
191 | second account will be '2', etc.
192 |
193 | Bankjob will help you find this number by listing your Wesabe accounts for you.
194 | Simply use:
195 | bankjob -wesabe_help "username password"
196 | (The quotes are important - this is a single argument to Bankjob with two words)
197 |
198 | If you already know the number of the account and you want to start uploading use:
199 |
200 | bankjob [other bankjob args] --wesabe "username password id"
201 |
202 | E.g.
203 | bankjob --scraper bpi_scraper.rb --wesabe "bloggsy pw123 2"
204 |
205 | If you only have a single account, you don't need to specify the id number
206 | (but Bankjob will check and will fail with an error if you have more than one account)
207 |
208 | bankjob [other bankjob args] --wesabe "username password"
209 |
210 | If in any doubt --wesabe-help "username password" will set you straight.
211 |
212 | Troubleshooting:
213 | - If you see an error like Wesabe::Request::Unauthorized, then chances
214 | are your username or password for Wesabe is incorrect.
215 |
216 | - If you see an error "end of file reached" then it may be that you are logged
217 | into the Wesabe account to which you are trying to upload - perhaps in a browser.
218 | In this case, log out from Wesabe in the browser, _wait a minute_, then try again.
219 | EOF
220 | else
221 | load_wesabe
222 | begin
223 | puts "Connecting to Wesabe...\n"
224 | wuser, wpass = *wesabe_args
225 | wesabe = Wesabe.new(wuser, wpass)
226 | puts "You have #{wesabe.accounts.length} Wesabe accounts:"
227 | wesabe.accounts.each do |account|
228 | puts " Account Name: #{account.name}"
229 | puts " wesabe id: #{account.id}"
230 | puts " account no: #{account.number}"
231 | puts " type: #{account.type}"
232 | puts " balance: #{account.balance}"
233 | puts " bank: #{account.financial_institution.name}"
234 | puts "To upload to this account use:"
235 | puts " bankjob [other bankjob args] --wesabe \"#{wuser} password #{account.id}\""
236 | puts ""
237 | if wesabe.accounts.length == 1
238 | puts "Since you have one account you do not need to specify the id number, use:"
239 | puts " bankjob [other bankjob args] --wesabe \"#{wuser} password\""
240 | end
241 | end
242 | rescue Exception => e
243 | msg =<<-EOF
244 | Failed to get Wesabe account information due to: #{e.message}.
245 | Check your username and password or use:
246 | bankjob --wesabe-help
247 | with no arguments for more details.
248 | EOF
249 | logger.debug(msg)
250 | logger.debug(e)
251 | raise msg
252 | end
253 | end
254 | end # wesabe_help
255 |
256 | private
257 |
258 | def self.load_wesabe(logger = nil)
259 | begin
260 | require 'wesabe'
261 | rescue LoadError => error
262 | msg = <<-EOF
263 | Failed to load the Wesabe gem due to #{error.module}
264 | See the gem at http://github.com/wesabe/wesabe-rubygem
265 | Install the gem with:
266 | $ sudo gem install -r --source http://gems.github.com/ wesabe-wesabe
267 | EOF
268 | logger.fatal(msg) unless logger.nil?
269 | raise msg
270 | end
271 | end
272 | end # module Bankjob
273 |
274 |
275 |
--------------------------------------------------------------------------------
/lib/bankjob/cli.rb:
--------------------------------------------------------------------------------
1 | require 'rubygems'
2 | require 'ostruct'
3 | require 'optparse'
4 | require 'logger'
5 |
6 | $:.unshift(File.dirname(__FILE__)) unless
7 | $:.include?(File.dirname(__FILE__)) || $:.include?(File.expand_path(File.dirname(__FILE__)))
8 |
9 | require 'bankjob_runner.rb'
10 |
11 | module Bankjob
12 | class CLI
13 |
14 | NEEDED = "Needed" # constant to indicate compulsory options
15 | NOT_NEEDED = "Not Needed" # constant to indicate no-longer compulsory options
16 |
17 | def self.execute(stdout, argv)
18 | # The BanjobOptions module above, through the magic of OptiFlags
19 | # has augmented ARGV with the command line options accessible through
20 | # ARGV.flags.
21 | runner = BankjobRunner.new()
22 | runner.run(parse(argv), stdout)
23 | end # execute
24 |
25 | ##
26 | # Parses the command line arguments using OptionParser and returns
27 | # an open struct with an attribute for each option
28 | #
29 | def self.parse(args)
30 | options = OpenStruct.new
31 |
32 | # Set the default options
33 | options.scraper = NEEDED
34 | options.scraper_args = []
35 | options.log_level = Logger::WARN
36 | options.log_file = nil
37 | options.debug = false
38 | options.input = nil
39 | options.ofx = false # ofx is the default but only if csv is false
40 | options.ofx_out = false
41 | options.csv = false
42 | options.csv_out = nil # allow for separate csv and ofx output files
43 | options.wesabe_help = false
44 | options.wesabe_upload = false
45 | options.wesabe_args = nil
46 | options.logger = nil
47 |
48 | opt = OptionParser.new do |opt|
49 |
50 | opt.banner = "Bankjob - scrapes your online banking website and produces an OFX or CSV document.\n" +
51 | "Usage: bankjob [options]\n"
52 |
53 | opt.version = Bankjob::BANKJOB_VERSION
54 |
55 | opt.on('-s', '--scraper SCRAPER',
56 | "The name of the ruby file that scrapes the website.\n") do |file|
57 | options.scraper = file
58 | end
59 |
60 | opt.on('--scraper-args ARGS',
61 | "Any arguments you want to pass on to your scraper.",
62 | "The entire set of arguments must be quoted and separated by spaces",
63 | "but you can use single quotes to specify multi-word arguments for",
64 | "your scraper. E.g.",
65 | " -scraper-args \"-user Joe -password Joe123 -arg3 'two words'\""," ",
66 | "This assumes your scraper accepts an array of args and knows what",
67 | "to do with them, it will vary from scraper to scraper.\n") do |sargs|
68 | options.scraper_args = sub_args_to_array(sargs)
69 | end
70 |
71 | opt.on('-i', '--input INPUT_HTML_FILE',
72 | "An html file used as the input instead of scraping the website -",
73 | "useful for debugging.\n") do |file|
74 | options.input = file
75 | end
76 |
77 | opt.on('-l', '--log LOG_FILE',
78 | "Specify a file to log information and debug messages.",
79 | "If --debug is used, log info will go to the console, but if neither",
80 | "this nor --debug is specfied, there will be no log.",
81 | "Note that the log is rolled over once per week\n") do |log_file|
82 | options.log_file = log_file
83 | end
84 |
85 | opt.on('q', '--quiet', "Suppress all messages, warnings and errors.",
86 | "Only fatal errors will go in the log") do
87 | options.log_level = Logger::FATAL
88 | end
89 |
90 | opt.on( '--verbose', "Log detailed informational messages.\n") do
91 | options.log_level = Logger::INFO
92 | end
93 |
94 | opt.on('--debug',
95 | "Log debug-level information to the log",
96 | "if here is one and put debug info in log\n") do
97 | options.log_level = Logger::DEBUG
98 | options.debug = true
99 | end
100 |
101 | opt.on('--ofx [FILE]',
102 | "Write out the statement as an OFX2 compliant XML document."," ",
103 | "If FILE is not specified, the XML is dumped to the console.",
104 | "If FILE specifies a directory then a new file will be created with a",
105 | "name generated from the dates of the first and last transactions.",
106 | "If FILE specifies a file that already exists it will be overwritten."," ",
107 | "(Note that ofx is the default format unless --csv is specified,",
108 | "and that both CSV and OFX documents can be produced by specifying",
109 | "both options.)\n") do |file|
110 | options.ofx = true
111 | options.ofx_out = file
112 | end
113 |
114 | opt.on('--csv [FILE]',
115 | "Writes out the statement as a CSV (comma separated values) document.",
116 | "All of the information available including numeric values for amount,",
117 | "raw and rule-generated descriptions, etc, are produced in the CSV document.", " ",
118 | "The document produced is suitable for loading into a spreadsheet like",
119 | "Microsoft Excel with the dates formatted to allow for auto recognition.",
120 | "This option can be used in conjunction with --ofx or --wesabe to produce",
121 | "a local permanent log of all the data scraped over time.", " ",
122 | "If FILE is not specified, the CSV is dumped to the console.",
123 | "If FILE specifies a directory then a new file will be created with a",
124 | "name generated from the dates of the first and last transactions.",
125 | "If FILE specifies a file that already exists then the new statement",
126 | "will be appended to the existing one in that file with care taken to",
127 | "merge removing duplicate entries.\n",
128 | "[WARNING - this merging does not yet function properly - its best to specify a directory for now.]\n"
129 | ) do |file|
130 | # TODO update this warning when we have merging working
131 | options.csv = true
132 | options.csv_out = file
133 | end
134 |
135 | opt.on('--wesabe-help [WESABE_ARGS]',
136 | "Show help information on how to use Bankjob to upload to Wesabe.",
137 | "Optionally use with \"wesabe-user password\" to get Wesabe account info.",
138 | "Note that the quotes around the WESABE_ARGS to send both username",
139 | "and password are necessary.", " ",
140 | "Use --wesabe-help with no args for more details.\n") do |wargs|
141 | options.wesabe_args = sub_args_to_array(wargs)
142 | options.wesabe_help = true
143 | options.scraper = NOT_NEEDED # scraper is not NEEDED when this option is set
144 | end
145 |
146 | opt.on('--wesabe WESABE_ARGS',
147 | "Produce an OFX document from the statement and upload it to a Wesabe account.",
148 | "WESABE_ARGS must be quoted and space-separated, specifying the wesabe account",
149 | "username, password and - if there is more than one - the wesabe account number.", " ",
150 | "Before trying this, use bankjob --wesabe-help to get more information.\n"
151 | ) do |wargs|
152 | options.wesabe_args = sub_args_to_array(wargs)
153 | options.wesabe_upload = true
154 | end
155 |
156 | opt.on('--version', "Display program version and exit.\n" ) do
157 | puts opt.version
158 | exit
159 | end
160 |
161 | opt.on_tail('-h', '--help', "Display this usage message and exit.\n" ) do
162 | puts opt
163 | puts <<-EOF
164 |
165 | Some common options:
166 |
167 | o Debugging:
168 | --debug --scraper bpi_scraper.rb --input /tmp/DownloadedPage.html --ofx
169 |
170 | o Regular use: (output ofx documents to a directory called 'bank')
171 | --scraper /bank/mybank_scraper.rb --scraper-args "me mypass123" --ofx /bank --log /bank/bankjob.log --verbose
172 |
173 | o Abbreviated options with CSV output: (output csv appended continuously to a file)
174 | -s /bank/otherbank_scraper.rb --csv /bank/statements.csv -l /bank/bankjob.log -q
175 |
176 | o Get help on using Wesabe:
177 | --wesabe-help
178 |
179 | o Upload to Wesabe: (I have 4 Wesabe accounts and am uploading to the 3rd)
180 | -s /bank/mybank_scraper.rb --wesabe "mywesabeuser password 3" -l /bank/bankjob.log --debug
181 | EOF
182 | exit!
183 | end
184 |
185 | end #OptionParser.new
186 |
187 | begin
188 | opt.parse!(args)
189 | _validate_options(options) # will raise exceptions if options are invalid
190 | _init_logger(options) # sets the logger
191 | rescue Exception => e
192 | puts e, "", opt
193 | exit
194 | end
195 |
196 | return options
197 | end #self.parse
198 |
199 | private
200 |
201 | # Checks if the options are valid, raising exceptiosn if they are not.
202 | # If the --debug option is true, then messages are dumped but flow continues
203 | def self._validate_options(options)
204 | begin
205 | #Note that OptionParser doesn't really handle compulsory arguments so we use
206 | #our own mechanism
207 | if options.scraper == NEEDED
208 | raise "Incomplete arguments: You must specify a scaper ruby script with --scraper"
209 | end
210 |
211 | # Add in the --ofx option if it is not already specified and if --csv is not specified either
212 | options.ofx = true unless options.csv or options.wesabe_upload
213 | rescue Exception => e
214 | if options.debug
215 | # just dump the message and eat the exception -
216 | # we may be using dummy values for debugging
217 | puts "Ignoring error in options due to --debug flag: #{e}"
218 | else
219 | raise e
220 | end
221 | end #begin/rescue
222 |
223 | end #_validate_options
224 |
225 | ##
226 | # Initializes the logger taking the log-level and the log
227 | # file name from the command line +options+ and setting the logger back on
228 | # the options struct as +options.logger+
229 | #
230 | # Note that the level is not set explicitly in options but derived from
231 | # flag options like --verbose (INFO), --quiet (FATAL) and --debug (DEBUG)
232 | #
233 | def self._init_logger(options)
234 | # the log log should roll over weekly
235 | if options.log_file.nil?
236 | if options.debug
237 | # if debug is on but no logfile is specified then log to console
238 | options.log_file = STDOUT
239 | else
240 | # Setting the log level to UNKNOWN effectively turns logging off
241 | options.log_level = Logger::UNKNOWN
242 | end
243 | end
244 | options.logger = Logger.new(options.log_file, 'weekly') # roll over weekly
245 | options.logger.level = options.log_level
246 | end
247 |
248 | # Takes a string of arguments and splits it into an array, allowing for 'single quotes'
249 | # to join words into a single argument.
250 | # (Note that parentheses are used to group to exclude the single quotes themselves, but grouping
251 | # results in scan creating an array of arrays with some nil elements hence flatten and delete)
252 | def self.sub_args_to_array(subargs)
253 | return nil if subargs.nil?
254 | return subargs.scan(/([^\s']+)|'([^']*)'/).flatten.delete_if { |x| x.nil?}
255 | end
256 |
257 | end #class CLI
258 | end
259 |
--------------------------------------------------------------------------------
/lib/bankjob/transaction.rb:
--------------------------------------------------------------------------------
1 |
2 | require 'rubygems'
3 | require 'builder'
4 | require 'digest/md5'
5 | require 'bankjob.rb'
6 |
7 | module Bankjob
8 |
9 | ##
10 | # A Transaction object represents a transaction in a bank account (a withdrawal, deposit,
11 | # transfer, etc) and is generally the result of running a Bankjob scraper.
12 | #
13 | # A Scraper will create Transactions while scraping web pages in an online banking site.
14 | # These Transactions will be collected in a Statement object which will then be written
15 | # to a file.
16 | #
17 | # A Transaction object knows how to write itself as a record in a CSV
18 | # (Comma Separated Values) file using +to_csv+ or as an XML element in an
19 | # OFX (Open Financial eXchange http://www.ofx.net) file using +to_ofx+
20 | #
21 | class Transaction
22 |
23 | # OFX transaction type for Generic credit
24 | CREDIT = "CREDIT"
25 |
26 | # OFX transaction type for Generic debit
27 | DEBIT = "DEBIT"
28 |
29 | # OFX transaction type for Interest earned or paid. (Depends on signage of amount)
30 | INT = "INT"
31 |
32 | # OFX transaction type for Dividend
33 | DIV = "DIV"
34 |
35 | # OFX transaction type for FI fee
36 | FEE = "FEE"
37 |
38 | # OFX transaction type for Service charge
39 | SRVCHG = "SRVCHG"
40 |
41 | # OFX transaction type for Deposit
42 | DEP = "DEP"
43 |
44 | # OFX transaction type for ATM debit or credit. (Depends on signage of amount)
45 | ATM = "ATM"
46 |
47 | # OFX transaction type for Point of sale debit or credit. (Depends on signage of amount)
48 | POS = "POS"
49 |
50 | # OFX transaction type for Transfer
51 | XFER = "XFER"
52 |
53 | # OFX transaction type for Check
54 | CHECK = "CHECK"
55 |
56 | # OFX transaction type for Electronic payment
57 | PAYMENT = "PAYMENT"
58 |
59 | # OFX transaction type for Cash withdrawal
60 | CASH = "CASH"
61 |
62 | # OFX transaction type for Direct deposit
63 | DIRECTDEP = "DIRECTDEP"
64 |
65 | # OFX transaction type for Merchant initiated debit
66 | DIRECTDEBIT = "DIRECTDEBIT"
67 |
68 | # OFX transaction type for Repeating payment/standing order
69 | REPEATPMT = "REPEATPMT"
70 |
71 | # OFX transaction type for Other
72 | OTHER = "OTHER"
73 |
74 | # OFX type of the transaction (credit, debit, atm withdrawal, etc)
75 | # Translates to the OFX element TRNTYPE and according to the OFX 2.0.3 schema this can be one of
76 | # * CREDIT
77 | # * DEBIT
78 | # * INT
79 | # * DIV
80 | # * FEE
81 | # * SRVCHG
82 | # * DEP
83 | # * ATM
84 | # * POS
85 | # * XFER
86 | # * CHECK
87 | # * PAYMENT
88 | # * CASH
89 | # * DIRECTDEP
90 | # * DIRECTDEBIT
91 | # * REPEATPMT
92 | # * OTHER
93 | attr_accessor :type
94 |
95 | # date of the transaction
96 | # Translates to OFX element DTPOSTED
97 | attr_accessor :date
98 |
99 | # the date the value affects the account (e.g. funds become available)
100 | # Translates to OFX element DTUSER
101 | attr_accessor :value_date
102 |
103 | # description of the transaction
104 | # This description is typically set by taking the raw description and
105 | # applying rules. If it is not set explicitly it returns the same
106 | # value as +raw_description+
107 | # Translates to OFX element MEMO
108 | attr_accessor :description
109 |
110 | # the original format of the description as scraped from the bank site
111 | # This allows the raw information to be preserved when modifying the
112 | # +description+ with transaction rules (see Scraper#transaction_rule)
113 | # This does _not_ appear in the OFX output, only +description+ does.
114 | attr_accessor :raw_description
115 |
116 | # amount of the credit or debit (negative for debits)
117 | # Translates to OFX element TRNAMT
118 | attr_accessor :amount
119 |
120 | # account balance after the transaction
121 | # Not used in OFX but important for working out statement balances
122 | attr_accessor :new_balance
123 |
124 | # account balance after the transaction as a numeric Ruby Float
125 | # Not used in OFX but important for working out statement balances
126 | # in calculations (see #real_amount)
127 | attr_reader :real_new_balance
128 |
129 | # the generated unique id for this transaction in an OFX record
130 | # Translates to OFX element FITID this is generated if not set
131 | attr_accessor :ofx_id
132 |
133 | # the payee of an expenditure (ie a debit or transfer)
134 | # This is of type Payee and translates to complex OFX element PAYEE
135 | attr_accessor :payee
136 |
137 | # the cheque number of a cheque transaction
138 | # This is of type Payee and translates to OFX element CHECKNUM
139 | attr_accessor :check_number
140 |
141 | ##
142 | # the numeric real-number amount of the transaction.
143 | #
144 | # The transaction amount is typically a string and may hold commas for
145 | # 1000s or for decimal separators, making it unusable for mathematical
146 | # operations.
147 | #
148 | # This attribute returns the amount converted to a Ruby Float, which can
149 | # be used in operations like:
150 | #
151 | # if (transaction.real_amount < 0)
152 | # puts "It's a debit!"
153 | # end
154 | #
155 | # The +real_amount+ attribute is calculated using the +decimal+ separator
156 | # passed into the constructor (defaults to ".")
157 | # See Scraper#decimal
158 | #
159 | # This attribute is not used in OFX.
160 | #
161 | attr_reader :real_amount
162 |
163 | ##
164 | # Creates a new Transaction with the specified attributes.
165 | #
166 | def initialize(decimal = ".")
167 | @ofx_id = nil
168 | @date = nil
169 | @value_date = nil
170 | @raw_description = nil
171 | @description = nil
172 | @amount = 0
173 | @new_balance = 0
174 | @decimal = decimal
175 |
176 | # Always create a Payee even if it doesn't get used - this ensures an empty
177 | # element in the OFX output which is more correct and, for one thing,
178 | # stops Wesabe from adding UNKNOWN PAYEE to every transaction (even deposits)
179 | @payee = Payee.new()
180 | @check_number = nil
181 | @type = OTHER
182 | end
183 |
184 | def date=(raw_date_time)
185 | @date = Bankjob.create_date_time(raw_date_time)
186 | end
187 |
188 | def value_date=(raw_date_time)
189 | @value_date = Bankjob.create_date_time(raw_date_time)
190 | end
191 |
192 | ##
193 | # Creates a unique ID for the transaction for use in OFX documents, unless
194 | # one has already been set.
195 | # All OFX transactions need a unique identifier.
196 | #
197 | # Note that this is generated by creating an MD5 digest of the transaction
198 | # date, raw description, type, amount and new_balance. Which means that two
199 | # identical transactions will always produce the same +ofx_id+.
200 | # (This is important so that repeated scrapes of the same transaction value
201 | # produce identical ofx_id values)
202 | #
203 | def ofx_id()
204 | if @ofx_id.nil?
205 | text = "#{@date}:#{@raw_description}:#{@type}:#{@amount}:#{@new_balance}"
206 | @ofx_id= Digest::MD5.hexdigest(text)
207 | end
208 | return @ofx_id
209 | end
210 |
211 | ##
212 | # Returns the description, defaulting to the +raw_description+ if no
213 | # specific description has been set by the user.
214 | #
215 | def description()
216 | @description.nil? ? raw_description : @description
217 | end
218 |
219 | ##
220 | # Returns the Transaction amount attribute as a ruby Float after
221 | # replacing the decimal separator with a . and stripping any other
222 | # separators.
223 | #
224 | def real_amount()
225 | Bankjob.string_to_float(amount, @decimal)
226 | end
227 |
228 | ##
229 | # Returns the new balance after the transaction as a ruby Float after
230 | # replacing the decimal separator with a . and stripping any other
231 | # separators.
232 | #
233 | def real_new_balance()
234 | Bankjob.string_to_float(new_balance, @decimal)
235 | end
236 |
237 | ##
238 | # Generates a string representing this Transaction as comma separated values
239 | # in the form:
240 | #
241 | # date, value_date, description, real_amount, real_new_balance, amount, new_balance, raw_description, ofx_id
242 | #
243 | def to_csv
244 | # if there's a payee, prepend their name to the description - otherwise skip it
245 | if (not payee.nil? and (not payee.name.nil?))
246 | desc = payee.name + " - " + description
247 | else
248 | desc = description
249 | end
250 | [Bankjob.date_time_to_csv(date), Bankjob.date_time_to_csv(value_date), desc, real_amount, real_new_balance, amount, new_balance, raw_description, ofx_id].to_csv
251 | end
252 |
253 | ##
254 | # Generates a string for use as a header in a CSV file for transactions.
255 | # This will produce the following string:
256 | #
257 | # date, value_date, description, real_amount, real_new_balance, amount, new_balance, raw_description, ofx_id
258 | #
259 | def self.csv_header
260 | %w{ Date Value-Date Description Amount New-Balance Raw-Amount Raw-New-Balance Raw-Description OFX-ID }.to_csv
261 | end
262 |
263 | ##
264 | # Creates a new Transaction from a string that defines a row in a CSV file.
265 | #
266 | # +csv_row+ must hold an array of values in precisely this order:
267 | #
268 | # date, value_date, description, real_amount, real_new_balance, amount, new_balance, raw_description, ofx_id
269 | #
270 | # (The format should be the same as that produced by +to_csv+)
271 | #
272 | def self.from_csv(csv_row, decimal)
273 | if (csv_row.length != 9) # must have 9 cols
274 | csv_lines = csv_row.join("\n\t")
275 | msg = "Failed to create Transaction from csv row: \n\t#{csv_lines}\n"
276 | msg << " - 9 columns are required in the form: date, value_date, "
277 | msg << "description, real_amount, real_new_balance, amount, new_balance, "
278 | msg << "raw_description, ofx_id"
279 | raise msg
280 | end
281 | tx = Transaction.new(decimal)
282 | tx.date, tx.value_date, tx.description = csv_row[0..2]
283 | # skip real_amount and real_new_balance, they're read only and calculated
284 | tx.amount, tx.new_balance, tx.raw_description, tx.ofx_id = csv_row[5..8]
285 | return tx
286 | end
287 |
288 | ##
289 | # Generates an XML string adhering to the OFX standard
290 | # (see Open Financial Exchange http://www.ofx.net)
291 | # representing a single Transaction XML element.
292 | #
293 | # The OFX 2 schema defines a STMTTRN (SatementTransaction) as follows:
294 | #
295 | #
296 | #
297 | #
298 | # The OFX element "STMTTRN" is of type "StatementTransaction"
299 | #
300 | #
301 | #
302 | #
303 | #
304 | #
305 | #
306 | #
307 | #
308 | #
309 | #
310 | #
311 | #
312 | #
313 | #
314 | #
315 | #
316 | #
317 | #
318 | #
319 | #
320 | #
321 | #
322 | #
323 | #
324 | #
325 | #
326 | #
327 | #
328 | #
329 | #
330 | #
331 | #
332 | #
333 | #
334 | def to_ofx
335 | buf = ""
336 | # Set margin=5 to indent it nicely within the output from Statement.to_ofx
337 | x = Builder::XmlMarkup.new(:target => buf, :indent => 2, :margin=>5)
338 | x.STMTTRN { # transaction statement
339 | x.TRNTYPE type
340 | x.DTPOSTED Bankjob.date_time_to_ofx(date) #Date transaction was posted to account, [datetime] yyyymmdd or yyyymmddhhmmss
341 | x.TRNAMT amount #Ammount of transaction [amount] can be , or . separated
342 | x.FITID ofx_id
343 | x.CHECKNUM check_number unless check_number.nil?
344 | buf << payee.to_ofx unless payee.nil?
345 | #x.NAME description
346 | x.MEMO description
347 | }
348 | return buf
349 | end
350 |
351 | ##
352 | # Produces a string representation of the transaction
353 | #
354 | def to_s
355 | "#{self.class} - ofx_id: #{@ofx_id}, date:#{@date}, raw description: #{@raw_description}, type: #{@type} amount: #{@amount}, new balance: #{@new_balance}"
356 | end
357 |
358 | ##
359 | # Overrides == to allow comparison of Transaction objects so that they can
360 | # be merged in Statements. See Statement#merge
361 | #
362 | def ==(other) #:nodoc:
363 | if other.kind_of?(Transaction)
364 | # sometimes the same date, when written and read back will not appear equal so convert to
365 | # a canonical string first
366 | return (Bankjob.date_time_to_ofx(@date) == Bankjob.date_time_to_ofx(other.date) and
367 | # ignore value date - it may be updated between statements
368 | # (consider using ofx_id here later)
369 | @raw_description == other.raw_description and
370 | @amount == other.amount and
371 | @type == other.type and
372 | @new_balance == other.new_balance)
373 | end
374 | end
375 |
376 | #
377 | # Overrides eql? so that array union will work when merging statements
378 | #
379 | def eql?(other) #:nodoc:
380 | return self == other
381 | end
382 |
383 | ##
384 | # Overrides hash so that array union will work when merging statements
385 | #
386 | def hash() #:nodoc:
387 | prime = 31;
388 | result = 1;
389 | result = prime * result + @amount.to_i
390 | result = prime * result + @new_balance.to_i
391 | result = prime * result + (@date.nil? ? 0 : Bankjob.date_time_to_ofx(@date).hash);
392 | result = prime * result + (@raw_description.nil? ? 0 : @raw_description.hash);
393 | result = prime * result + (@type.nil? ? 0 : @type.hash);
394 | # don't use value date
395 | return result;
396 | end
397 |
398 | end # class Transaction
399 | end # module
400 |
401 |
--------------------------------------------------------------------------------
/lib/bankjob/statement.rb:
--------------------------------------------------------------------------------
1 | require 'rubygems'
2 | require 'builder'
3 | require 'fastercsv'
4 | require 'bankjob'
5 |
6 | module Bankjob
7 |
8 | ##
9 | # A Statement object represents a bank statement and is generally the result of running a Bankjob scraper.
10 | # The Statement holds an array of Transaction objects and specifies the closing balance and the currency in use.
11 | #
12 | # A Scraper will create a Statement by scraping web pages in an online banking site.
13 | # The Statement can then be stored as a file in CSV (Comma Separated Values) format
14 | # using +to_csv+ or in OFX (Open Financial eXchange http://www.ofx.net) format
15 | # using +to_ofx+.
16 | #
17 | # One special ability of Statement is the ability to merge with an existing statement,
18 | # automatically eliminating overlapping transactions.
19 | # This means that when writing subsequent statements to the same CSV file
20 | # (note well: CSV only) a continous transaction record can be built up
21 | # over a long period.
22 | #
23 | class Statement
24 |
25 | # OFX value for the ACCTTYPE of a checking account
26 | CHECKING = "CHECKING"
27 |
28 | # OFX value for the ACCTTYPE of a savings account
29 | SAVINGS = "SAVINGS"
30 |
31 | # OFX value for the ACCTTYPE of a money market account
32 | MONEYMRKT = "MONEYMRKT"
33 |
34 | # OFX value for the ACCTTYPE of a loan account
35 | CREDITLINE = "CREDITLINE"
36 |
37 | # the account balance after the last transaction in the statement
38 | # Translates to the OFX element BALAMT in LEDGERBAL
39 | attr_accessor :closing_balance
40 |
41 | # the avaliable funds in the account after the last transaction in the statement (generally the same as closing_balance)
42 | # Translates to the OFX element BALAMT in AVAILBAL
43 | attr_accessor :closing_available
44 |
45 | # the array of Transaction objects that comprise the statement
46 | attr_accessor :transactions
47 |
48 | # the three-letter currency symbol generated into the OFX output (defaults to EUR)
49 | # This is passed into the initializer (usually by the Scraper - see Scraper#currency)
50 | attr_reader :currency
51 |
52 | # the identifier of the bank - a 1-9 char string (may be empty)
53 | # Translates to the OFX element BANKID
54 | attr_accessor :bank_id
55 |
56 | # the account number of the statement - a 1-22 char string that must be passed
57 | # into the initalizer of the Statement
58 | # Translates to the OFX element ACCTID
59 | attr_accessor :account_number
60 |
61 | # the type of bank account the statement is for
62 | # Tranlsates to the OFX type ACCTTYPE and must be one of
63 | # * CHECKING
64 | # * SAVINGS
65 | # * MONEYMRKT
66 | # * CREDITLINE
67 | # Use a constant to set this - defaults to CHECKING
68 | attr_accessor :account_type
69 |
70 | # the last date of the period the statement covers
71 | # Translates to the OFX element DTEND
72 | attr_accessor :to_date
73 |
74 | # the first date of the period the statement covers
75 | # Translates to the OFX element DTSTART
76 | attr_accessor :from_date
77 |
78 | ##
79 | # Creates a new empty Statement with no transactions.
80 | # The +account_number+ must be specified as a 1-22 character string.
81 | # The specified +currency+ defaults to EUR if nothing is passed in.
82 | #
83 | def initialize(account_number, currency = "EUR")
84 | @account_number = account_number
85 | @currency = currency
86 | @transactions = []
87 | @account_type = CHECKING
88 | @closing_balance = nil
89 | @closing_available = nil
90 | end
91 |
92 | ##
93 | # Appends a new Transaction to the end of this Statement
94 | #
95 | def add_transaction(transaction)
96 | @transactions << transaction
97 | end
98 |
99 | ##
100 | # Overrides == to allow comparison of Statement objects.
101 | # Two Statements are considered equal (that is, ==) if
102 | # and only iff they have the same values for:
103 | # * +to_date+
104 | # * +from_date+
105 | # * +closing_balance+
106 | # * +closing_available+
107 | # * each and every transaction.
108 | # Note that the transactions are compared with Transaction.==
109 | #
110 | def ==(other) # :nodoc:
111 | if other.kind_of?(Statement)
112 | return (from_date == other.from_date and
113 | to_date == other.to_date and
114 | closing_balance == other.closing_balance and
115 | closing_available == other.closing_available and
116 | transactions == other.transactions)
117 | end
118 | return false
119 | end
120 |
121 | ##
122 | # Merges the transactions of +other+ into the transactions of this statement
123 | # and returns the resulting array of transactions
124 | # Raises an exception if the two statements overlap in a discontiguous fashion.
125 | #
126 | def merge_transactions(other)
127 | if (other.kind_of?(Statement))
128 | union = transactions | other.transactions # the set union of both
129 | # now check that the union contains all of the originals, otherwise
130 | # we have merged some sort of non-contiguous range
131 | raise "Failed to merge transactions properly." unless union.first(@transactions.length) == @transactions
132 | return union
133 | end
134 | end
135 |
136 | ##
137 | # Merges the transactions of +other+ into the transactions of this statement
138 | # and returns the result.
139 | # Neither statement is changed. See #merge! if you want to modify the statement.
140 | # Raises an exception if the two statements overlap in a discontiguous fashion.
141 | #
142 | def merge(other)
143 | union = merge_transactions(other)
144 | merged = self.dup
145 | merged.closing_balance = nil
146 | merged.closing_available = nil
147 | merged.transactions = union
148 | return merged
149 | end
150 |
151 | ##
152 | # Merges the transactions of +other+ into the transactions of this statement.
153 | # Causes this statement to be changed. See #merge for details.
154 | #
155 | def merge!(other)
156 | @closing_balance = nil
157 | @closing_available = nil
158 | @transactions = merge_transactions(other)
159 | end
160 |
161 | ##
162 | # Generates a CSV (comma separated values) string with a single
163 | # row for each transaction.
164 | # Note that no header row is generated as it would make it
165 | # difficult to concatenate and merge subsequent CSV strings
166 | # (but we should consider it as a user option in the future)
167 | #
168 | def to_csv
169 | buf = ""
170 | transactions.each do |transaction|
171 | buf << transaction.to_csv
172 | end
173 | return buf
174 | end
175 |
176 | ##
177 | # Generates a string for use as a header in a CSV file for a statement.
178 | #
179 | # Delegates to Transaction#csv_header
180 | #
181 | def self.csv_header
182 | return Transaction.csv_header
183 | end
184 |
185 | ##
186 | # Reads in transactions from a CSV file or string specified by +source+
187 | # and adds them to this statement.
188 | #
189 | # Uses a simple (dumb) heuristic to determine if the +source+ is a file
190 | # or a string: if it contains a comma (,) then it is a string
191 | # otherwise it is treated as a file path.
192 | #
193 | def from_csv(source, decimal = ".")
194 | if (source =~ /,/)
195 | # assume source is a string
196 | FasterCSV.parse(source) do |row|
197 | add_transaction(Transaction.from_csv(row, decimal))
198 | end
199 | else
200 | # assume source is a filepath
201 | FasterCSV.foreach(source) do |row|
202 | add_transaction(Transaction.from_csv(row, decimal))
203 | end
204 | end
205 | end
206 |
207 | ##
208 | # Generates an XML string adhering to the OFX standard
209 | # (see Open Financial eXchange http://www.ofx.net)
210 | # representing a single bank statement holding a list
211 | # of transactions.
212 | # The XML for the individual transactions is generated
213 | # by the Transaction class itself.
214 | #
215 | # The OFX 2 schema for a statement response (STMTRS) is:
216 | #
217 | #
218 | #
219 | #
220 | # The OFX element "STMTRS" is of type "StatementResponse"
221 | #
222 | #
223 | #
224 | #
225 | #
226 | #
227 | #
228 | #
229 | #
230 | #
231 | #
232 | #
233 | #
234 | #
235 | # Where the BANKTRANLIST (Bank Transaction List) is defined as:
236 | #
237 | #
238 | #
239 | #
240 | # The OFX element "BANKTRANLIST" is of type "BankTransactionList"
241 | #
242 | #
243 | #
244 | #
245 | #
246 | #
247 | #
248 | #
249 | #
250 | # And this is the definition of the type BankAccount.
251 | #
252 | #
253 | #
254 | #
255 | # The OFX elements BANKACCTFROM and BANKACCTTO are of type "BankAccount"
256 | #
257 | #
258 | #
259 | #
260 | #
261 | #
262 | #
263 | #
264 | #
265 | #
266 | #
267 | #
268 | #
269 | #
270 | #
271 | # The to_ofx method will only generate the essential elements which are
272 | # * BANKID - the bank identifier (a 1-9 char string - may be empty)
273 | # * ACCTID - the account number (a 1-22 char string - may not be empty!)
274 | # * ACCTTYPE - the type of account - must be one of:
275 | # "CHECKING", "SAVINGS", "MONEYMRKT", "CREDITLINE"
276 | #
277 | # (See Transaction for a definition of STMTTRN)
278 | #
279 | def to_ofx
280 | buf = ""
281 | # Use Builder to generate XML. Builder works by catching missing_method
282 | # calls and generating an XML element with the name of the missing method,
283 | # nesting according to the nesting of the calls and using arguments for content
284 | x = Builder::XmlMarkup.new(:target => buf, :indent => 2)
285 | x.OFX {
286 | x.BANKMSGSRSV1 { #Bank Message Response
287 | x.STMTTRNRS { #Statement-transaction aggregate response
288 | x.STMTRS { #Statement response
289 | x.CURDEF currency #Currency
290 | x.BANKACCTFROM {
291 | x.BANKID bank_id # bank identifier
292 | x.ACCTID account_number
293 | x.ACCTTYPE account_type # acct type: checking/savings/...
294 | }
295 | x.BANKTRANLIST { #Transactions
296 | x.DTSTART Bankjob.date_time_to_ofx(from_date)
297 | x.DTEND Bankjob.date_time_to_ofx(to_date)
298 | transactions.each { |transaction|
299 | buf << transaction.to_ofx
300 | }
301 | }
302 | x.LEDGERBAL { # the final balance at the end of the statement
303 | x.BALAMT closing_balance # balance amount
304 | x.DTASOF Bankjob.date_time_to_ofx(to_date) # balance date
305 | }
306 | x.AVAILBAL { # the final Available balance
307 | x.BALAMT closing_available
308 | x.DTASOF Bankjob.date_time_to_ofx(to_date)
309 | }
310 | }
311 | }
312 | }
313 | }
314 | return buf
315 | end
316 |
317 | ONE_MINUTE = 60
318 | ELEVEN_59_PM = 23 * 60 * 60 + 59 * 60 # seconds at 23:59
319 | MIDDAY = 12 * 60 * 60
320 |
321 | ##
322 | # Finishes the statement after scraping in two ways depending on the information
323 | # that the scraper was able to obtain. Optionally have your scraper class call
324 | # this after scraping is finished.
325 | #
326 | # This method:
327 | #
328 | # 1. Sets the closing balance and available_balance and the to_ and from_dates
329 | # by using the first and last transactions in the list. Which transaction is
330 | # used depends on whether +most_recent_first+ is true or false.
331 | # The scraper may just set these directly in which case this may not be necessary.
332 | #
333 | # 2. If +fake_times+ is true time-stamps are invented and added to the transaction
334 | # date attributes. This is useful if the website beings scraped shows dates, but
335 | # not times, but has transactions listed in chronoligical arder.
336 | # Without this process, the ofx generated has no proper no indication of the order of
337 | # transactions that occurred in the same day other than the order in the statement
338 | # and this may be ignored by the client. (Specifically, Wesabe will reorder transactions
339 | # in the same day if they all appear to occur at the same time).
340 | #
341 | # Note that the algorithm to set the fake times is a little tricky. Assuming
342 | # the transactionsa are most-recent-first, the first last transaction on each
343 | # day is set at 11:59pm each transaction prior to that is one minute earlier.
344 | #
345 | # But for the first transactions in the statement, the first is set at a few
346 | # minutes after midnight, then we count backward. (The actual number of minutes
347 | # is based on the number of transactions + 1 to be sure it doesnt pass midnight)
348 | #
349 | # This is crucial because transactions for a given day will often span 2 or more
350 | # statement. By starting just after midnight and going back to just before midnight
351 | # we reduce the chance of overlap.
352 | #
353 | # If the to-date is the same as the from-date for a transaction, then we start at
354 | # midday, so that prior and subsequent statements don't overlap.
355 | #
356 | # This simple algorithm basically guarantees no overlaps so long as:
357 | # i. The number of transactions is small compared to the number of minutes in a day
358 | # ii. A single day will not span more than 3 statements
359 | #
360 | # If the statement is most-recent-last (+most_recent_first = false+) the same
361 | # algorithm is applied, only in reverse
362 | #
363 | def finish(most_recent_first, fake_times=false)
364 | if !@transactions.empty? then
365 | # if the user hasn't set the balances, set them to the first or last
366 | # transaction balance depending on the order
367 | if most_recent_first then
368 | @closing_balance ||= transactions.first.new_balance
369 | @closing_available ||= transactions.first.new_balance
370 | @to_date ||= transactions.first.date
371 | @from_date ||= transactions.last.date
372 | else
373 | @closing_balance ||= transactions.last.new_balance
374 | @closing_available ||= transactions.last.new_balance
375 | @to_date ||= transactions.last.date
376 | @from_date ||= transactions.first.date
377 | end
378 |
379 | if fake_times and to_date.hour == 0 then
380 | # the statement was unable to scrape times to go with the dates, but the
381 | # client (say wesabe) will get the transaction order wrong if there are no
382 | # times, so here we add times that order the transactions according to the
383 | # order of the array of transactions
384 |
385 | # the delta is 1 minute forward or backward fr
386 | if to_date == from_date then
387 | # all of the statement's transactions occur in the same day - to try to
388 | # avoid overlap with subsequent or previous transacitons we group order them
389 | # from 11am onward
390 | seconds = MIDDAY
391 | else
392 | seconds = (transactions.length + 1) * 60
393 | end
394 |
395 | if most_recent_first then
396 | yday = transactions.first.date.yday
397 | start = 0
398 | delta = 1
399 | finish = transactions.length
400 | else
401 | yday = transactions.last.date.yday
402 | start = transactions.length - 1
403 | finish = -1
404 | delta = -1
405 | end
406 |
407 | i = start
408 | until i == finish
409 | tx = transactions[i]
410 | if tx.date.yday != yday
411 | # starting a new day, begin the countdown from 23:59 again
412 | yday = tx.date.yday
413 | seconds = ELEVEN_59_PM
414 | end
415 | tx.date += seconds unless tx.date.hour > 0
416 | seconds -= ONE_MINUTE
417 | i += delta
418 | end
419 | end
420 | end
421 | end
422 |
423 | def to_s
424 | buf = "#{self.class}: close_bal = #{closing_balance}, avail = #{closing_available}, curr = #{currency}, transactions:"
425 | transactions.each do |tx|
426 | buf << "\n\t\t#{tx.to_s}"
427 | end
428 | buf << "\n---\n"
429 | return buf
430 | end
431 | end # class Statement
432 | end # module
433 |
--------------------------------------------------------------------------------
/lib/bankjob/scraper.rb:
--------------------------------------------------------------------------------
1 |
2 | require 'rubygems'
3 | require 'mechanize'
4 | require 'logger'
5 | require 'bankjob'
6 |
7 | module Bankjob
8 |
9 | ##
10 | # The Scraper class is the basis of all Bankjob web scrapers for scraping specific
11 | # bank websites.
12 | #
13 | # To create your own scraper simply subclass Scraper and be sure to override
14 | # the method +scrape_statement+ to perform the scraping and return a
15 | # Bankjob::Statement object.
16 | #
17 | # Scraper provides some other optional methods to help you build Statements:
18 | #
19 | # +currency+:: use this class attribute to set the OFX currency at the top of
20 | # your Scraper subclass definition. E.g.:
21 | #
22 | #
23 | # class MyScraper < Scraper
24 | # currency "USD"
25 | # ...
26 | #
27 | # It defaults to "EUR" for euros.
28 | #
29 | # +decimal+:: use this class attribute to set the decimal separator at the top of
30 | # your Scraper subclass definition. E.g.:
31 | #
32 | # class MyScraper < Scraper
33 | # decimal ","
34 | # ...
35 | #
36 | # It defaults to "." (period), the common alternative being "," (comma)
37 | #
38 | # Note that this should be set to the separator used in the +amount+
39 | # attribute of the Transaction objects your Scraper creates. If, say,
40 | # you deliberately scrape values like "12,34" and convert them to
41 | # "12.34" before storing them in your Transaction, then leave the
42 | # decimal as ".".
43 | # If you choose to store the Transaction amount with as "12,34",
44 | # however, the +decimal+ setting becomes important when calling
45 | # Transaction#real_amount to get the amount as a Float upon which
46 | # calculations can be performed.
47 | #
48 | # +options+:: holds the command line options provided when Bankjob was launched.
49 | # Use this attribute to get access to global options. For your scraper
50 | # specific options use the array passed into +scrape_statement+ instead.
51 | # (See #options below for more advice on how to use this)
52 | #
53 | # +logger+:: holds the logger initialized by Bankjob based on the command line
54 | # options. Use this to attribute to log information, warnings and debug messages
55 | # from your logger.
56 | # (See #logger below for more advice on how to use this)
57 | #
58 | # +create_statement+:: creates a new empty Statement object with the appropriate
59 | # default attributes (that is, the right currency)
60 | # Use this in your Scraper to instantiate new Statement objects.
61 | #
62 | # +create_transaction+:: creates a new empty Transaction object with the appropriate
63 | # default attributes (that is, the right decimal separator)
64 | # Use this in your Scraper to instantiate new Transaction objects.
65 | #
66 | # +transaction_rule+:: registers a rule to be applied to all transactions after the
67 | # statement has been scraped.
68 | # Define as many of these as you need in your craper to build better
69 | # organized Transaction objects with clearer descriptions of the
70 | # transaction, etc.
71 | #
72 | # +finish+ :: finishes a transaction by setting the balances and to and from dates
73 | # based on the first and last transactions. Also, optionally, generates
74 | # fake timestamps for transactions that have no time component in their
75 | # dates. This is important for clients that use the timestamps to order
76 | # the transactions correctly, and would otherwise mess up the order
77 | # if all transactions on the same day were at the same time (E.g. Wesabe)
78 | #
79 | # Here is an example of a simple (but incomplete) scraper.
80 | # Note that all of the scraping and parsing is in the +scrape_statement+ method, although
81 | # a lot of the details of Hpricot parsing are left up to the imagination of the reader.
82 | #
83 | # When creating a scraper yourself look in the +scrapers+ directory of the bankjob gem
84 | # to see some more useful examples.
85 | #
86 | # class AcmeBankScraper < Scraper
87 | # #####
88 | # # 1. Set up the Scraper properties for currency and separator
89 | # # (this is optional)
90 | #
91 | # currency "EUR" # set the currency (EUR is the default anyway but just to demo..)
92 | # decimal "," # set the decimal separator to comma instead of .
93 | #
94 | # #####
95 | # # 2. Create some rules to post-process my transactions
96 | # # (this is optional but is easier to maintain than manipulating
97 | # # the values in the scraper itself)
98 | #
99 | # # rule to set negative transactions as debits
100 | # transaction_rule do |tx|
101 | # tx.type = "DEBIT" if (tx.real_amount < 0 and tx.type == "OTHER")
102 | # end
103 | #
104 | # # General description parsing rule
105 | # transaction_rule do |tx|
106 | # case tx.description
107 | # when /ATM/i
108 | # tx.type = "ATM"
109 | # when /ELEC PURCHASE/
110 | # tx.description.gsub!(/ELEC PURCHASE \d+/, "spent with ATM card: ")
111 | # end
112 | # end
113 | #
114 | # #####
115 | # # 3. Implement main engine of the scraper
116 | # # (this is essential and where 99% of the work is)
117 | #
118 | # def scrape_statement(args)
119 | #
120 | # logger.debug("Reading debug input html from #{options.input} instead of scraping the real website.")
121 | # agent = WWW::Mechanize.new
122 | # agent.user_agent_alias = 'Windows IE 6' # pretend that we're IE 6.0
123 | # # navigate to the login page
124 | # login_page = agent.get("http://mybank.com/login")
125 | # # find login form, fill it out and submit it
126 | # form = login_page.forms.name('myBanksLoginForm').first
127 | # # Mechanize creates constants like USERNAME for the form element it finds with that name
128 | # form.USERNAME = args[0] # assuming -scraper_args "user password"
129 | # form.PASSWORD = args[1]
130 | # agent.submit(form)
131 | # sleep 3 #wait while the login takes effect
132 | #
133 | # transactions_page = agent.get("http://mybank.com/transactions")
134 | # statement = create_statement
135 | #
136 | # # ... go read the Hpricot documentation to work out how to get your transactions out of
137 | # # the transactions_page and create a new transaction object for each one
138 | # # We're going to gloss over that part here ....
139 | #
140 | # table = # use Hpricot to get the html table element assuming your transactions are in a table
141 | # rows = (table/"tr[@valign=top]") # works for a table where the rows needed have the valign attr set to top
142 | # rows.each do |row|
143 | # transaction = create_transaction
144 | # transaction.date = #... scrape a date here
145 | # ...
146 | # statement.transactions << transaction
147 | # end
148 | # end
149 | # end
150 | #
151 | #--
152 | # (Non RDOC comment) There are two parts to the Scraper class:
153 | # - the public part which defines the
154 | # method to be overridden in subclasses and provides utility methods and attributes;
155 | # - the private internal part which handles the mechanics of registering a
156 | # subclass as the scraper to be used, setting the currency and decimal attributes
157 | # and registering transaction rules
158 | #
159 | #
160 | class Scraper
161 |
162 | ##
163 | # Provides access to a logger instance created in the BankjobRunner which
164 | # subclasses can use for logging if they need to.
165 | #
166 | # To use this in your own scraper, use code like:
167 | #
168 | # include 'logger'
169 | # ...
170 | # logger.debug("MyScraper is scraping the page at #{my_url}")
171 | # logger.info("MyScraper fetched new statement from MyBank and has been sitting in my chair")
172 | # logger.warn("MyScraper's been sitting in MY chair!")
173 | # logger.fatal("MyScraper's been sitting in MY CHAIR and IT'S ALL BROKEN!")
174 | #
175 | attr_accessor :logger
176 |
177 | ##
178 | # Provides access to the command line options which subclasses can use it if
179 | # they need access to the global options used to launch Bankjob
180 | #
181 | # To use this in your own scraper, use code like:
182 | #
183 | # if (options.input?) then
184 | # print "the input html file for debugging is #{options.input}
185 | # end
186 | #
187 | attr_accessor :options
188 |
189 | ##
190 | # Returns the decimal separator for this scraper
191 | # This is typically set in the scraper class using the "decimal" directive.
192 | #
193 | def decimal
194 | @@decimal
195 | end
196 |
197 | ##
198 | # Returns the OFX currency for this scraper.
199 | # This is typically set in the scraper class using the "currency" directive.
200 | #
201 | def currency
202 | @@currency
203 | end
204 |
205 | ##
206 | # Sets the decimal separator for the money amounts used in the data fetched
207 | # by this scraper.
208 | # The scraper class can use this as a directive to set the separator so:
209 | # decimal ","
210 | #
211 | # Defaults to period ".", but will typically need to be set as a comma in
212 | # european websites
213 | #
214 | def self.decimal(decimal)
215 | @@decimal = decimal
216 | end
217 |
218 | ##
219 | # Sets the OFX currency name for use in the OFX statements produced by
220 | # this scraper.
221 | #
222 | # The scraper class can use this as a directive to set the separator so:
223 | # currency "USD"
224 | #
225 | # Defaults to EUR
226 | #
227 | def self.currency(currency)
228 | @@currency = currency
229 | end
230 |
231 | ##
232 | # Sets the account number for statements produced by this statement.
233 | #
234 | # The scraper class can use this as a directive to set the number so:
235 | # account_number "12345678"
236 | #
237 | # Must be a string from 1 to 22 chars in length
238 | #
239 | # This will be used by the create_statement method to set the account,
240 | # but the scraper may ignore this and simply construct its own statements
241 | # or change the number using the accessor: statement.account_number =
242 | # after constructing it.
243 | #
244 | # The scraper class can use this as a directive to set the separator so:
245 | # currency "USD"
246 | #
247 | # Defaults to EUR
248 | #
249 | def self.account_number(account_number)
250 | @@account_number = account_number
251 | end
252 |
253 | ##
254 | # Sets the account type for statements produced by this statement.
255 | #
256 | # The scraper class can use this as a directive to set the type so:
257 | # account_type Statement::SAVINGS
258 | #
259 | # Must be a string based on one of the constants in Statement
260 | #
261 | # This will be used by the create_statement method to set the account type,
262 | # but the scraper may ignore this and simply construct its own statements
263 | # or change the type using the accessor: statement.account_type =
264 | # after constructing it.
265 | #
266 | # Defaults to Statement::CHECKING
267 | #
268 | def self.account_type(account_type)
269 | @@account_type = account_type
270 | end
271 |
272 | ##
273 | # Sets the bank identifier for statements produced by this statement.
274 | #
275 | # The scraper class can use this as a directive to set the number so:
276 | # bank_id "12345678"
277 | #
278 | # Must be a string from 1 to 9 chars in length
279 | #
280 | # This will be used by the create_statement method to set the bank id,
281 | # but the scraper may ignore this and simply construct its own statements
282 | # or change the number using the accessor: statement.bank_id =
283 | # after constructing it.
284 | #
285 | # Defaults to blank
286 | #
287 | def self.bank_id(bank_id)
288 | @@bank_id = bank_id
289 | end
290 |
291 | ##
292 | # ScraperRule is a struct used for holding a rule body with its priority.
293 | # Users can create transaction rules in their Scraper subclasses using
294 | # the Scraper#ransaction_rule method.
295 | ScraperRule = Struct.new(:priority, :rule_body)
296 |
297 | ##
298 | # Processes a transaction after it has been created to allow it to be manipulated
299 | # into a more useful form for the client.
300 | #
301 | # For example, the transaction description might be simplified to remove certain
302 | # common strings, or the Payee details might be extracted from the description.
303 | #
304 | # Implementing this as a class method using a block permits the user to add
305 | # implement transaction processing rules by calling this method several times
306 | # rather than implementing a single method (gives it a sort of DSL look)
307 | #
308 | # E.g.
309 | # # This rule detects ATM withdrawals and modifies
310 | # # the description and sets the the type it uses
311 | # transaction_rule do |tx|
312 | # if (tx.real_amount < 0)
313 | # if tx.raw_description =~ /WDR.*ATM\s+\d+\s+/i
314 | # # $' holds whatever is after the pattern match - usually the ATM location
315 | # tx.description = "ATM withdrawal at #{$'}"
316 | # tx.type = Transaction::ATM
317 | # end
318 | # end
319 | # end
320 | #
321 | #
322 | # A transaction rule can optionally specifiy a +priority+ - any integer value.
323 | # The default priority is zero, with lower priority rules being executed last.
324 | #
325 | # The final order in which transaction rules will be executed is thus:
326 | # * rules with a higher priority value will be executed before rules with
327 | # a lower priority no matter where they are declared
328 | # * rules of the same priority declared in the same class wil be executed in
329 | # the order in which they are declared - top rules first
330 | # * rules in parent classes are executed before rules in subclasses of the
331 | # same priority.
332 | #
333 | # If you really want a rule to be fired last, and you want to allow for
334 | # subclasses to your scraper, use a negative priority like this:
335 | #
336 | # transaction_rule(-999) do |tx|
337 | # puts "I get executed last"
338 | # end
339 | #
340 | def self.transaction_rule(priority = 0, &rule_body)
341 | @@transaction_rules ||= []
342 | rule = ScraperRule.new(priority, rule_body)
343 | # Using Array#sort won't work on here (or later) because it doesn't preserve
344 | # the order of the rules with equal priorty - thus breaking the
345 | # rules of priority detailed above. So we have to sort as we insert
346 | # each new rule in order without messing up the equal-priority order
347 | # which is first come, first in.
348 | # Imagine we have a set of rule already inorder of priority such as:
349 | # A:999, B:999, C:0, D:0, E:-999, F:-999
350 | # we're now adding X:0, which should come after D since it's added later
351 | # First we reverse the array to get
352 | # F:-999, E:-999, D:0, C:0, B:999, A:999
353 | # then we find the first element with priority greater than or equal to
354 | # X's priority of 0. Just greater than won't work because we'll end up
355 | # putting X between B and C whereas it was added after D.
356 | # So we find D, then get it's index in the original array which is 3
357 | # which tells us we can insert X at 4 into the forward-sorted rules
358 | #
359 | rev = @@transaction_rules.reverse
360 | last_higher_or_equal = rev.find { |r| r.priority.to_i >= priority }
361 | if last_higher_or_equal.nil?
362 | # insert a the start of the list
363 | @@transaction_rules.insert(0, rule)
364 | else
365 | index_of_last = @@transaction_rules.index(last_higher_or_equal)
366 | # now insert it after the last higher or equal priority rule
367 | @@transaction_rules.insert(index_of_last + 1, rule)
368 | end
369 | end
370 |
371 | ##
372 | # Runs through all of the rules registered with calls to +transaction_rule+
373 | # and applies them to each Transaction in the specified +statement+.
374 | #
375 | # Bankjob calls this after +scrape_statement+ and before writing out the
376 | # statement to CSV or OFX
377 | #
378 | def self.post_process_transactions(statement) #:nodoc:
379 | if defined?(@@transaction_rules)
380 | @@transaction_rules.each do |rule|
381 | statement.transactions.each do |transaction|
382 | rule.rule_body.call(transaction)
383 | end
384 | end
385 | end
386 | return statement
387 | end
388 |
389 | ##
390 | # Scrapes a website to produce a new Statement object.
391 | #
392 | # This is the one method which a Scraper *must* implement by overriding
393 | # this method.
394 | #
395 | # Override this in your own Scraper to use Mechanize and Hpricot (or
396 | # some other mechanism if you prefer) to parse your bank website
397 | # and create a Bankjob::Statement object to hold the data.
398 | #
399 | # The implementation here will raise an error if not overridden.
400 | #
401 | def scrape_statement
402 | raise "You must override the instance method scrape_statement in your scraper!"
403 | end
404 |
405 | ##
406 | # Creates a new Statement.
407 | #
408 | # Calling this method is the preferred way of creating a new Statement object
409 | # since it sets the OFX currency (and possibly other attributes) based on the
410 | # values set in the definition of the Scraper subclass.
411 | # It is otherwise no different, however, than calling Statement.new() yourself.
412 | #
413 | def create_statement
414 | statement = Statement.new(@@account_number, @@currency)
415 | statement.bank_id = @@bank_id if defined?(@@bank_id)
416 | statement.account_type = @@account_type if defined?(@@account_type)
417 | return statement
418 | end
419 |
420 | ##
421 | # Creates a new Transaction.
422 | #
423 | # Calling this method is the preferred way of creating a new Transaction object
424 | # since it sets the decimal separator (and possibly other attributes) based on the
425 | # values set in the definition of the Scraper subclass.
426 | #
427 | # It is otherwise no different, however, than calling Transaction.new() yourself.
428 | #
429 | def create_transaction
430 | Transaction.new(@@decimal)
431 | end
432 |
433 | ##
434 | # Private
435 | #
436 | # The internal workings of the Scraper come after this point - they
437 | # are not documented in RDOC
438 | ##
439 |
440 | #SCRAPER_INTERFACE is the list of methods that a scraper must define
441 | SCRAPER_INTERFACE = [:scrape_statement]
442 |
443 | # set up the directories in which user's scrapers will be sought
444 | HOME_DIR = File.dirname(__FILE__);
445 | SCRAPERS_DIR = File.join(HOME_DIR, "..", "..", "scrapers")
446 |
447 | ##
448 | # +inherited+ is always called when a class extends Scraper.
449 | # The subclass itself is passed in as +scraper_class+ alllowing
450 | # us to register it to be instantiated later
451 | #
452 | def self.inherited(scraper_class) #:nodoc:
453 | # verify that the scraper class indeed defines the necessary methods
454 | SCRAPER_INTERFACE.each do |method|
455 | if (not scraper_class.public_method_defined?(method))
456 | raise "Invalid scraper: the scraper class #{scraper_class.name} does not define the method #{method}"
457 | end
458 | end
459 | # in the future we might keep a registry of scrapers but for now
460 | # we assume there will always be one, and just register that class
461 | @@last_scraper_class = scraper_class
462 | end
463 |
464 | ##
465 | # This is the main method of the dynamic Scraper-loader: It loads
466 | # the actual scraper ruby file and initializes the class therein.
467 | #
468 | # Note that no assumption is made about the name of the class
469 | # defined within the specified +scraper_filename+. Rather, the
470 | # +self.inherited+ method will hold a reference to the last
471 | # class loaded that extends Bankjob::Scraper and that reference
472 | # is used here to initialize the class immediately after load()
473 | # is called on the specified file.
474 | #
475 | def self.load_scraper(scraper_filename, options, logger) #:nodoc:
476 | # temporarily add the same dir as bankjob and the scrapers dir
477 | # to the ruby LOAD_PATH for finding the scraper
478 | begin
479 | $:.unshift(HOME_DIR)
480 | $:.unshift(SCRAPERS_DIR)
481 | logger.debug("About to load the scraper file named #{scraper_filename}")
482 | load(scraper_filename)
483 | rescue Exception => e
484 | logger.error("Failed to load the scraper file #{scraper_filename} due to #{e.message}.\n\t#{e.backtrace[0]}")
485 | ensure
486 | $:.delete(SCRAPERS_DIR)
487 | $:.delete(HOME_DIR)
488 | end
489 |
490 | if (not defined?(@@last_scraper_class) or @@last_scraper_class.nil?)
491 | raise "Cannot initialize the scraper as none was loaded successfully."
492 | else
493 | logger.debug("About to instantiate scraper class: #{@@last_scraper_class.name}\n")
494 | scraper = @@last_scraper_class.new()
495 | scraper.logger = logger
496 | scraper.options = options
497 | end
498 |
499 | return scraper
500 | end # init_scraper
501 | end # Scraper
502 | end # module Bankjob
--------------------------------------------------------------------------------
/website/javascripts/rounded_corners_lite.inc.js:
--------------------------------------------------------------------------------
1 |
2 | /****************************************************************
3 | * *
4 | * curvyCorners *
5 | * ------------ *
6 | * *
7 | * This script generates rounded corners for your divs. *
8 | * *
9 | * Version 1.2.9 *
10 | * Copyright (c) 2006 Cameron Cooke *
11 | * By: Cameron Cooke and Tim Hutchison. *
12 | * *
13 | * *
14 | * Website: http://www.curvycorners.net *
15 | * Email: info@totalinfinity.com *
16 | * Forum: http://www.curvycorners.net/forum/ *
17 | * *
18 | * *
19 | * This library is free software; you can redistribute *
20 | * it and/or modify it under the terms of the GNU *
21 | * Lesser General Public License as published by the *
22 | * Free Software Foundation; either version 2.1 of the *
23 | * License, or (at your option) any later version. *
24 | * *
25 | * This library is distributed in the hope that it will *
26 | * be useful, but WITHOUT ANY WARRANTY; without even the *
27 | * implied warranty of MERCHANTABILITY or FITNESS FOR A *
28 | * PARTICULAR PURPOSE. See the GNU Lesser General Public *
29 | * License for more details. *
30 | * *
31 | * You should have received a copy of the GNU Lesser *
32 | * General Public License along with this library; *
33 | * Inc., 59 Temple Place, Suite 330, Boston, *
34 | * MA 02111-1307 USA *
35 | * *
36 | ****************************************************************/
37 |
38 | var isIE = navigator.userAgent.toLowerCase().indexOf("msie") > -1; var isMoz = document.implementation && document.implementation.createDocument; var isSafari = ((navigator.userAgent.toLowerCase().indexOf('safari')!=-1)&&(navigator.userAgent.toLowerCase().indexOf('mac')!=-1))?true:false; function curvyCorners()
39 | { if(typeof(arguments[0]) != "object") throw newCurvyError("First parameter of curvyCorners() must be an object."); if(typeof(arguments[1]) != "object" && typeof(arguments[1]) != "string") throw newCurvyError("Second parameter of curvyCorners() must be an object or a class name."); if(typeof(arguments[1]) == "string")
40 | { var startIndex = 0; var boxCol = getElementsByClass(arguments[1]);}
41 | else
42 | { var startIndex = 1; var boxCol = arguments;}
43 | var curvyCornersCol = new Array(); if(arguments[0].validTags)
44 | var validElements = arguments[0].validTags; else
45 | var validElements = ["div"]; for(var i = startIndex, j = boxCol.length; i < j; i++)
46 | { var currentTag = boxCol[i].tagName.toLowerCase(); if(inArray(validElements, currentTag) !== false)
47 | { curvyCornersCol[curvyCornersCol.length] = new curvyObject(arguments[0], boxCol[i]);}
48 | }
49 | this.objects = curvyCornersCol; this.applyCornersToAll = function()
50 | { for(var x = 0, k = this.objects.length; x < k; x++)
51 | { this.objects[x].applyCorners();}
52 | }
53 | }
54 | function curvyObject()
55 | { this.box = arguments[1]; this.settings = arguments[0]; this.topContainer = null; this.bottomContainer = null; this.masterCorners = new Array(); this.contentDIV = null; var boxHeight = get_style(this.box, "height", "height"); var boxWidth = get_style(this.box, "width", "width"); var borderWidth = get_style(this.box, "borderTopWidth", "border-top-width"); var borderColour = get_style(this.box, "borderTopColor", "border-top-color"); var boxColour = get_style(this.box, "backgroundColor", "background-color"); var backgroundImage = get_style(this.box, "backgroundImage", "background-image"); var boxPosition = get_style(this.box, "position", "position"); var boxPadding = get_style(this.box, "paddingTop", "padding-top"); this.boxHeight = parseInt(((boxHeight != "" && boxHeight != "auto" && boxHeight.indexOf("%") == -1)? boxHeight.substring(0, boxHeight.indexOf("px")) : this.box.scrollHeight)); this.boxWidth = parseInt(((boxWidth != "" && boxWidth != "auto" && boxWidth.indexOf("%") == -1)? boxWidth.substring(0, boxWidth.indexOf("px")) : this.box.scrollWidth)); this.borderWidth = parseInt(((borderWidth != "" && borderWidth.indexOf("px") !== -1)? borderWidth.slice(0, borderWidth.indexOf("px")) : 0)); this.boxColour = format_colour(boxColour); this.boxPadding = parseInt(((boxPadding != "" && boxPadding.indexOf("px") !== -1)? boxPadding.slice(0, boxPadding.indexOf("px")) : 0)); this.borderColour = format_colour(borderColour); this.borderString = this.borderWidth + "px" + " solid " + this.borderColour; this.backgroundImage = ((backgroundImage != "none")? backgroundImage : ""); this.boxContent = this.box.innerHTML; if(boxPosition != "absolute") this.box.style.position = "relative"; this.box.style.padding = "0px"; if(isIE && boxWidth == "auto" && boxHeight == "auto") this.box.style.width = "100%"; if(this.settings.autoPad == true && this.boxPadding > 0)
56 | this.box.innerHTML = ""; this.applyCorners = function()
57 | { for(var t = 0; t < 2; t++)
58 | { switch(t)
59 | { case 0:
60 | if(this.settings.tl || this.settings.tr)
61 | { var newMainContainer = document.createElement("DIV"); newMainContainer.style.width = "100%"; newMainContainer.style.fontSize = "1px"; newMainContainer.style.overflow = "hidden"; newMainContainer.style.position = "absolute"; newMainContainer.style.paddingLeft = this.borderWidth + "px"; newMainContainer.style.paddingRight = this.borderWidth + "px"; var topMaxRadius = Math.max(this.settings.tl ? this.settings.tl.radius : 0, this.settings.tr ? this.settings.tr.radius : 0); newMainContainer.style.height = topMaxRadius + "px"; newMainContainer.style.top = 0 - topMaxRadius + "px"; newMainContainer.style.left = 0 - this.borderWidth + "px"; this.topContainer = this.box.appendChild(newMainContainer);}
62 | break; case 1:
63 | if(this.settings.bl || this.settings.br)
64 | { var newMainContainer = document.createElement("DIV"); newMainContainer.style.width = "100%"; newMainContainer.style.fontSize = "1px"; newMainContainer.style.overflow = "hidden"; newMainContainer.style.position = "absolute"; newMainContainer.style.paddingLeft = this.borderWidth + "px"; newMainContainer.style.paddingRight = this.borderWidth + "px"; var botMaxRadius = Math.max(this.settings.bl ? this.settings.bl.radius : 0, this.settings.br ? this.settings.br.radius : 0); newMainContainer.style.height = botMaxRadius + "px"; newMainContainer.style.bottom = 0 - botMaxRadius + "px"; newMainContainer.style.left = 0 - this.borderWidth + "px"; this.bottomContainer = this.box.appendChild(newMainContainer);}
65 | break;}
66 | }
67 | if(this.topContainer) this.box.style.borderTopWidth = "0px"; if(this.bottomContainer) this.box.style.borderBottomWidth = "0px"; var corners = ["tr", "tl", "br", "bl"]; for(var i in corners)
68 | { if(i > -1 < 4)
69 | { var cc = corners[i]; if(!this.settings[cc])
70 | { if(((cc == "tr" || cc == "tl") && this.topContainer != null) || ((cc == "br" || cc == "bl") && this.bottomContainer != null))
71 | { var newCorner = document.createElement("DIV"); newCorner.style.position = "relative"; newCorner.style.fontSize = "1px"; newCorner.style.overflow = "hidden"; if(this.backgroundImage == "")
72 | newCorner.style.backgroundColor = this.boxColour; else
73 | newCorner.style.backgroundImage = this.backgroundImage; switch(cc)
74 | { case "tl":
75 | newCorner.style.height = topMaxRadius - this.borderWidth + "px"; newCorner.style.marginRight = this.settings.tr.radius - (this.borderWidth*2) + "px"; newCorner.style.borderLeft = this.borderString; newCorner.style.borderTop = this.borderString; newCorner.style.left = -this.borderWidth + "px"; break; case "tr":
76 | newCorner.style.height = topMaxRadius - this.borderWidth + "px"; newCorner.style.marginLeft = this.settings.tl.radius - (this.borderWidth*2) + "px"; newCorner.style.borderRight = this.borderString; newCorner.style.borderTop = this.borderString; newCorner.style.backgroundPosition = "-" + (topMaxRadius + this.borderWidth) + "px 0px"; newCorner.style.left = this.borderWidth + "px"; break; case "bl":
77 | newCorner.style.height = botMaxRadius - this.borderWidth + "px"; newCorner.style.marginRight = this.settings.br.radius - (this.borderWidth*2) + "px"; newCorner.style.borderLeft = this.borderString; newCorner.style.borderBottom = this.borderString; newCorner.style.left = -this.borderWidth + "px"; newCorner.style.backgroundPosition = "-" + (this.borderWidth) + "px -" + (this.boxHeight + (botMaxRadius + this.borderWidth)) + "px"; break; case "br":
78 | newCorner.style.height = botMaxRadius - this.borderWidth + "px"; newCorner.style.marginLeft = this.settings.bl.radius - (this.borderWidth*2) + "px"; newCorner.style.borderRight = this.borderString; newCorner.style.borderBottom = this.borderString; newCorner.style.left = this.borderWidth + "px"
79 | newCorner.style.backgroundPosition = "-" + (botMaxRadius + this.borderWidth) + "px -" + (this.boxHeight + (botMaxRadius + this.borderWidth)) + "px"; break;}
80 | }
81 | }
82 | else
83 | { if(this.masterCorners[this.settings[cc].radius])
84 | { var newCorner = this.masterCorners[this.settings[cc].radius].cloneNode(true);}
85 | else
86 | { var newCorner = document.createElement("DIV"); newCorner.style.height = this.settings[cc].radius + "px"; newCorner.style.width = this.settings[cc].radius + "px"; newCorner.style.position = "absolute"; newCorner.style.fontSize = "1px"; newCorner.style.overflow = "hidden"; var borderRadius = parseInt(this.settings[cc].radius - this.borderWidth); for(var intx = 0, j = this.settings[cc].radius; intx < j; intx++)
87 | { if((intx +1) >= borderRadius)
88 | var y1 = -1; else
89 | var y1 = (Math.floor(Math.sqrt(Math.pow(borderRadius, 2) - Math.pow((intx+1), 2))) - 1); if(borderRadius != j)
90 | { if((intx) >= borderRadius)
91 | var y2 = -1; else
92 | var y2 = Math.ceil(Math.sqrt(Math.pow(borderRadius,2) - Math.pow(intx, 2))); if((intx+1) >= j)
93 | var y3 = -1; else
94 | var y3 = (Math.floor(Math.sqrt(Math.pow(j ,2) - Math.pow((intx+1), 2))) - 1);}
95 | if((intx) >= j)
96 | var y4 = -1; else
97 | var y4 = Math.ceil(Math.sqrt(Math.pow(j ,2) - Math.pow(intx, 2))); if(y1 > -1) this.drawPixel(intx, 0, this.boxColour, 100, (y1+1), newCorner, -1, this.settings[cc].radius); if(borderRadius != j)
98 | { for(var inty = (y1 + 1); inty < y2; inty++)
99 | { if(this.settings.antiAlias)
100 | { if(this.backgroundImage != "")
101 | { var borderFract = (pixelFraction(intx, inty, borderRadius) * 100); if(borderFract < 30)
102 | { this.drawPixel(intx, inty, this.borderColour, 100, 1, newCorner, 0, this.settings[cc].radius);}
103 | else
104 | { this.drawPixel(intx, inty, this.borderColour, 100, 1, newCorner, -1, this.settings[cc].radius);}
105 | }
106 | else
107 | { var pixelcolour = BlendColour(this.boxColour, this.borderColour, pixelFraction(intx, inty, borderRadius)); this.drawPixel(intx, inty, pixelcolour, 100, 1, newCorner, 0, this.settings[cc].radius, cc);}
108 | }
109 | }
110 | if(this.settings.antiAlias)
111 | { if(y3 >= y2)
112 | { if (y2 == -1) y2 = 0; this.drawPixel(intx, y2, this.borderColour, 100, (y3 - y2 + 1), newCorner, 0, 0);}
113 | }
114 | else
115 | { if(y3 >= y1)
116 | { this.drawPixel(intx, (y1 + 1), this.borderColour, 100, (y3 - y1), newCorner, 0, 0);}
117 | }
118 | var outsideColour = this.borderColour;}
119 | else
120 | { var outsideColour = this.boxColour; var y3 = y1;}
121 | if(this.settings.antiAlias)
122 | { for(var inty = (y3 + 1); inty < y4; inty++)
123 | { this.drawPixel(intx, inty, outsideColour, (pixelFraction(intx, inty , j) * 100), 1, newCorner, ((this.borderWidth > 0)? 0 : -1), this.settings[cc].radius);}
124 | }
125 | }
126 | this.masterCorners[this.settings[cc].radius] = newCorner.cloneNode(true);}
127 | if(cc != "br")
128 | { for(var t = 0, k = newCorner.childNodes.length; t < k; t++)
129 | { var pixelBar = newCorner.childNodes[t]; var pixelBarTop = parseInt(pixelBar.style.top.substring(0, pixelBar.style.top.indexOf("px"))); var pixelBarLeft = parseInt(pixelBar.style.left.substring(0, pixelBar.style.left.indexOf("px"))); var pixelBarHeight = parseInt(pixelBar.style.height.substring(0, pixelBar.style.height.indexOf("px"))); if(cc == "tl" || cc == "bl"){ pixelBar.style.left = this.settings[cc].radius -pixelBarLeft -1 + "px";}
130 | if(cc == "tr" || cc == "tl"){ pixelBar.style.top = this.settings[cc].radius -pixelBarHeight -pixelBarTop + "px";}
131 | switch(cc)
132 | { case "tr":
133 | pixelBar.style.backgroundPosition = "-" + Math.abs((this.boxWidth - this.settings[cc].radius + this.borderWidth) + pixelBarLeft) + "px -" + Math.abs(this.settings[cc].radius -pixelBarHeight -pixelBarTop - this.borderWidth) + "px"; break; case "tl":
134 | pixelBar.style.backgroundPosition = "-" + Math.abs((this.settings[cc].radius -pixelBarLeft -1) - this.borderWidth) + "px -" + Math.abs(this.settings[cc].radius -pixelBarHeight -pixelBarTop - this.borderWidth) + "px"; break; case "bl":
135 | pixelBar.style.backgroundPosition = "-" + Math.abs((this.settings[cc].radius -pixelBarLeft -1) - this.borderWidth) + "px -" + Math.abs((this.boxHeight + this.settings[cc].radius + pixelBarTop) -this.borderWidth) + "px"; break;}
136 | }
137 | }
138 | }
139 | if(newCorner)
140 | { switch(cc)
141 | { case "tl":
142 | if(newCorner.style.position == "absolute") newCorner.style.top = "0px"; if(newCorner.style.position == "absolute") newCorner.style.left = "0px"; if(this.topContainer) this.topContainer.appendChild(newCorner); break; case "tr":
143 | if(newCorner.style.position == "absolute") newCorner.style.top = "0px"; if(newCorner.style.position == "absolute") newCorner.style.right = "0px"; if(this.topContainer) this.topContainer.appendChild(newCorner); break; case "bl":
144 | if(newCorner.style.position == "absolute") newCorner.style.bottom = "0px"; if(newCorner.style.position == "absolute") newCorner.style.left = "0px"; if(this.bottomContainer) this.bottomContainer.appendChild(newCorner); break; case "br":
145 | if(newCorner.style.position == "absolute") newCorner.style.bottom = "0px"; if(newCorner.style.position == "absolute") newCorner.style.right = "0px"; if(this.bottomContainer) this.bottomContainer.appendChild(newCorner); break;}
146 | }
147 | }
148 | }
149 | var radiusDiff = new Array(); radiusDiff["t"] = Math.abs(this.settings.tl.radius - this.settings.tr.radius)
150 | radiusDiff["b"] = Math.abs(this.settings.bl.radius - this.settings.br.radius); for(z in radiusDiff)
151 | { if(z == "t" || z == "b")
152 | { if(radiusDiff[z])
153 | { var smallerCornerType = ((this.settings[z + "l"].radius < this.settings[z + "r"].radius)? z +"l" : z +"r"); var newFiller = document.createElement("DIV"); newFiller.style.height = radiusDiff[z] + "px"; newFiller.style.width = this.settings[smallerCornerType].radius+ "px"
154 | newFiller.style.position = "absolute"; newFiller.style.fontSize = "1px"; newFiller.style.overflow = "hidden"; newFiller.style.backgroundColor = this.boxColour; switch(smallerCornerType)
155 | { case "tl":
156 | newFiller.style.bottom = "0px"; newFiller.style.left = "0px"; newFiller.style.borderLeft = this.borderString; this.topContainer.appendChild(newFiller); break; case "tr":
157 | newFiller.style.bottom = "0px"; newFiller.style.right = "0px"; newFiller.style.borderRight = this.borderString; this.topContainer.appendChild(newFiller); break; case "bl":
158 | newFiller.style.top = "0px"; newFiller.style.left = "0px"; newFiller.style.borderLeft = this.borderString; this.bottomContainer.appendChild(newFiller); break; case "br":
159 | newFiller.style.top = "0px"; newFiller.style.right = "0px"; newFiller.style.borderRight = this.borderString; this.bottomContainer.appendChild(newFiller); break;}
160 | }
161 | var newFillerBar = document.createElement("DIV"); newFillerBar.style.position = "relative"; newFillerBar.style.fontSize = "1px"; newFillerBar.style.overflow = "hidden"; newFillerBar.style.backgroundColor = this.boxColour; newFillerBar.style.backgroundImage = this.backgroundImage; switch(z)
162 | { case "t":
163 | if(this.topContainer)
164 | { if(this.settings.tl.radius && this.settings.tr.radius)
165 | { newFillerBar.style.height = topMaxRadius - this.borderWidth + "px"; newFillerBar.style.marginLeft = this.settings.tl.radius - this.borderWidth + "px"; newFillerBar.style.marginRight = this.settings.tr.radius - this.borderWidth + "px"; newFillerBar.style.borderTop = this.borderString; if(this.backgroundImage != "")
166 | newFillerBar.style.backgroundPosition = "-" + (topMaxRadius + this.borderWidth) + "px 0px"; this.topContainer.appendChild(newFillerBar);}
167 | this.box.style.backgroundPosition = "0px -" + (topMaxRadius - this.borderWidth) + "px";}
168 | break; case "b":
169 | if(this.bottomContainer)
170 | { if(this.settings.bl.radius && this.settings.br.radius)
171 | { newFillerBar.style.height = botMaxRadius - this.borderWidth + "px"; newFillerBar.style.marginLeft = this.settings.bl.radius - this.borderWidth + "px"; newFillerBar.style.marginRight = this.settings.br.radius - this.borderWidth + "px"; newFillerBar.style.borderBottom = this.borderString; if(this.backgroundImage != "")
172 | newFillerBar.style.backgroundPosition = "-" + (botMaxRadius + this.borderWidth) + "px -" + (this.boxHeight + (topMaxRadius + this.borderWidth)) + "px"; this.bottomContainer.appendChild(newFillerBar);}
173 | }
174 | break;}
175 | }
176 | }
177 | if(this.settings.autoPad == true && this.boxPadding > 0)
178 | { var contentContainer = document.createElement("DIV"); contentContainer.style.position = "relative"; contentContainer.innerHTML = this.boxContent; contentContainer.className = "autoPadDiv"; var topPadding = Math.abs(topMaxRadius - this.boxPadding); var botPadding = Math.abs(botMaxRadius - this.boxPadding); if(topMaxRadius < this.boxPadding)
179 | contentContainer.style.paddingTop = topPadding + "px"; if(botMaxRadius < this.boxPadding)
180 | contentContainer.style.paddingBottom = botMaxRadius + "px"; contentContainer.style.paddingLeft = this.boxPadding + "px"; contentContainer.style.paddingRight = this.boxPadding + "px"; this.contentDIV = this.box.appendChild(contentContainer);}
181 | }
182 | this.drawPixel = function(intx, inty, colour, transAmount, height, newCorner, image, cornerRadius)
183 | { var pixel = document.createElement("DIV"); pixel.style.height = height + "px"; pixel.style.width = "1px"; pixel.style.position = "absolute"; pixel.style.fontSize = "1px"; pixel.style.overflow = "hidden"; var topMaxRadius = Math.max(this.settings["tr"].radius, this.settings["tl"].radius); if(image == -1 && this.backgroundImage != "")
184 | { pixel.style.backgroundImage = this.backgroundImage; pixel.style.backgroundPosition = "-" + (this.boxWidth - (cornerRadius - intx) + this.borderWidth) + "px -" + ((this.boxHeight + topMaxRadius + inty) -this.borderWidth) + "px";}
185 | else
186 | { pixel.style.backgroundColor = colour;}
187 | if (transAmount != 100)
188 | setOpacity(pixel, transAmount); pixel.style.top = inty + "px"; pixel.style.left = intx + "px"; newCorner.appendChild(pixel);}
189 | }
190 | function insertAfter(parent, node, referenceNode)
191 | { parent.insertBefore(node, referenceNode.nextSibling);}
192 | function BlendColour(Col1, Col2, Col1Fraction)
193 | { var red1 = parseInt(Col1.substr(1,2),16); var green1 = parseInt(Col1.substr(3,2),16); var blue1 = parseInt(Col1.substr(5,2),16); var red2 = parseInt(Col2.substr(1,2),16); var green2 = parseInt(Col2.substr(3,2),16); var blue2 = parseInt(Col2.substr(5,2),16); if(Col1Fraction > 1 || Col1Fraction < 0) Col1Fraction = 1; var endRed = Math.round((red1 * Col1Fraction) + (red2 * (1 - Col1Fraction))); if(endRed > 255) endRed = 255; if(endRed < 0) endRed = 0; var endGreen = Math.round((green1 * Col1Fraction) + (green2 * (1 - Col1Fraction))); if(endGreen > 255) endGreen = 255; if(endGreen < 0) endGreen = 0; var endBlue = Math.round((blue1 * Col1Fraction) + (blue2 * (1 - Col1Fraction))); if(endBlue > 255) endBlue = 255; if(endBlue < 0) endBlue = 0; return "#" + IntToHex(endRed)+ IntToHex(endGreen)+ IntToHex(endBlue);}
194 | function IntToHex(strNum)
195 | { base = strNum / 16; rem = strNum % 16; base = base - (rem / 16); baseS = MakeHex(base); remS = MakeHex(rem); return baseS + '' + remS;}
196 | function MakeHex(x)
197 | { if((x >= 0) && (x <= 9))
198 | { return x;}
199 | else
200 | { switch(x)
201 | { case 10: return "A"; case 11: return "B"; case 12: return "C"; case 13: return "D"; case 14: return "E"; case 15: return "F";}
202 | }
203 | }
204 | function pixelFraction(x, y, r)
205 | { var pixelfraction = 0; var xvalues = new Array(1); var yvalues = new Array(1); var point = 0; var whatsides = ""; var intersect = Math.sqrt((Math.pow(r,2) - Math.pow(x,2))); if ((intersect >= y) && (intersect < (y+1)))
206 | { whatsides = "Left"; xvalues[point] = 0; yvalues[point] = intersect - y; point = point + 1;}
207 | var intersect = Math.sqrt((Math.pow(r,2) - Math.pow(y+1,2))); if ((intersect >= x) && (intersect < (x+1)))
208 | { whatsides = whatsides + "Top"; xvalues[point] = intersect - x; yvalues[point] = 1; point = point + 1;}
209 | var intersect = Math.sqrt((Math.pow(r,2) - Math.pow(x+1,2))); if ((intersect >= y) && (intersect < (y+1)))
210 | { whatsides = whatsides + "Right"; xvalues[point] = 1; yvalues[point] = intersect - y; point = point + 1;}
211 | var intersect = Math.sqrt((Math.pow(r,2) - Math.pow(y,2))); if ((intersect >= x) && (intersect < (x+1)))
212 | { whatsides = whatsides + "Bottom"; xvalues[point] = intersect - x; yvalues[point] = 0;}
213 | switch (whatsides)
214 | { case "LeftRight":
215 | pixelfraction = Math.min(yvalues[0],yvalues[1]) + ((Math.max(yvalues[0],yvalues[1]) - Math.min(yvalues[0],yvalues[1]))/2); break; case "TopRight":
216 | pixelfraction = 1-(((1-xvalues[0])*(1-yvalues[1]))/2); break; case "TopBottom":
217 | pixelfraction = Math.min(xvalues[0],xvalues[1]) + ((Math.max(xvalues[0],xvalues[1]) - Math.min(xvalues[0],xvalues[1]))/2); break; case "LeftBottom":
218 | pixelfraction = (yvalues[0]*xvalues[1])/2; break; default:
219 | pixelfraction = 1;}
220 | return pixelfraction;}
221 | function rgb2Hex(rgbColour)
222 | { try{ var rgbArray = rgb2Array(rgbColour); var red = parseInt(rgbArray[0]); var green = parseInt(rgbArray[1]); var blue = parseInt(rgbArray[2]); var hexColour = "#" + IntToHex(red) + IntToHex(green) + IntToHex(blue);}
223 | catch(e){ alert("There was an error converting the RGB value to Hexadecimal in function rgb2Hex");}
224 | return hexColour;}
225 | function rgb2Array(rgbColour)
226 | { var rgbValues = rgbColour.substring(4, rgbColour.indexOf(")")); var rgbArray = rgbValues.split(", "); return rgbArray;}
227 | function setOpacity(obj, opacity)
228 | { opacity = (opacity == 100)?99.999:opacity; if(isSafari && obj.tagName != "IFRAME")
229 | { var rgbArray = rgb2Array(obj.style.backgroundColor); var red = parseInt(rgbArray[0]); var green = parseInt(rgbArray[1]); var blue = parseInt(rgbArray[2]); obj.style.backgroundColor = "rgba(" + red + ", " + green + ", " + blue + ", " + opacity/100 + ")";}
230 | else if(typeof(obj.style.opacity) != "undefined")
231 | { obj.style.opacity = opacity/100;}
232 | else if(typeof(obj.style.MozOpacity) != "undefined")
233 | { obj.style.MozOpacity = opacity/100;}
234 | else if(typeof(obj.style.filter) != "undefined")
235 | { obj.style.filter = "alpha(opacity:" + opacity + ")";}
236 | else if(typeof(obj.style.KHTMLOpacity) != "undefined")
237 | { obj.style.KHTMLOpacity = opacity/100;}
238 | }
239 | function inArray(array, value)
240 | { for(var i = 0; i < array.length; i++){ if (array[i] === value) return i;}
241 | return false;}
242 | function inArrayKey(array, value)
243 | { for(key in array){ if(key === value) return true;}
244 | return false;}
245 | function addEvent(elm, evType, fn, useCapture) { if (elm.addEventListener) { elm.addEventListener(evType, fn, useCapture); return true;}
246 | else if (elm.attachEvent) { var r = elm.attachEvent('on' + evType, fn); return r;}
247 | else { elm['on' + evType] = fn;}
248 | }
249 | function removeEvent(obj, evType, fn, useCapture){ if (obj.removeEventListener){ obj.removeEventListener(evType, fn, useCapture); return true;} else if (obj.detachEvent){ var r = obj.detachEvent("on"+evType, fn); return r;} else { alert("Handler could not be removed");}
250 | }
251 | function format_colour(colour)
252 | { var returnColour = "#ffffff"; if(colour != "" && colour != "transparent")
253 | { if(colour.substr(0, 3) == "rgb")
254 | { returnColour = rgb2Hex(colour);}
255 | else if(colour.length == 4)
256 | { returnColour = "#" + colour.substring(1, 2) + colour.substring(1, 2) + colour.substring(2, 3) + colour.substring(2, 3) + colour.substring(3, 4) + colour.substring(3, 4);}
257 | else
258 | { returnColour = colour;}
259 | }
260 | return returnColour;}
261 | function get_style(obj, property, propertyNS)
262 | { try
263 | { if(obj.currentStyle)
264 | { var returnVal = eval("obj.currentStyle." + property);}
265 | else
266 | { if(isSafari && obj.style.display == "none")
267 | { obj.style.display = ""; var wasHidden = true;}
268 | var returnVal = document.defaultView.getComputedStyle(obj, '').getPropertyValue(propertyNS); if(isSafari && wasHidden)
269 | { obj.style.display = "none";}
270 | }
271 | }
272 | catch(e)
273 | { }
274 | return returnVal;}
275 | function getElementsByClass(searchClass, node, tag)
276 | { var classElements = new Array(); if(node == null)
277 | node = document; if(tag == null)
278 | tag = '*'; var els = node.getElementsByTagName(tag); var elsLen = els.length; var pattern = new RegExp("(^|\s)"+searchClass+"(\s|$)"); for (i = 0, j = 0; i < elsLen; i++)
279 | { if(pattern.test(els[i].className))
280 | { classElements[j] = els[i]; j++;}
281 | }
282 | return classElements;}
283 | function newCurvyError(errorMessage)
284 | { return new Error("curvyCorners Error:\n" + errorMessage)
285 | }
286 |
--------------------------------------------------------------------------------