sim-meter-au 0.1.0
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/LICENSE.md +21 -0
- data/README.md +243 -0
- data/exe/sim-meter-au +7 -0
- data/lib/sim-meter-au.rb +4 -0
- data/lib/sim_meter_au/cli.rb +416 -0
- data/lib/sim_meter_au/errors.rb +12 -0
- data/lib/sim_meter_au/models.rb +83 -0
- data/lib/sim_meter_au/provider.rb +82 -0
- data/lib/sim_meter_au/providers/aldi_mobile/parsers/orders.rb +73 -0
- data/lib/sim_meter_au/providers/aldi_mobile/parsers/service_details.rb +140 -0
- data/lib/sim_meter_au/providers/aldi_mobile/parsers/usage.rb +83 -0
- data/lib/sim_meter_au/providers/aldi_mobile.rb +228 -0
- data/lib/sim_meter_au/serializable.rb +28 -0
- data/lib/sim_meter_au/service.rb +7 -0
- data/lib/sim_meter_au/version.rb +5 -0
- data/lib/sim_meter_au.rb +47 -0
- metadata +131 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimMeterAU
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
class ConfigurationError < Error; end
|
|
6
|
+
class ProviderNotFoundError < ConfigurationError; end
|
|
7
|
+
class AuthenticationError < Error; end
|
|
8
|
+
class ConnectionError < Error; end
|
|
9
|
+
class UnexpectedResponseError < Error; end
|
|
10
|
+
class ServiceNotFoundError < Error; end
|
|
11
|
+
class AmbiguousServiceError < Error; end
|
|
12
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimMeterAU
|
|
4
|
+
Plan = Data.define(
|
|
5
|
+
:name,
|
|
6
|
+
:type,
|
|
7
|
+
:expires_on,
|
|
8
|
+
:days_remaining,
|
|
9
|
+
:auto_recharge
|
|
10
|
+
) do
|
|
11
|
+
include Serializable
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
DataBalance = Data.define(
|
|
15
|
+
:plan_remaining_bytes,
|
|
16
|
+
:rollover_bytes,
|
|
17
|
+
:total_remaining_bytes
|
|
18
|
+
) do
|
|
19
|
+
include Serializable
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
Payg = Data.define(
|
|
23
|
+
:balance_cents,
|
|
24
|
+
:expires_on,
|
|
25
|
+
:days_until_expiry
|
|
26
|
+
) do
|
|
27
|
+
include Serializable
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
ServiceDetails = Data.define(
|
|
31
|
+
:service,
|
|
32
|
+
:status,
|
|
33
|
+
:plan,
|
|
34
|
+
:data,
|
|
35
|
+
:payg,
|
|
36
|
+
:roaming_enabled,
|
|
37
|
+
:retrieved_at
|
|
38
|
+
) do
|
|
39
|
+
include Serializable
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
UsageSummary = Data.define(
|
|
43
|
+
:type,
|
|
44
|
+
:count,
|
|
45
|
+
:rounded_usage,
|
|
46
|
+
:charge_dollars
|
|
47
|
+
) do
|
|
48
|
+
include Serializable
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
UsageRecord = Data.define(
|
|
52
|
+
:type,
|
|
53
|
+
:number,
|
|
54
|
+
:destination,
|
|
55
|
+
:usage,
|
|
56
|
+
:charge_dollars,
|
|
57
|
+
:date_label
|
|
58
|
+
) do
|
|
59
|
+
include Serializable
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
UsageReport = Data.define(
|
|
63
|
+
:service,
|
|
64
|
+
:from,
|
|
65
|
+
:to,
|
|
66
|
+
:summaries,
|
|
67
|
+
:records
|
|
68
|
+
) do
|
|
69
|
+
include Serializable
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
Order = Data.define(
|
|
73
|
+
:id,
|
|
74
|
+
:number,
|
|
75
|
+
:type,
|
|
76
|
+
:plan_name,
|
|
77
|
+
:date_label,
|
|
78
|
+
:total_cents,
|
|
79
|
+
:transaction_reference
|
|
80
|
+
) do
|
|
81
|
+
include Serializable
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimMeterAU
|
|
4
|
+
class Provider
|
|
5
|
+
OPERATIONS = %i[
|
|
6
|
+
authenticate!
|
|
7
|
+
authenticated?
|
|
8
|
+
services
|
|
9
|
+
service
|
|
10
|
+
service_details
|
|
11
|
+
usage
|
|
12
|
+
orders
|
|
13
|
+
].freeze
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
def provider_key(value = nil)
|
|
17
|
+
@provider_key = value.to_sym if value
|
|
18
|
+
@provider_key
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def display_name(value = nil)
|
|
22
|
+
@display_name = value.to_s if value
|
|
23
|
+
@display_name || provider_key.to_s
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def credential_names(*names)
|
|
27
|
+
@credential_names = names.map(&:to_sym) unless names.empty?
|
|
28
|
+
@credential_names || []
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def credentials_from(environment)
|
|
32
|
+
prefix = provider_key.to_s.upcase
|
|
33
|
+
credential_names.to_h do |name|
|
|
34
|
+
[name, environment.fetch("#{prefix}_#{name.to_s.upcase}", "")]
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def provider
|
|
40
|
+
self.class.provider_key
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def authenticate!
|
|
44
|
+
unsupported_operation(__method__)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def login!
|
|
48
|
+
authenticate!
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def authenticated?
|
|
52
|
+
unsupported_operation(__method__)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def services
|
|
56
|
+
unsupported_operation(__method__)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def service(_identifier)
|
|
60
|
+
unsupported_operation(__method__)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def service_details(_identifier)
|
|
64
|
+
unsupported_operation(__method__)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def usage(_identifier, from:, to:)
|
|
68
|
+
unsupported_operation(__method__)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def orders(_identifier)
|
|
72
|
+
unsupported_operation(__method__)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def unsupported_operation(operation)
|
|
78
|
+
raise NotImplementedError,
|
|
79
|
+
"#{self.class} must implement ##{operation}"
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimMeterAU
|
|
4
|
+
module Providers
|
|
5
|
+
class AldiMobile
|
|
6
|
+
module Parsers
|
|
7
|
+
class Orders
|
|
8
|
+
HEADERS = ["Order #", "Number", "Type", "Plan", "Date", "Actions"].freeze
|
|
9
|
+
|
|
10
|
+
def parse(page)
|
|
11
|
+
table = page.css("table").find do |candidate|
|
|
12
|
+
candidate.css("th").map { |heading| text(heading) } == HEADERS
|
|
13
|
+
end
|
|
14
|
+
return [] unless table
|
|
15
|
+
|
|
16
|
+
orders = []
|
|
17
|
+
current = nil
|
|
18
|
+
|
|
19
|
+
table.css("tbody tr").each do |row|
|
|
20
|
+
cells = row.css("td").map { |cell| text(cell) }
|
|
21
|
+
if cells.length >= 6
|
|
22
|
+
orders << build_order(current) if current
|
|
23
|
+
current = {
|
|
24
|
+
id: cells[0],
|
|
25
|
+
number: normalize_number(cells[1]),
|
|
26
|
+
type: cells[2],
|
|
27
|
+
plan_name: cells[3],
|
|
28
|
+
date_label: cells[4]
|
|
29
|
+
}
|
|
30
|
+
elsif current && cells.length == 1
|
|
31
|
+
current[:total_cents] = parse_total(cells[0])
|
|
32
|
+
current[:transaction_reference] = cells[0][/Transaction Ref:\s*([^\s]+)/i, 1]
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
orders << build_order(current) if current
|
|
37
|
+
orders
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def build_order(attributes)
|
|
43
|
+
Order.new(
|
|
44
|
+
id: attributes[:id],
|
|
45
|
+
number: attributes[:number],
|
|
46
|
+
type: attributes[:type],
|
|
47
|
+
plan_name: attributes[:plan_name],
|
|
48
|
+
date_label: attributes[:date_label],
|
|
49
|
+
total_cents: attributes[:total_cents],
|
|
50
|
+
transaction_reference: attributes[:transaction_reference]
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def parse_total(value)
|
|
55
|
+
amount = value[/Total Cost:\s*\$([\d,]+(?:\.\d+)?)/i, 1]
|
|
56
|
+
return unless amount
|
|
57
|
+
|
|
58
|
+
(amount.delete(",").to_f * 100).round
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def normalize_number(number)
|
|
62
|
+
digits = number.gsub(/\D/, "")
|
|
63
|
+
digits.start_with?("61") ? "0#{digits.delete_prefix("61")}" : digits
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def text(node)
|
|
67
|
+
node&.text.to_s.gsub(/\s+/, " ").strip
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
5
|
+
module SimMeterAU
|
|
6
|
+
module Providers
|
|
7
|
+
class AldiMobile
|
|
8
|
+
module Parsers
|
|
9
|
+
class ServiceDetails
|
|
10
|
+
QUANTITY_UNITS = {
|
|
11
|
+
"B" => 1,
|
|
12
|
+
"KB" => 1024,
|
|
13
|
+
"MB" => 1024**2,
|
|
14
|
+
"GB" => 1024**3,
|
|
15
|
+
"TB" => 1024**4
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
def parse(page:, service:, roaming_enabled:, retrieved_at: Time.now)
|
|
19
|
+
plan_panel = find_panel(page, excluding: /Pay As You Go/i)
|
|
20
|
+
payg_panel = find_panel(page, including: /Pay As You Go/i)
|
|
21
|
+
plan_rows = resource_rows(plan_panel)
|
|
22
|
+
payg_rows = resource_rows(payg_panel)
|
|
23
|
+
plan = parse_plan(plan_panel, plan_rows)
|
|
24
|
+
|
|
25
|
+
SimMeterAU::ServiceDetails.new(
|
|
26
|
+
service:,
|
|
27
|
+
status: plan ? "active_plan" : "no_active_plan",
|
|
28
|
+
plan:,
|
|
29
|
+
data: parse_data(plan_rows),
|
|
30
|
+
payg: parse_payg(payg_rows),
|
|
31
|
+
roaming_enabled:,
|
|
32
|
+
retrieved_at:
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def find_panel(page, including: nil, excluding: nil)
|
|
39
|
+
page.css("#service-body .panel").find do |panel|
|
|
40
|
+
heading = text(panel.at_css("h3.panel-title"))
|
|
41
|
+
next false if heading.empty?
|
|
42
|
+
next false if including && !heading.match?(including)
|
|
43
|
+
next false if excluding && heading.match?(excluding)
|
|
44
|
+
|
|
45
|
+
true
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def resource_rows(panel)
|
|
50
|
+
return {} unless panel
|
|
51
|
+
|
|
52
|
+
panel.css(".row.clearfix").to_h do |row|
|
|
53
|
+
label = text(row.at_css("label"))
|
|
54
|
+
value = text(row.css(".col-xs-6").last)
|
|
55
|
+
[label, value]
|
|
56
|
+
end.reject { |label, _value| label.empty? }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def parse_plan(panel, rows)
|
|
60
|
+
return unless panel
|
|
61
|
+
|
|
62
|
+
name = text(panel.at_css("h3.panel-title"))
|
|
63
|
+
days, expiry = parse_expiry(rows["Days remaining"])
|
|
64
|
+
auto_recharge = panel.at_css("#autorechargeswitch")&.key?("checked")
|
|
65
|
+
|
|
66
|
+
Plan.new(
|
|
67
|
+
name:,
|
|
68
|
+
type: plan_type(name),
|
|
69
|
+
expires_on: expiry,
|
|
70
|
+
days_remaining: days,
|
|
71
|
+
auto_recharge:
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def parse_data(rows)
|
|
76
|
+
values = {
|
|
77
|
+
plan_remaining_bytes: parse_bytes(rows["Plan data remaining"]),
|
|
78
|
+
rollover_bytes: parse_bytes(rows["Data rollover"]),
|
|
79
|
+
total_remaining_bytes: parse_bytes(rows["Total data for you to use"])
|
|
80
|
+
}
|
|
81
|
+
return if values.values.all?(&:nil?)
|
|
82
|
+
|
|
83
|
+
DataBalance.new(**values)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def parse_payg(rows)
|
|
87
|
+
balance_label = rows.keys.find { |label| label.match?(/Pay As You Go.*balance/i) }
|
|
88
|
+
days, expiry = parse_expiry(rows["Days until service expiry"])
|
|
89
|
+
balance = parse_money(rows[balance_label]) if balance_label
|
|
90
|
+
return if balance.nil? && expiry.nil? && days.nil?
|
|
91
|
+
|
|
92
|
+
Payg.new(
|
|
93
|
+
balance_cents: balance,
|
|
94
|
+
expires_on: expiry,
|
|
95
|
+
days_until_expiry: days
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def parse_expiry(value)
|
|
100
|
+
return [nil, nil] if value.to_s.empty?
|
|
101
|
+
|
|
102
|
+
days = value[/\A\d+/]&.to_i
|
|
103
|
+
date = value[/Expiry\s+(\d{2}\/\d{2}\/\d{4})/, 1]
|
|
104
|
+
expiry = Date.strptime(date, "%d/%m/%Y") if date
|
|
105
|
+
[days, expiry]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def parse_bytes(value)
|
|
109
|
+
match = /([\d,.]+)\s*(B|KB|MB|GB|TB)\b/i.match(value.to_s)
|
|
110
|
+
return unless match
|
|
111
|
+
|
|
112
|
+
number = match[1].delete(",").to_f
|
|
113
|
+
(number * QUANTITY_UNITS.fetch(match[2].upcase)).round
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def parse_money(value)
|
|
117
|
+
amount = value.to_s[/\$\s*([\d,]+(?:\.\d+)?)/, 1]
|
|
118
|
+
return unless amount
|
|
119
|
+
|
|
120
|
+
(amount.delete(",").to_f * 100).round
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def plan_type(name)
|
|
124
|
+
case name
|
|
125
|
+
when /Family Plan/i then "family"
|
|
126
|
+
when /Mobile Plan/i then "mobile"
|
|
127
|
+
when /Data Plan/i then "data"
|
|
128
|
+
when /Super Pack/i then "super_pack"
|
|
129
|
+
else "unknown"
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def text(node)
|
|
134
|
+
node&.text.to_s.gsub(/\s+/, " ").strip
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimMeterAU
|
|
4
|
+
module Providers
|
|
5
|
+
class AldiMobile
|
|
6
|
+
module Parsers
|
|
7
|
+
class Usage
|
|
8
|
+
def parse(page:, service:, from:, to:)
|
|
9
|
+
UsageReport.new(
|
|
10
|
+
service:,
|
|
11
|
+
from:,
|
|
12
|
+
to:,
|
|
13
|
+
summaries: parse_summaries(page),
|
|
14
|
+
records: parse_records(page)
|
|
15
|
+
)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def parse_summaries(page)
|
|
21
|
+
table = find_table(page, ["Usage Type", "Count", "Rounded Duration/Data", "Charge"])
|
|
22
|
+
return [] unless table
|
|
23
|
+
|
|
24
|
+
table.css("tbody tr").filter_map do |row|
|
|
25
|
+
cells = row.css("td").map { |cell| text(cell) }
|
|
26
|
+
next unless cells.length >= 4
|
|
27
|
+
|
|
28
|
+
UsageSummary.new(
|
|
29
|
+
type: cells[0],
|
|
30
|
+
count: cells[1].delete(",").to_i,
|
|
31
|
+
rounded_usage: cells[2],
|
|
32
|
+
charge_dollars: money_string(cells[3])
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def parse_records(page)
|
|
38
|
+
table = find_table(page, ["Number", "Destination", "Duration/Data", "Charge", "Date"])
|
|
39
|
+
return [] unless table
|
|
40
|
+
|
|
41
|
+
current_type = nil
|
|
42
|
+
table.css("tbody tr").filter_map do |row|
|
|
43
|
+
cells = row.css("td").map { |cell| text(cell) }
|
|
44
|
+
if cells.length == 1
|
|
45
|
+
current_type = cells.first
|
|
46
|
+
next
|
|
47
|
+
end
|
|
48
|
+
next unless cells.length >= 5
|
|
49
|
+
|
|
50
|
+
UsageRecord.new(
|
|
51
|
+
type: current_type,
|
|
52
|
+
number: normalize_number(cells[0]),
|
|
53
|
+
destination: cells[1],
|
|
54
|
+
usage: cells[2],
|
|
55
|
+
charge_dollars: money_string(cells[3]),
|
|
56
|
+
date_label: cells[4]
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def find_table(page, expected_headers)
|
|
62
|
+
page.css("table").find do |table|
|
|
63
|
+
table.css("th").map { |heading| text(heading) } == expected_headers
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def normalize_number(number)
|
|
68
|
+
digits = number.gsub(/\D/, "")
|
|
69
|
+
digits.start_with?("61") ? "0#{digits.delete_prefix("61")}" : digits
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def money_string(value)
|
|
73
|
+
value.to_s.delete_prefix("$").delete(",")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def text(node)
|
|
77
|
+
node&.text.to_s.gsub(/\s+/, " ").strip
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mechanize"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "json"
|
|
6
|
+
require "date"
|
|
7
|
+
|
|
8
|
+
module SimMeterAU
|
|
9
|
+
module Providers
|
|
10
|
+
class AldiMobile < Provider
|
|
11
|
+
provider_key :aldi_mobile
|
|
12
|
+
display_name "ALDImobile"
|
|
13
|
+
credential_names :login, :password
|
|
14
|
+
DEFAULT_BASE_URL = "https://my.aldimobile.com.au"
|
|
15
|
+
LOGIN_PATH = "/login/"
|
|
16
|
+
LOGIN_FORM_ACTION = %r{/login_check/?\z}
|
|
17
|
+
SERVICE_SWITCH_PATH = %r{\A/admin/s/(?<id>\d+)/switch/?\z}
|
|
18
|
+
MOBILE_NUMBER = /(?:\+?61[\s-]?|0)4(?:[\s-]?\d){8}/
|
|
19
|
+
|
|
20
|
+
attr_reader :page
|
|
21
|
+
|
|
22
|
+
def initialize(login:, password:, base_url: DEFAULT_BASE_URL, agent: nil)
|
|
23
|
+
raise ConfigurationError, "login cannot be empty" if login.to_s.strip.empty?
|
|
24
|
+
raise ConfigurationError, "password cannot be empty" if password.to_s.empty?
|
|
25
|
+
|
|
26
|
+
@login = login.to_s
|
|
27
|
+
@password = password.to_s
|
|
28
|
+
@base_url = base_url.delete_suffix("/")
|
|
29
|
+
@agent = agent || build_agent
|
|
30
|
+
@authenticated = false
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def authenticate!
|
|
34
|
+
login_page = @agent.get("#{@base_url}#{LOGIN_PATH}")
|
|
35
|
+
form = find_login_form(login_page)
|
|
36
|
+
|
|
37
|
+
unless form
|
|
38
|
+
raise UnexpectedResponseError, "ALDImobile login form was not found"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
form["login_user[login]"] = @login
|
|
42
|
+
form["login_user[password]"] = @password
|
|
43
|
+
|
|
44
|
+
submit_button = form.button_with(name: "login_user[save]")
|
|
45
|
+
@page = submit_button ? @agent.submit(form, submit_button) : form.submit
|
|
46
|
+
|
|
47
|
+
unless authenticated_page?(@page)
|
|
48
|
+
@authenticated = false
|
|
49
|
+
raise AuthenticationError, authentication_error_message(@page)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
@authenticated = true
|
|
53
|
+
self
|
|
54
|
+
rescue Mechanize::ResponseCodeError => error
|
|
55
|
+
raise ConnectionError, "ALDImobile returned HTTP #{error.response_code}"
|
|
56
|
+
rescue SocketError, Timeout::Error, EOFError, Errno::ECONNREFUSED,
|
|
57
|
+
Errno::ECONNRESET, OpenSSL::SSL::SSLError => error
|
|
58
|
+
raise ConnectionError, "Could not connect to ALDImobile: #{error.class}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def authenticated?
|
|
62
|
+
@authenticated
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def services
|
|
66
|
+
ensure_authenticated!
|
|
67
|
+
|
|
68
|
+
@page.links.filter_map do |link|
|
|
69
|
+
match = SERVICE_SWITCH_PATH.match(link.href.to_s)
|
|
70
|
+
next unless match
|
|
71
|
+
|
|
72
|
+
number = link.text.to_s[MOBILE_NUMBER]
|
|
73
|
+
next unless number
|
|
74
|
+
|
|
75
|
+
Service.new(
|
|
76
|
+
id: match[:id],
|
|
77
|
+
name: service_name(link.text, number),
|
|
78
|
+
number: normalize_mobile_number(number)
|
|
79
|
+
)
|
|
80
|
+
end.uniq(&:id)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def service(identifier)
|
|
84
|
+
ensure_authenticated!
|
|
85
|
+
return identifier if identifier.is_a?(Service)
|
|
86
|
+
|
|
87
|
+
query = identifier.to_s.strip
|
|
88
|
+
matches = services.select do |candidate|
|
|
89
|
+
candidate.id == query ||
|
|
90
|
+
candidate.number == normalize_identifier_number(query) ||
|
|
91
|
+
candidate.name.casecmp?(query)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
if matches.empty?
|
|
95
|
+
raise ServiceNotFoundError, "No ALDImobile service matches #{query.inspect}"
|
|
96
|
+
end
|
|
97
|
+
if matches.length > 1
|
|
98
|
+
raise AmbiguousServiceError, "More than one ALDImobile service matches #{query.inspect}"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
matches.first
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def service_details(identifier)
|
|
105
|
+
selected_service = service(identifier)
|
|
106
|
+
overview = request { @agent.get("#{@base_url}/admin/s/#{selected_service.id}/switch") }
|
|
107
|
+
roaming_enabled = roaming_enabled?(selected_service)
|
|
108
|
+
|
|
109
|
+
Parsers::ServiceDetails.new.parse(
|
|
110
|
+
page: overview,
|
|
111
|
+
service: selected_service,
|
|
112
|
+
roaming_enabled:
|
|
113
|
+
)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def usage(identifier, from: Date.today - 30, to: Date.today)
|
|
117
|
+
selected_service = service(identifier)
|
|
118
|
+
from = coerce_date(from)
|
|
119
|
+
to = coerce_date(to)
|
|
120
|
+
raise ConfigurationError, "usage start date must not be after end date" if from > to
|
|
121
|
+
|
|
122
|
+
page = request { @agent.get("#{@base_url}/admin/s/#{selected_service.id}/usage") }
|
|
123
|
+
form = page.forms.find { |candidate| candidate.action.to_s.end_with?("/usage") }
|
|
124
|
+
unless form
|
|
125
|
+
raise UnexpectedResponseError, "ALDImobile usage form was not found"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
form["usage[start]"] = from.iso8601
|
|
129
|
+
form["usage[end]"] = to.iso8601
|
|
130
|
+
submit_button = form.button_with(name: "usage[getusage]")
|
|
131
|
+
result = request { submit_button ? @agent.submit(form, submit_button) : form.submit }
|
|
132
|
+
|
|
133
|
+
Parsers::Usage.new.parse(page: result, service: selected_service, from:, to:)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def orders(identifier)
|
|
137
|
+
selected_service = service(identifier)
|
|
138
|
+
page = request { @agent.get("#{@base_url}/admin/s/#{selected_service.id}/orders") }
|
|
139
|
+
Parsers::Orders.new.parse(page)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
private
|
|
143
|
+
|
|
144
|
+
def ensure_authenticated!
|
|
145
|
+
raise AuthenticationError, "Log in before requesting services" unless authenticated?
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def roaming_enabled?(selected_service)
|
|
149
|
+
response = request do
|
|
150
|
+
@agent.get("#{@base_url}/admin/roaming/status/#{selected_service.number}")
|
|
151
|
+
end
|
|
152
|
+
status = JSON.parse(response.body).fetch("roaming_status", nil)
|
|
153
|
+
return true if status.to_s == "1"
|
|
154
|
+
return false if status.to_s == "0"
|
|
155
|
+
|
|
156
|
+
nil
|
|
157
|
+
rescue JSON::ParserError
|
|
158
|
+
nil
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def request
|
|
162
|
+
yield
|
|
163
|
+
rescue Mechanize::ResponseCodeError => error
|
|
164
|
+
raise ConnectionError, "ALDImobile returned HTTP #{error.response_code}"
|
|
165
|
+
rescue SocketError, Timeout::Error, EOFError, Errno::ECONNREFUSED,
|
|
166
|
+
Errno::ECONNRESET, OpenSSL::SSL::SSLError => error
|
|
167
|
+
raise ConnectionError, "Could not connect to ALDImobile: #{error.class}"
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def coerce_date(value)
|
|
171
|
+
return value if value.is_a?(Date)
|
|
172
|
+
|
|
173
|
+
Date.iso8601(value.to_s)
|
|
174
|
+
rescue Date::Error
|
|
175
|
+
raise ConfigurationError, "invalid ISO date: #{value.inspect}"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def build_agent
|
|
179
|
+
Mechanize.new.tap do |agent|
|
|
180
|
+
agent.user_agent = "sim_meter_au/#{VERSION} aldi_mobile"
|
|
181
|
+
agent.open_timeout = 10
|
|
182
|
+
agent.read_timeout = 30
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def find_login_form(login_page)
|
|
187
|
+
return unless login_page.respond_to?(:form_with)
|
|
188
|
+
|
|
189
|
+
login_page.form_with(id: "login-specific") ||
|
|
190
|
+
login_page.form_with(action: LOGIN_FORM_ACTION)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def authenticated_page?(response_page)
|
|
194
|
+
path = response_page.uri.path
|
|
195
|
+
on_account_page = path == "/admin" || path.start_with?("/admin/")
|
|
196
|
+
has_login_form = !find_login_form(response_page).nil?
|
|
197
|
+
has_logout_link = !response_page.link_with(href: %r{/logout/?\z}).nil?
|
|
198
|
+
|
|
199
|
+
!has_login_form && (on_account_page || has_logout_link)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def authentication_error_message(response_page)
|
|
203
|
+
error_element = response_page.at(
|
|
204
|
+
".alert-danger, .form-error-message, .error, [role='alert']"
|
|
205
|
+
)
|
|
206
|
+
message = error_element&.text.to_s.gsub(/\s+/, " ").strip
|
|
207
|
+
|
|
208
|
+
message.empty? ? "ALDImobile login failed" : message
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def service_name(link_text, number)
|
|
212
|
+
link_text.to_s.sub(number, "").sub(/\s*-\s*\z/, "").strip
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def normalize_mobile_number(number)
|
|
216
|
+
digits = number.gsub(/\D/, "")
|
|
217
|
+
digits.start_with?("61") ? "0#{digits.delete_prefix("61")}" : digits
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def normalize_identifier_number(identifier)
|
|
221
|
+
digits = identifier.gsub(/\D/, "")
|
|
222
|
+
return "" if digits.length < 9
|
|
223
|
+
|
|
224
|
+
digits.start_with?("61") ? "0#{digits.delete_prefix("61")}" : digits
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|