halffare 0.1.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/README.rdoc +38 -0
- data/bin/halffare +116 -0
- data/halffare.rdoc +114 -0
- data/lib/halffare.rb +69 -0
- data/lib/halffare/fetch.rb +112 -0
- data/lib/halffare/model/order.rb +16 -0
- data/lib/halffare/price.rb +11 -0
- data/lib/halffare/price_base.rb +27 -0
- data/lib/halffare/price_guess.rb +14 -0
- data/lib/halffare/price_sbb.rb +102 -0
- data/lib/halffare/pricerules_sbb.yml +16 -0
- data/lib/halffare/stats.rb +155 -0
- data/lib/halffare/version.rb +11 -0
- metadata +150 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: bda0442ac9dab0587de36dde13f84c71f1c45753
|
4
|
+
data.tar.gz: 16e0a906faeda411f7a5b4bd6b57ffc38eae4d2d
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: f2c60bf02b70ce2f8d9fcc65f07461bd84f9ff50ae694bb2e6da28625313ed2b1335f312861bef01c5f1c6fa15ff24ef7dab1283453493a1f5b6160289f11373
|
7
|
+
data.tar.gz: 908e0da3000e7155e1c1ce3b7da14ce772a3d4fca00860939667871fc07541607baa71e7761b1b8943c517b84c8eafe0decd8f785cd373a10a99b1e427410ce3
|
data/README.rdoc
ADDED
@@ -0,0 +1,38 @@
|
|
1
|
+
= halffare
|
2
|
+
--------------------------------------
|
3
|
+
This software is in no way affiliated with, authorized, maintained, sponsored or endorsed by sbb.ch or any of its affiliates or subsidiaries.
|
4
|
+
--------------------------------------
|
5
|
+
|
6
|
+
+halffare+ evaluates whether a SBB Half-Fare travelcard is profitable based on your online order history.
|
7
|
+
|
8
|
+
Provides commands to download your online order history from sbb.ch and evaluate it.
|
9
|
+
|
10
|
+
{<img src="https://raw.githubusercontent.com/rndstr/halffare/master/media/halffare-results.png" alt="halffare evaluation results" />}
|
11
|
+
|
12
|
+
|
13
|
+
== Getting started
|
14
|
+
|
15
|
+
Install
|
16
|
+
|
17
|
+
$ gem install halffare
|
18
|
+
|
19
|
+
Download order history
|
20
|
+
|
21
|
+
$ halffare fetch --months=12 --output=year.halffare
|
22
|
+
|
23
|
+
Evaluate orders
|
24
|
+
|
25
|
+
$ halffare stats --input=year.halffare
|
26
|
+
|
27
|
+
More options
|
28
|
+
|
29
|
+
$ halffare help
|
30
|
+
|
31
|
+
== Usage
|
32
|
+
|
33
|
+
:include:halffare.rdoc
|
34
|
+
|
35
|
+
== License
|
36
|
+
|
37
|
+
{MIT license}[https://github.com/rndstr/halffare/blob/master/LICENSE]
|
38
|
+
|
data/bin/halffare
ADDED
@@ -0,0 +1,116 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
require 'gli'
|
3
|
+
require 'halffare'
|
4
|
+
|
5
|
+
include GLI::App
|
6
|
+
|
7
|
+
program_desc 'Evaluates whether a SBB Half-Fare travelcard is profitable based on your online order history'
|
8
|
+
|
9
|
+
version Halffare::VERSION
|
10
|
+
|
11
|
+
desc 'Fetches your order history from sbb.ch and stores them in a file'
|
12
|
+
command :fetch do |c|
|
13
|
+
c.desc 'Username for sbb.ch'
|
14
|
+
c.arg_name 'username'
|
15
|
+
c.flag [:u,:username]
|
16
|
+
|
17
|
+
c.desc 'Password for sbb.ch'
|
18
|
+
c.arg_name 'password'
|
19
|
+
c.flag [:p,:password]
|
20
|
+
|
21
|
+
c.desc 'Filename to store the data in'
|
22
|
+
c.default_value 'orders.halffare'
|
23
|
+
c.arg_name 'output'
|
24
|
+
c.flag [:o, :output]
|
25
|
+
|
26
|
+
c.desc 'Number of pages to retrieve'
|
27
|
+
c.default_value 32
|
28
|
+
c.arg_name 'count'
|
29
|
+
c.flag [:pages]
|
30
|
+
|
31
|
+
c.desc 'Stop fetching orders when reaching months back'
|
32
|
+
c.arg_name :months
|
33
|
+
c.flag [:m, :months]
|
34
|
+
|
35
|
+
c.desc 'Overwrite an existing data file'
|
36
|
+
c.switch :force, :negatable => false
|
37
|
+
|
38
|
+
c.desc 'Also print debug output'
|
39
|
+
c.switch :debug, :negatable => false
|
40
|
+
|
41
|
+
c.action do |global_options,options,args|
|
42
|
+
Halffare.debug = options[:debug]
|
43
|
+
if File.exists?(options[:output]) && !options[:force]
|
44
|
+
log_error "file #{options[:output]} exists, use --force or --output"
|
45
|
+
else
|
46
|
+
fetch = Halffare::Fetch.new()
|
47
|
+
fetch.login(options[:username], options[:password])
|
48
|
+
fetch.download(options[:output], options[:pages], options[:months])
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
desc "Calculates and displays stats about your order history"
|
54
|
+
command :stats do |c|
|
55
|
+
c.desc 'Filename to read the data from'
|
56
|
+
c.default_value 'orders.halffare'
|
57
|
+
c.arg_name 'input'
|
58
|
+
c.flag [:i, :input]
|
59
|
+
|
60
|
+
c.desc 'Strategy to use for determining price savings [guess, sbb, sbbguess]'
|
61
|
+
c.default_value :sbbguess
|
62
|
+
c.arg_name :strategy
|
63
|
+
c.flag [:s, :strategy]
|
64
|
+
# c.must_match [:sbb, :guess, 'sbbguess']
|
65
|
+
|
66
|
+
c.desc 'Prices found in input file are of type [half, full, ask]'
|
67
|
+
c.default_value :ask
|
68
|
+
c.arg_name :pricetype
|
69
|
+
c.flag [:p, :pricetype]
|
70
|
+
# c.must_match [:half, :full, :ask]
|
71
|
+
|
72
|
+
c.desc 'Restrict how many months in the past to consider orders'
|
73
|
+
c.arg_name :months
|
74
|
+
c.flag [:m, :months]
|
75
|
+
|
76
|
+
c.desc 'Also print debug output'
|
77
|
+
c.switch :debug, :negatable => false
|
78
|
+
|
79
|
+
c.action do |global_options,options,args|
|
80
|
+
Halffare.debug = options[:debug]
|
81
|
+
stats = Halffare::Stats.new
|
82
|
+
stats.read(options[:input], options[:months])
|
83
|
+
if stats.count > 0
|
84
|
+
if options[:pricetype] == :ask
|
85
|
+
options[:pricetype] = yesno('Did you buy your tickets with a half-fare card?', nil) ? 'half' : 'full';
|
86
|
+
end
|
87
|
+
stats.calculate(options[:strategy], options[:pricetype] == 'half')
|
88
|
+
stats.display(options[:pricetype] == 'half')
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
pre do |global,command,options,args|
|
94
|
+
# Pre logic here
|
95
|
+
# Return true to proceed; false to abort and not call the
|
96
|
+
# chosen command
|
97
|
+
# Use skips_pre before a command to skip this block
|
98
|
+
# on that command only
|
99
|
+
true
|
100
|
+
end
|
101
|
+
|
102
|
+
post do |global,command,options,args|
|
103
|
+
# Post logic here
|
104
|
+
# Use skips_post before a command to skip this
|
105
|
+
# block on that command only
|
106
|
+
end
|
107
|
+
|
108
|
+
on_error do |exception|
|
109
|
+
p exception
|
110
|
+
p exception.backtrace.join("\n")
|
111
|
+
# Error logic here
|
112
|
+
# return false to skip default error handling
|
113
|
+
true
|
114
|
+
end
|
115
|
+
|
116
|
+
exit run(ARGV)
|
data/halffare.rdoc
ADDED
@@ -0,0 +1,114 @@
|
|
1
|
+
== halffare - Evaluates whether a SBB Half-Fare travelcard is profitable based on your online order history
|
2
|
+
|
3
|
+
v0.1.1
|
4
|
+
|
5
|
+
=== Global Options
|
6
|
+
=== --help
|
7
|
+
Show this message
|
8
|
+
|
9
|
+
|
10
|
+
|
11
|
+
=== --version
|
12
|
+
Display the program version
|
13
|
+
|
14
|
+
|
15
|
+
|
16
|
+
=== Commands
|
17
|
+
==== Command: <tt>fetch </tt>
|
18
|
+
Fetches your order history from sbb.ch and stores them in a file
|
19
|
+
|
20
|
+
|
21
|
+
===== Options
|
22
|
+
===== -m|--months months
|
23
|
+
|
24
|
+
Stop fetching orders when reaching months back
|
25
|
+
|
26
|
+
[Default Value] None
|
27
|
+
|
28
|
+
|
29
|
+
===== -o|--output output
|
30
|
+
|
31
|
+
Filename to store the data in
|
32
|
+
|
33
|
+
[Default Value] orders.halffare
|
34
|
+
|
35
|
+
|
36
|
+
===== -p|--password password
|
37
|
+
|
38
|
+
Password for sbb.ch
|
39
|
+
|
40
|
+
[Default Value] None
|
41
|
+
|
42
|
+
|
43
|
+
===== --pages count
|
44
|
+
|
45
|
+
Number of pages to retrieve
|
46
|
+
|
47
|
+
[Default Value] 32
|
48
|
+
|
49
|
+
|
50
|
+
===== -u|--username username
|
51
|
+
|
52
|
+
Username for sbb.ch
|
53
|
+
|
54
|
+
[Default Value] None
|
55
|
+
|
56
|
+
|
57
|
+
===== --debug
|
58
|
+
Also print debug output
|
59
|
+
|
60
|
+
|
61
|
+
|
62
|
+
===== --force
|
63
|
+
Overwrite an existing data file
|
64
|
+
|
65
|
+
|
66
|
+
|
67
|
+
==== Command: <tt>help command</tt>
|
68
|
+
Shows a list of commands or help for one command
|
69
|
+
|
70
|
+
Gets help for the application or its commands. Can also list the commands in a way helpful to creating a bash-style completion function
|
71
|
+
===== Options
|
72
|
+
===== -c
|
73
|
+
List commands one per line, to assist with shell completion
|
74
|
+
|
75
|
+
|
76
|
+
|
77
|
+
==== Command: <tt>stats </tt>
|
78
|
+
Calculates and displays stats about your order history
|
79
|
+
|
80
|
+
|
81
|
+
===== Options
|
82
|
+
===== -i|--input input
|
83
|
+
|
84
|
+
Filename to read the data from
|
85
|
+
|
86
|
+
[Default Value] orders.halffare
|
87
|
+
|
88
|
+
|
89
|
+
===== -m|--months months
|
90
|
+
|
91
|
+
Restrict how many months in the past to consider orders
|
92
|
+
|
93
|
+
[Default Value] None
|
94
|
+
|
95
|
+
|
96
|
+
===== -p|--pricetype pricetype
|
97
|
+
|
98
|
+
Prices found in input file are of type [half, full, ask]
|
99
|
+
|
100
|
+
[Default Value] ask
|
101
|
+
|
102
|
+
|
103
|
+
===== -s|--strategy strategy
|
104
|
+
|
105
|
+
Strategy to use for determining price savings [guess, sbb, sbbguess]
|
106
|
+
|
107
|
+
[Default Value] sbbguess
|
108
|
+
|
109
|
+
|
110
|
+
===== --debug
|
111
|
+
Also print debug output
|
112
|
+
|
113
|
+
|
114
|
+
|
data/lib/halffare.rb
ADDED
@@ -0,0 +1,69 @@
|
|
1
|
+
require 'mechanize'
|
2
|
+
require 'highline/import'
|
3
|
+
require 'logger'
|
4
|
+
require 'date'
|
5
|
+
require 'yaml'
|
6
|
+
|
7
|
+
require 'halffare/version.rb'
|
8
|
+
require 'halffare/fetch.rb'
|
9
|
+
require 'halffare/stats.rb'
|
10
|
+
require 'halffare/model/order.rb'
|
11
|
+
require 'halffare/price.rb'
|
12
|
+
require 'halffare/price_base.rb'
|
13
|
+
require 'halffare/price_guess.rb'
|
14
|
+
require 'halffare/price_sbb.rb'
|
15
|
+
|
16
|
+
module Kernel
|
17
|
+
private
|
18
|
+
def currency(cash)
|
19
|
+
'CHF' + sprintf('%.2f', cash.to_f.abs)
|
20
|
+
end
|
21
|
+
|
22
|
+
def log_debug(str)
|
23
|
+
say(str) if Halffare.debug
|
24
|
+
end
|
25
|
+
|
26
|
+
def log_info(str)
|
27
|
+
say("#{str}")
|
28
|
+
end
|
29
|
+
|
30
|
+
def log_notice(str)
|
31
|
+
say("<%= color('#{str.gsub("'","\\\\'")}', BOLD) %>")
|
32
|
+
end
|
33
|
+
|
34
|
+
def log_heading(str)
|
35
|
+
say("<%= color('#{str.gsub("'","\\\\'")}', YELLOW, BOLD) %>")
|
36
|
+
end
|
37
|
+
|
38
|
+
def log_result(str)
|
39
|
+
say("<%= color('#{str.gsub("'","\\\\'")}', GREEN) %>")
|
40
|
+
end
|
41
|
+
|
42
|
+
def log_emphasize(str)
|
43
|
+
say("<%= color('#{str.gsub("'","\\\\'")}', CYAN) %>")
|
44
|
+
end
|
45
|
+
|
46
|
+
def log_error(str)
|
47
|
+
say("<%= color('#{'ERROR: ' + str.gsub("'","\\\\'")}', WHITE, ON_RED, BOLD) %>")
|
48
|
+
end
|
49
|
+
|
50
|
+
def log_order(order)
|
51
|
+
log_info "\n"
|
52
|
+
log_heading "#{order.order_date} #{order.description}"
|
53
|
+
log_emphasize "You paid: #{currency(order.price)}"
|
54
|
+
end
|
55
|
+
|
56
|
+
def yesno(prompt = 'Continue?', default = true)
|
57
|
+
a = ''
|
58
|
+
s = '[y/n]'
|
59
|
+
unless default.nil?
|
60
|
+
s = default ? '[Y/n]' : '[y/N]'
|
61
|
+
end
|
62
|
+
d = default ? 'y' : 'n'
|
63
|
+
until %w[y n].include? a
|
64
|
+
a = ask("#{prompt} #{s} ") { |q| q.limit = 1; q.case = :downcase }
|
65
|
+
a = d if a.length == 0
|
66
|
+
end
|
67
|
+
a == 'y'
|
68
|
+
end
|
69
|
+
end
|
@@ -0,0 +1,112 @@
|
|
1
|
+
module Halffare
|
2
|
+
|
3
|
+
class Fetch
|
4
|
+
USER_AGENT = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.11 (KHTML, like Gecko) Chrome/23.0.1271.64 Safari/537.11'
|
5
|
+
URL_LOGIN = 'http://www.sbb.ch/meta/login.html'
|
6
|
+
URL_ORDERS = 'https://www.sbb.ch/ticketshop/b2c/dossierListe.do'
|
7
|
+
ORDER_NOTE_FILE_CREATED = 'halffare-orders-file-created'
|
8
|
+
|
9
|
+
def initialize()
|
10
|
+
@agent = ::Mechanize.new
|
11
|
+
@agent.user_agent = USER_AGENT
|
12
|
+
end
|
13
|
+
|
14
|
+
def login(username, password)
|
15
|
+
username = ask("sbb.ch username? ") unless username
|
16
|
+
password = ask("sbb.ch password? ") { |q| q.echo = "*" } unless password
|
17
|
+
log_info "logging in..."
|
18
|
+
|
19
|
+
#@agent.log = Logger.new('debug.log')
|
20
|
+
|
21
|
+
@response = @agent.get URL_LOGIN
|
22
|
+
|
23
|
+
login = @response.forms.first
|
24
|
+
login['logon.username'] = username
|
25
|
+
login['logon.password'] = password
|
26
|
+
|
27
|
+
@response = @agent.submit(login)
|
28
|
+
end
|
29
|
+
|
30
|
+
def download(filename, pages, months)
|
31
|
+
@response = @agent.get URL_ORDERS
|
32
|
+
|
33
|
+
logged_in!
|
34
|
+
|
35
|
+
begin
|
36
|
+
left = pages.to_i
|
37
|
+
page = 1
|
38
|
+
stop_after = months ? (Date.today << months.to_i).strftime('%Y-%m-%d') : nil
|
39
|
+
|
40
|
+
log_info "will stop scraping after reaching #{stop_after}" unless stop_after.nil?
|
41
|
+
log_info "writing data to #{filename}"
|
42
|
+
|
43
|
+
file = File.open(filename, "w")
|
44
|
+
oldest_date_on_page = Date.today.strftime
|
45
|
+
|
46
|
+
# fake entry so when evaluating this file the calculation goes up until today
|
47
|
+
# and not to the last order.
|
48
|
+
file.write "#{oldest_date_on_page}|#{oldest_date_on_page}|0|#{ORDER_NOTE_FILE_CREATED}|\n"
|
49
|
+
|
50
|
+
loop do
|
51
|
+
print ">>> page #{page}/#{pages} "
|
52
|
+
orders do |idx, travel_date, order_date, price, note, name|
|
53
|
+
print "."
|
54
|
+
|
55
|
+
travel_date = Date.parse(travel_date).strftime
|
56
|
+
order_date = Date.parse(order_date).strftime
|
57
|
+
oldest_date_on_page = [order_date, oldest_date_on_page].min
|
58
|
+
|
59
|
+
file.write "#{travel_date}|#{order_date}|#{price}|#{note}|#{name}\n" if stop_after.nil? || travel_date >= stop_after
|
60
|
+
end
|
61
|
+
puts
|
62
|
+
next!
|
63
|
+
|
64
|
+
log_debug "oldest order on page was on #{oldest_date_on_page}" if Halffare.debug
|
65
|
+
|
66
|
+
page += 1
|
67
|
+
left -= 1
|
68
|
+
|
69
|
+
break if !stop_after.nil? && oldest_date_on_page < stop_after
|
70
|
+
break if left <= 0
|
71
|
+
end
|
72
|
+
rescue IOError => e
|
73
|
+
puts e
|
74
|
+
ensure
|
75
|
+
file.close unless file == nil
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
private
|
80
|
+
def orders
|
81
|
+
@response.search('#orders tbody tr').each do |order|
|
82
|
+
idx = order.attr('id').split('-')[1]
|
83
|
+
order_date = order.xpath('./td[1]').text.gsub(/[[:space:]]/, ' ').strip
|
84
|
+
travel_date = order.xpath('./td[2]').text.gsub(/[[:space:]]/, ' ').strip
|
85
|
+
price = order.xpath('./td[4]').text.gsub(/[[:space:]]/, ' ').strip
|
86
|
+
note = order.xpath('./td[5]').text.gsub(/[[:space:]]/, ' ').strip
|
87
|
+
|
88
|
+
description = ""
|
89
|
+
@response.search('#bezeichnungen tr#ordersBezeichnung-' + idx + ' td ul li').each { |i|
|
90
|
+
description << "::" unless description.empty?
|
91
|
+
|
92
|
+
description << i.to_s.gsub(/<br>.*</, '<').gsub(/[[:space:]]/, ' ').gsub(/<.*?>/, '').strip
|
93
|
+
}
|
94
|
+
|
95
|
+
yield idx, order_date, travel_date, price, note, description if block_given?
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
def next!
|
100
|
+
@response = @response.forms_with(:action => '/ticketshop/b2c/dossierListe.do').first
|
101
|
+
@response = @response.submit(@response.button_with(:name => 'method:more'))
|
102
|
+
end
|
103
|
+
|
104
|
+
def logged_in!
|
105
|
+
error = @response.at '.skinMessageBoxError'
|
106
|
+
if error != nil
|
107
|
+
STDERR.puts "ERROR: Not logged in, verify your username and password"
|
108
|
+
exit 1
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
112
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
module Halffare
|
2
|
+
module Model
|
3
|
+
class Order
|
4
|
+
attr_reader :travel_date, :order_date, :price, :note, :description
|
5
|
+
def initialize(row)
|
6
|
+
from_row(row)
|
7
|
+
end
|
8
|
+
|
9
|
+
def from_row(row)
|
10
|
+
@travel_date, @order_date, @price, @note, @description = row.split("|")
|
11
|
+
@description.strip!
|
12
|
+
@price = @price.to_f
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
module Halffare
|
2
|
+
class PriceBase
|
3
|
+
|
4
|
+
# Extracts the prices from an order.
|
5
|
+
#
|
6
|
+
# @param order [Order] The order
|
7
|
+
# @return halfprice, fullprice
|
8
|
+
def get(order)
|
9
|
+
raise 'you need to implement get(order) in your price strategy'
|
10
|
+
end
|
11
|
+
|
12
|
+
def halffare=(hf)
|
13
|
+
@halffare = hf
|
14
|
+
end
|
15
|
+
|
16
|
+
private
|
17
|
+
# Which price has been paid
|
18
|
+
def price_paid
|
19
|
+
@halffare ? 'half' : 'full'
|
20
|
+
end
|
21
|
+
|
22
|
+
# Opposite of {#price_paid}
|
23
|
+
def price_paid_other
|
24
|
+
@halffare ? 'full' : 'half'
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
module Halffare
|
2
|
+
# Simple guesstimator assuming half-fare card is always half the price
|
3
|
+
# which is probably the case for non-regional tickets
|
4
|
+
class PriceGuess < PriceBase
|
5
|
+
def get(order)
|
6
|
+
price = order.price.to_f
|
7
|
+
if @halffare
|
8
|
+
return price, price*2
|
9
|
+
else
|
10
|
+
return price/2, price
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
@@ -0,0 +1,102 @@
|
|
1
|
+
# encoding: binary
|
2
|
+
module Halffare
|
3
|
+
# More accurate price strategy
|
4
|
+
#
|
5
|
+
# Uses a static table for price conversions, falling back on
|
6
|
+
# guess strategy if no entry found
|
7
|
+
class PriceSbb < PriceGuess
|
8
|
+
alias :price_guess_get :get
|
9
|
+
|
10
|
+
# @param force_guess_fallback [true, false] To use `guess` strategy as fallback instead of asking the user
|
11
|
+
def initialize(force_guess_fallback = false)
|
12
|
+
@force_guess_fallback = force_guess_fallback
|
13
|
+
end
|
14
|
+
|
15
|
+
def get(order)
|
16
|
+
log_debug "looking for an existing rule..."
|
17
|
+
rules.each { |rule|
|
18
|
+
rule.each { |match, definition|
|
19
|
+
if order.description =~ /#{match}/u
|
20
|
+
case
|
21
|
+
when definition['scale'] != nil
|
22
|
+
log_debug "found scale rule: #{definition}"
|
23
|
+
return price_scale(definition, order)
|
24
|
+
when definition['set'] != nil
|
25
|
+
log_debug "found set rule: #{definition}"
|
26
|
+
return price_set(definition, order)
|
27
|
+
when definition['choices'] != nil
|
28
|
+
log_debug "found choices rules: #{definition}"
|
29
|
+
return price_choices(definition, order)
|
30
|
+
end
|
31
|
+
end
|
32
|
+
}
|
33
|
+
}
|
34
|
+
log_result "no satisfying match found" if Halffare.debug
|
35
|
+
return price_guess_get(order) if @force_guess_fallback
|
36
|
+
|
37
|
+
ask_for_price(order)
|
38
|
+
end
|
39
|
+
|
40
|
+
private
|
41
|
+
# Loads rules form the rules file.
|
42
|
+
def rules
|
43
|
+
@rules ||= YAML.load_file(File.expand_path('../pricerules_sbb.yml', __FILE__))
|
44
|
+
end
|
45
|
+
|
46
|
+
# Calculates the prices according to the `scale` definition.
|
47
|
+
def price_scale(definition, order)
|
48
|
+
return definition['scale'][price_paid][0] * order.price, definition['scale'][price_paid][1] * order.price
|
49
|
+
end
|
50
|
+
|
51
|
+
# Calculates the prices according to the `set` definition.
|
52
|
+
def price_set(definition, order)
|
53
|
+
if order.price != definition['set'][price_paid]
|
54
|
+
log_order(order)
|
55
|
+
log_error 'order matched but price differs; ignoring this order.'
|
56
|
+
p order
|
57
|
+
p definition
|
58
|
+
return 0, 0
|
59
|
+
else
|
60
|
+
return definition['set']['half'], definition['set']['full']
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
# Calculates the prices according to the `choices` definition.
|
65
|
+
def price_choices(definition, order)
|
66
|
+
# auto select
|
67
|
+
definition['choices'].each { |name,prices| return prices['half'], prices['full'] if prices[price_paid] == order.price }
|
68
|
+
|
69
|
+
return price_guess_get(order) if @force_guess_fallback
|
70
|
+
|
71
|
+
choose do |menu|
|
72
|
+
menu.prompt = "\nSelect the choice that applies to your travels? "
|
73
|
+
|
74
|
+
log_info "\n"
|
75
|
+
definition['choices'].each do |name,prices|
|
76
|
+
menu.choice "#{name} (half-fare: #{currency(prices['half'])}, full: #{currency(prices['full'])})" do
|
77
|
+
end
|
78
|
+
end
|
79
|
+
menu.choice "…or enter manually" do ask_for_price(order) end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
# Ask the user for the price.
|
84
|
+
def ask_for_price(order)
|
85
|
+
guesshalf, guessfull = price_guess_get(order)
|
86
|
+
|
87
|
+
if !Halffare.debug
|
88
|
+
# was already logged
|
89
|
+
log_order(order)
|
90
|
+
end
|
91
|
+
|
92
|
+
if @halffare
|
93
|
+
other = ask("What would have been the full price? ", Float) { |q| q.default = guessfull }
|
94
|
+
return order.price, other
|
95
|
+
else
|
96
|
+
other = ask("What would have been the half-fare price? ", Float) { |q| q.default = guesshalf }
|
97
|
+
return other, order.price
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
end
|
102
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
- "Gutschrift:Erstattung":
|
2
|
+
scale: {half: [-1,-2], full: [-0.5,-1]}
|
3
|
+
- "Individual tickets":
|
4
|
+
scale: {half: [1,2], full: [0.5,1]}
|
5
|
+
- "bike pass":
|
6
|
+
scale: {half: [1,1], full: [1,1]}
|
7
|
+
- "ZVV Individual ticket/Day Pass city network Zürich \\(Zone 110\\)":
|
8
|
+
set: {half: 5.80, full: 8.40}
|
9
|
+
- "Passepartout Individual ticket LUZERN":
|
10
|
+
choices: {
|
11
|
+
"Kurzstrecke 101": {half: 2.40, full: 2.90},
|
12
|
+
"2 Zonen 101": {half: 3.20, full: 4.50}
|
13
|
+
}
|
14
|
+
- "Class upgrade \\(route-specific\\)":
|
15
|
+
scale: {half: [1,2], full: [0.5,1]}
|
16
|
+
|
@@ -0,0 +1,155 @@
|
|
1
|
+
module Halffare
|
2
|
+
class Stats
|
3
|
+
|
4
|
+
# Reads orders from `filename` that date back to max `months` months.
|
5
|
+
#
|
6
|
+
# @param filename [String] The filename to read from
|
7
|
+
# @param months [Integer, nil] Number of months look back or nil for all
|
8
|
+
def read(filename, months=nil)
|
9
|
+
@orders = []
|
10
|
+
start = months ? (Date.today << months.to_i).strftime('%Y-%m-%d') : nil
|
11
|
+
file = File.open(filename, "r:UTF-8") do |f|
|
12
|
+
while line = f.gets
|
13
|
+
order = Halffare::Model::Order.new(line)
|
14
|
+
if (start.nil? || line[0,10] >= start) && (order.note != Fetch::ORDER_NOTE_FILE_CREATED)
|
15
|
+
@orders.push(order)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
log_info "read #{@orders.length} orders from #{filename}"
|
20
|
+
if @orders.length == 0
|
21
|
+
if start.nil?
|
22
|
+
log_notice "no orders found"
|
23
|
+
else
|
24
|
+
log_notice "no orders found after #{start}, maybe tweak the --months param"
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
# How many orders were processed.
|
30
|
+
def count
|
31
|
+
@orders.length
|
32
|
+
end
|
33
|
+
|
34
|
+
# Calculates prices according to given strategy.
|
35
|
+
#
|
36
|
+
# @param strategy [String] Strategy name
|
37
|
+
# @param halffare [true, false] True if tickets were bought with a halffare card
|
38
|
+
def calculate(strategy, halffare)
|
39
|
+
@halfprice = 0
|
40
|
+
@fullprice = 0
|
41
|
+
|
42
|
+
if halffare
|
43
|
+
log_info "assuming order prices as half-fare"
|
44
|
+
else
|
45
|
+
log_info "assuming order prices as full"
|
46
|
+
end
|
47
|
+
|
48
|
+
log_notice "please note that you are using a strategy that involves guessing the real price" if ['guess', 'sbbguess'].include? strategy
|
49
|
+
|
50
|
+
strategy = price_factory(strategy)
|
51
|
+
strategy.halffare = halffare
|
52
|
+
log_info "using price strategy: #{strategy.class}"
|
53
|
+
price = Price.new(strategy)
|
54
|
+
log_info "calculating prices..."
|
55
|
+
|
56
|
+
@date_min = false
|
57
|
+
@date_max = false
|
58
|
+
@orders.each do |order|
|
59
|
+
|
60
|
+
if Halffare.debug
|
61
|
+
log_order(order)
|
62
|
+
end
|
63
|
+
|
64
|
+
halfprice, fullprice = price.get(order)
|
65
|
+
|
66
|
+
if Halffare.debug
|
67
|
+
if halfprice != 0 && fullprice != 0
|
68
|
+
log_result "FOUND: #{order.description} (#{order.price}): half=#{currency(halfprice)}, full=#{currency(fullprice)}"
|
69
|
+
|
70
|
+
if halffare
|
71
|
+
log_emphasize "You would pay (full price): #{currency(fullprice)}, you save #{currency(fullprice - order.price)}"
|
72
|
+
else
|
73
|
+
log_emphasize "You would pay (half-fare): #{currency(halfprice)}, you pay #{currency(order.price - halfprice)} more"
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
@halfprice += halfprice
|
79
|
+
@fullprice += fullprice
|
80
|
+
|
81
|
+
@date_min = order.travel_date if !@date_min || order.travel_date < @date_min
|
82
|
+
@date_max = order.travel_date if !@date_max || order.travel_date > @date_max
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
# Load config file.
|
87
|
+
def config
|
88
|
+
@config ||= YAML.load_file(File.expand_path('../../../halffare.yml', __FILE__))
|
89
|
+
end
|
90
|
+
|
91
|
+
def display(halffare)
|
92
|
+
say("\n")
|
93
|
+
days = (Date.parse(@date_max) - Date.parse(@date_min)).to_i
|
94
|
+
paid = halffare ? @halfprice : @fullprice
|
95
|
+
paid_per_day = paid / days
|
96
|
+
fullprice_per_day = @fullprice / days
|
97
|
+
saved_per_day = (@fullprice - @halfprice) / days
|
98
|
+
|
99
|
+
log_info "Results"
|
100
|
+
log_info "======="
|
101
|
+
say("\n")
|
102
|
+
log_info "OVERALL"
|
103
|
+
log_info "orders: #{@orders.length}"
|
104
|
+
log_info "first travel date: #{@date_min}"
|
105
|
+
log_info "last travel date : #{@date_max} (#{days} days)"
|
106
|
+
log_info "half-fare price : #{currency(@halfprice)}#{halffare ? ' (what you paid)':''}"
|
107
|
+
log_info "full price : #{currency(@fullprice)}#{halffare ? '' : ' (what you paid)'}"
|
108
|
+
log_info "half-fare savings: #{currency(@fullprice - @halfprice)}"
|
109
|
+
say("\n")
|
110
|
+
log_info "PER DAY"
|
111
|
+
log_info "you pay : #{currency(paid_per_day)}"
|
112
|
+
log_info "half-fare savings: #{currency(saved_per_day)}"
|
113
|
+
|
114
|
+
say("\n")
|
115
|
+
log_info "Half-Fare Card"
|
116
|
+
log_info "-------------"
|
117
|
+
|
118
|
+
config['cards'].each do |months,cash|
|
119
|
+
say("\n")
|
120
|
+
years = months/12
|
121
|
+
saved = saved_per_day * years * 365
|
122
|
+
log_info "#{years} YEAR#{years == 1 ? '' : 'S'} #{currency(cash)}"
|
123
|
+
log_info "you pay : #{currency(paid_per_day * years * 365)}"
|
124
|
+
log_info "full price : #{currency(fullprice_per_day * years * 365)}"
|
125
|
+
log_info "half-fare savings: #{currency(saved)}"
|
126
|
+
if saved >= cash
|
127
|
+
say("<%= color('GOOD TO GO', WHITE, BOLD, ON_GREEN) %> (#{currency((saved - cash) / years)} net win per year)")
|
128
|
+
else
|
129
|
+
say("<%= color('NAY', WHITE, BOLD, ON_RED) %> (#{currency((cash - saved) / years)} net loss per year)")
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
133
|
+
if fullprice_per_day * 365 > 2500
|
134
|
+
say("\n")
|
135
|
+
log_result "Since your tickets would cost approximately #{currency(fullprice_per_day * 365)} per year (w/o half-fare card) you should check out the GA prices for your age bracket: http://www.sbb.ch/abos-billette/abonnemente/ga/preise.html"
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
139
|
+
private
|
140
|
+
def price_factory(strategy)
|
141
|
+
case strategy.to_s
|
142
|
+
when "guess"
|
143
|
+
Halffare::PriceGuess.new
|
144
|
+
when "sbb"
|
145
|
+
Halffare::PriceSbb.new
|
146
|
+
when "sbbguess"
|
147
|
+
Halffare::PriceSbb.new(true)
|
148
|
+
else
|
149
|
+
raise "unknown strategy: #{strategy}"
|
150
|
+
end
|
151
|
+
end
|
152
|
+
|
153
|
+
end
|
154
|
+
end
|
155
|
+
|
metadata
ADDED
@@ -0,0 +1,150 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: halffare
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Roland Schilter
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2015-05-19 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: rake
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - '>='
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '0'
|
20
|
+
type: :development
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - '>='
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '0'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: rdoc
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - '>='
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '0'
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - '>='
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '0'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: aruba
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - '>='
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '0'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - '>='
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: gli
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - '='
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: 2.9.0
|
62
|
+
type: :runtime
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - '='
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: 2.9.0
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: mechanize
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - '='
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: 2.7.3
|
76
|
+
type: :runtime
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - '='
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: 2.7.3
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: highline
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - '='
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: 1.6.21
|
90
|
+
type: :runtime
|
91
|
+
prerelease: false
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - '='
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: 1.6.21
|
97
|
+
description: Evaluates whether a SBB Half-Fare travelcard is profitable based on your
|
98
|
+
online order history
|
99
|
+
email: roli@schilter.me
|
100
|
+
executables:
|
101
|
+
- halffare
|
102
|
+
extensions: []
|
103
|
+
extra_rdoc_files:
|
104
|
+
- README.rdoc
|
105
|
+
- halffare.rdoc
|
106
|
+
files:
|
107
|
+
- bin/halffare
|
108
|
+
- lib/halffare/model/order.rb
|
109
|
+
- lib/halffare/fetch.rb
|
110
|
+
- lib/halffare/price.rb
|
111
|
+
- lib/halffare/price_base.rb
|
112
|
+
- lib/halffare/price_guess.rb
|
113
|
+
- lib/halffare/price_sbb.rb
|
114
|
+
- lib/halffare/pricerules_sbb.yml
|
115
|
+
- lib/halffare/stats.rb
|
116
|
+
- lib/halffare/version.rb
|
117
|
+
- lib/halffare.rb
|
118
|
+
- README.rdoc
|
119
|
+
- halffare.rdoc
|
120
|
+
homepage: https://github.com/rndstr/halffare
|
121
|
+
licenses:
|
122
|
+
- MIT
|
123
|
+
metadata: {}
|
124
|
+
post_install_message:
|
125
|
+
rdoc_options:
|
126
|
+
- --title
|
127
|
+
- halffare
|
128
|
+
- --main
|
129
|
+
- README.rdoc
|
130
|
+
- -ri
|
131
|
+
require_paths:
|
132
|
+
- lib
|
133
|
+
- lib
|
134
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
135
|
+
requirements:
|
136
|
+
- - '>='
|
137
|
+
- !ruby/object:Gem::Version
|
138
|
+
version: '0'
|
139
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
140
|
+
requirements:
|
141
|
+
- - '>='
|
142
|
+
- !ruby/object:Gem::Version
|
143
|
+
version: '0'
|
144
|
+
requirements: []
|
145
|
+
rubyforge_project:
|
146
|
+
rubygems_version: 2.0.5
|
147
|
+
signing_key:
|
148
|
+
specification_version: 4
|
149
|
+
summary: SBB Half-Fare travelcard profitability
|
150
|
+
test_files: []
|