halffare 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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: []
|