truenorth 0.2.8 → 0.5.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 +4 -4
- data/lib/truenorth/cli.rb +22 -3
- data/lib/truenorth/client.rb +391 -25
- data/lib/truenorth/config.rb +35 -0
- data/lib/truenorth/version.rb +1 -1
- data/lib/truenorth.rb +1 -0
- data/truenorth.gemspec +4 -1
- metadata +15 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1ae8cfc6540c62e513428be9bf6b460cba5f1207f405d5dd075d2f3754fe9976
|
|
4
|
+
data.tar.gz: 20382b0505099606ebf214359796a956947ee6cf3667b7ac8d5797debf7e995b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 38de51f859b0c3c6f20a2c23d2bffabc91de6de3d4e8f065a9e9f25bacfa2b1ac6d53d5aed5e8755a67ba8b99ed46909a63f8ea891929958e0e0e020bdb37949
|
|
7
|
+
data.tar.gz: e8372584aaa758b89b29c620e0c31ff63d97740e1a8bfd3ccaec52e88a063b2934e05aad1c465995454d0ffa1171596d4fd74d3dc26160c85f1e5b2aa1a3249c
|
data/lib/truenorth/cli.rb
CHANGED
|
@@ -35,9 +35,17 @@ module Truenorth
|
|
|
35
35
|
desc 'availability [DATE]', 'Check available slots'
|
|
36
36
|
option :activity, aliases: '-a', default: 'squash', desc: 'Activity type (squash, golf, music, meeting)'
|
|
37
37
|
option :json, type: :boolean, desc: 'Output as JSON'
|
|
38
|
+
option :debug, type: :boolean, desc: 'Show debug output'
|
|
39
|
+
option :http, type: :boolean, desc: 'Use HTTP mode (faster, but only 2 courts)'
|
|
38
40
|
def availability(date = nil)
|
|
39
41
|
date = parse_date(date)
|
|
40
|
-
|
|
42
|
+
|
|
43
|
+
client = if options[:http]
|
|
44
|
+
say 'Using HTTP mode (fast, 2 courts only)...', :yellow if !options[:json]
|
|
45
|
+
Client.new(debug: options[:debug])
|
|
46
|
+
else
|
|
47
|
+
BrowserClient.new(debug: options[:debug])
|
|
48
|
+
end
|
|
41
49
|
|
|
42
50
|
say "Checking availability for #{date}...", :cyan
|
|
43
51
|
result = client.availability(date, activity: options[:activity])
|
|
@@ -64,6 +72,8 @@ module Truenorth
|
|
|
64
72
|
option :court, aliases: '-c', desc: 'Preferred court (e.g., "Court 1", "Squash Court 2")'
|
|
65
73
|
option :activity, aliases: '-a', default: 'squash', desc: 'Activity type'
|
|
66
74
|
option :dry_run, type: :boolean, aliases: '-n', desc: 'Test without actually booking'
|
|
75
|
+
option :http, type: :boolean, desc: 'Use HTTP mode (faster, but only 2 courts)'
|
|
76
|
+
option :debug, type: :boolean, desc: 'Show debug output'
|
|
67
77
|
def book(time_or_description)
|
|
68
78
|
# Parse natural language input
|
|
69
79
|
parsed = parse_booking_request(time_or_description, options[:date], options[:activity])
|
|
@@ -71,7 +81,13 @@ module Truenorth
|
|
|
71
81
|
date = parsed[:date]
|
|
72
82
|
time = parsed[:time]
|
|
73
83
|
activity = parsed[:activity]
|
|
74
|
-
|
|
84
|
+
|
|
85
|
+
# Use browser mode unless --http flag is set (needed to see all 3 courts)
|
|
86
|
+
client = if options[:http]
|
|
87
|
+
Client.new(debug: options[:debug])
|
|
88
|
+
else
|
|
89
|
+
BrowserClient.new(debug: options[:debug])
|
|
90
|
+
end
|
|
75
91
|
|
|
76
92
|
mode = options[:dry_run] ? ' (DRY RUN)' : ''
|
|
77
93
|
say "Booking #{activity} at #{time} on #{date}#{mode}...", :cyan
|
|
@@ -109,8 +125,10 @@ module Truenorth
|
|
|
109
125
|
desc 'reservations', 'List your current reservations'
|
|
110
126
|
option :json, type: :boolean, desc: 'Output as JSON'
|
|
111
127
|
option :all, type: :boolean, aliases: '-a', desc: 'Show all family members (default: only you)'
|
|
128
|
+
option :debug, type: :boolean, desc: 'Show debug output'
|
|
112
129
|
def reservations
|
|
113
|
-
|
|
130
|
+
# Use HTTP mode for reservations (works reliably)
|
|
131
|
+
client = Client.new(debug: options[:debug])
|
|
114
132
|
|
|
115
133
|
say 'Fetching reservations...', :cyan
|
|
116
134
|
results = client.reservations
|
|
@@ -153,6 +171,7 @@ module Truenorth
|
|
|
153
171
|
option :all, type: :boolean, aliases: '-a', desc: 'Cancel from full list (use with --all view)'
|
|
154
172
|
option :debug, type: :boolean, desc: 'Show debug output'
|
|
155
173
|
def cancel(index)
|
|
174
|
+
# Use HTTP mode for cancellation (works reliably)
|
|
156
175
|
client = Client.new(debug: options[:debug])
|
|
157
176
|
|
|
158
177
|
say 'Fetching reservations...', :cyan
|
data/lib/truenorth/client.rb
CHANGED
|
@@ -15,7 +15,8 @@ module Truenorth
|
|
|
15
15
|
BOOKING_PATH = '/group/pages/facility-booking'
|
|
16
16
|
RESERVATIONS_PATH = '/group/pages/my-reservations'
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
# Use a desktop user agent - server may detect mobile based on UA
|
|
19
|
+
USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ' \
|
|
19
20
|
'AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
|
|
20
21
|
|
|
21
22
|
# Activity type IDs
|
|
@@ -37,16 +38,27 @@ module Truenorth
|
|
|
37
38
|
'32' => 'Court 3'
|
|
38
39
|
}.freeze
|
|
39
40
|
|
|
41
|
+
# Court IDs by activity
|
|
42
|
+
COURT_IDS_BY_ACTIVITY = {
|
|
43
|
+
'squash' => %w[16 17 18],
|
|
44
|
+
'golf' => %w[30 31 32],
|
|
45
|
+
'music' => %w[16 17 18], # May need to update these
|
|
46
|
+
'room' => %w[30 31 32], # May need to update these
|
|
47
|
+
'meeting' => %w[30 31 32] # May need to update these
|
|
48
|
+
}.freeze
|
|
49
|
+
|
|
40
50
|
attr_reader :cookies, :debug_log, :logged_in
|
|
41
51
|
|
|
42
52
|
def initialize(base_url: nil, debug: false)
|
|
43
53
|
@base_url = base_url || Config.base_url
|
|
44
54
|
raise Error, 'No base URL configured. Run: truenorth configure' unless @base_url
|
|
45
55
|
|
|
46
|
-
@cookies = {}
|
|
56
|
+
@cookies = Config.cookies || {}
|
|
47
57
|
@debug = debug
|
|
48
58
|
@debug_log = StringIO.new
|
|
49
|
-
@logged_in =
|
|
59
|
+
@logged_in = !@cookies.empty? # If we have cookies, assume logged in
|
|
60
|
+
|
|
61
|
+
log "Loaded #{@cookies.length} cookies from cache" if @logged_in && @debug
|
|
50
62
|
end
|
|
51
63
|
|
|
52
64
|
# Login to the booking system
|
|
@@ -91,7 +103,8 @@ module Truenorth
|
|
|
91
103
|
|
|
92
104
|
if response.body.include?('Sign Out') || response.body.include?('My Reservations')
|
|
93
105
|
@logged_in = true
|
|
94
|
-
|
|
106
|
+
Config.save_cookies(@cookies)
|
|
107
|
+
log 'Login successful (cookies saved)'
|
|
95
108
|
true
|
|
96
109
|
else
|
|
97
110
|
html = Nokogiri::HTML(response.body)
|
|
@@ -110,19 +123,70 @@ module Truenorth
|
|
|
110
123
|
response = get(BOOKING_PATH)
|
|
111
124
|
html = Nokogiri::HTML(response.body)
|
|
112
125
|
|
|
113
|
-
#
|
|
126
|
+
# Navigate to the requested date first if needed
|
|
127
|
+
current_date = html.at_css('input[name*="sheetDate"]')&.[]('value')
|
|
128
|
+
requested_date = date.strftime('%m/%d/%Y')
|
|
114
129
|
activity_id = ACTIVITIES[activity.to_s.downcase] || '5'
|
|
130
|
+
|
|
131
|
+
if current_date != requested_date
|
|
132
|
+
log "Navigating from #{current_date} to #{requested_date}"
|
|
133
|
+
html = change_date(html, requested_date, activity_id)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Then change activity type - this should now return slots for the correct date
|
|
115
137
|
current_activity = html.at_css('input[name*="activityId"]')&.[]('value')
|
|
116
138
|
|
|
117
139
|
if current_activity && current_activity != activity_id
|
|
118
|
-
|
|
140
|
+
log "Changing activity from #{current_activity} to #{activity_id}"
|
|
119
141
|
html = change_activity(html, activity_id)
|
|
120
142
|
end
|
|
121
143
|
|
|
122
|
-
#
|
|
144
|
+
# Debug: Save HTML after activity change
|
|
145
|
+
if @debug
|
|
146
|
+
debug_file = "/tmp/truenorth_after_activity_#{Time.now.to_i}.html"
|
|
147
|
+
File.write(debug_file, html.to_s)
|
|
148
|
+
log "Saved HTML after activity change to: #{debug_file}"
|
|
149
|
+
end
|
|
123
150
|
|
|
124
|
-
|
|
125
|
-
|
|
151
|
+
# Court dropdown not available in HTML response - use hardcoded IDs
|
|
152
|
+
court_ids = COURT_IDS_BY_ACTIVITY[activity.to_s.downcase] || []
|
|
153
|
+
|
|
154
|
+
if court_ids.length >= 3
|
|
155
|
+
log "Querying #{court_ids.length} courts individually (IDs: #{court_ids.join(', ')})"
|
|
156
|
+
all_slots = {}
|
|
157
|
+
|
|
158
|
+
court_ids.each do |court_id|
|
|
159
|
+
court_name = COURTS[court_id] || "Court #{court_id}"
|
|
160
|
+
log "Querying #{court_name} (ID: #{court_id})"
|
|
161
|
+
|
|
162
|
+
# Set activityAreaId and refresh
|
|
163
|
+
form_id = extract_form_id(html)
|
|
164
|
+
view_state = extract_view_state(html)
|
|
165
|
+
form_fields = extract_all_form_fields(html, form_id)
|
|
166
|
+
form_fields["#{form_id}:activityAreaId"] = court_id
|
|
167
|
+
|
|
168
|
+
# Make AJAX request to update table for this court
|
|
169
|
+
result = change_date_ajax(form_id, view_state, requested_date, form_fields)
|
|
170
|
+
if result[:success]
|
|
171
|
+
court_html = Nokogiri::HTML(result[:body].scan(/<!\[CDATA\[(.*?)\]\]>/m).flatten.join("\n"))
|
|
172
|
+
court_slots = parse_slots(court_html)
|
|
173
|
+
|
|
174
|
+
# Merge slots
|
|
175
|
+
court_slots.each do |time, courts|
|
|
176
|
+
all_slots[time] ||= []
|
|
177
|
+
all_slots[time].concat(courts)
|
|
178
|
+
all_slots[time].uniq!
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
slots = all_slots
|
|
184
|
+
log "Combined #{slots.count} time slots from all courts"
|
|
185
|
+
else
|
|
186
|
+
# Fallback to original parsing
|
|
187
|
+
slots = parse_slots(html)
|
|
188
|
+
log "Found #{slots.count} available time slots"
|
|
189
|
+
end
|
|
126
190
|
|
|
127
191
|
{
|
|
128
192
|
success: true,
|
|
@@ -132,8 +196,10 @@ module Truenorth
|
|
|
132
196
|
}
|
|
133
197
|
end
|
|
134
198
|
|
|
199
|
+
|
|
135
200
|
# Book a slot
|
|
136
|
-
|
|
201
|
+
# If slot_info is provided, it should have: { area_id:, start_time:, end_time:, court: }
|
|
202
|
+
def book(time, date: Date.today, court: nil, activity: 'squash', dry_run: false, slot_info: nil)
|
|
137
203
|
ensure_logged_in!
|
|
138
204
|
|
|
139
205
|
log "\n=== BOOK SLOT ==="
|
|
@@ -164,11 +230,22 @@ module Truenorth
|
|
|
164
230
|
form_fields = extract_all_form_fields(html, form_id)
|
|
165
231
|
end
|
|
166
232
|
|
|
167
|
-
# Find the slot
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
233
|
+
# Find the slot (or use provided slot_info)
|
|
234
|
+
if slot_info
|
|
235
|
+
log "Using provided slot info: #{slot_info[:court]} at #{slot_info[:start_time]}"
|
|
236
|
+
# Convert slot_info keys from symbols if needed and ensure we have an id
|
|
237
|
+
slot = {
|
|
238
|
+
id: nil, # We'll generate this or it's not needed for AJAX
|
|
239
|
+
area_id: slot_info[:area_id],
|
|
240
|
+
court: slot_info[:court],
|
|
241
|
+
start_time: slot_info[:start_time] || slot_info[:time],
|
|
242
|
+
end_time: slot_info[:end_time]
|
|
243
|
+
}
|
|
244
|
+
else
|
|
245
|
+
slot = find_slot(html, time, court)
|
|
246
|
+
raise BookingError, "No slot available at #{time}" unless slot
|
|
247
|
+
log "Found slot: #{slot[:court]} at #{slot[:start_time]}"
|
|
248
|
+
end
|
|
172
249
|
|
|
173
250
|
# Select slot via AJAX
|
|
174
251
|
select_result = select_slot_ajax(form_id, view_state, slot, components, form_fields)
|
|
@@ -409,17 +486,70 @@ module Truenorth
|
|
|
409
486
|
def parse_slots(html)
|
|
410
487
|
slots = {}
|
|
411
488
|
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
next if td['class']&.include?('reserved')
|
|
489
|
+
# Debug: Count total columns found
|
|
490
|
+
headers = html.css('thead th[role="columnheader"]')
|
|
491
|
+
log "Found #{headers.length} column headers: #{headers.map { |h| h['aria-label'] }.join(', ')}"
|
|
416
492
|
|
|
417
|
-
|
|
493
|
+
# Debug: Count slots by area-id
|
|
494
|
+
area_counts = Hash.new(0)
|
|
495
|
+
html.css('td.slot div[data-start-time]').each do |div|
|
|
418
496
|
area_id = div['data-area-id']
|
|
419
|
-
|
|
497
|
+
area_counts[area_id] += 1
|
|
498
|
+
end
|
|
499
|
+
log "Slots by area ID: #{area_counts.inspect}"
|
|
500
|
+
|
|
501
|
+
# Find ALL slots with data-start-time
|
|
502
|
+
html.css('td.slot div[data-start-time]').each do |div|
|
|
503
|
+
td = div.parent
|
|
504
|
+
while td && td.name != 'td'
|
|
505
|
+
td = td.parent
|
|
506
|
+
end
|
|
507
|
+
next unless td
|
|
508
|
+
|
|
509
|
+
classes = td['class'].to_s
|
|
510
|
+
|
|
511
|
+
# Skip definitively unavailable slots
|
|
512
|
+
next if classes.include?('reserved')
|
|
513
|
+
next if classes.include?('restrict')
|
|
514
|
+
next if classes.include?('blocked')
|
|
420
515
|
|
|
421
|
-
|
|
422
|
-
slots
|
|
516
|
+
# Include ANY slot that's marked as "open", even if it has past-time
|
|
517
|
+
# The website shows past-time slots for future dates as bookable
|
|
518
|
+
if classes.include?('open')
|
|
519
|
+
start_time = div['data-start-time']
|
|
520
|
+
area_id = div['data-area-id']
|
|
521
|
+
court_name = COURTS[area_id] || "Court #{area_id}"
|
|
522
|
+
|
|
523
|
+
slots[start_time] ||= []
|
|
524
|
+
slots[start_time] << court_name unless slots[start_time].include?(court_name)
|
|
525
|
+
end
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
# If we found very few slots, be more aggressive
|
|
529
|
+
if slots.length < 10
|
|
530
|
+
log "Found only #{slots.length} slots with strict parsing, trying relaxed parsing..."
|
|
531
|
+
|
|
532
|
+
# Try including ALL non-reserved slots
|
|
533
|
+
html.css('td.slot div[data-start-time]').each do |div|
|
|
534
|
+
td = div.parent
|
|
535
|
+
while td && td.name != 'td'
|
|
536
|
+
td = td.parent
|
|
537
|
+
end
|
|
538
|
+
next unless td
|
|
539
|
+
|
|
540
|
+
classes = td['class'].to_s
|
|
541
|
+
# Only skip if explicitly reserved/restricted/blocked
|
|
542
|
+
next if classes.include?('reserved') && !classes.include?('open')
|
|
543
|
+
next if classes.include?('restrict')
|
|
544
|
+
next if classes.include?('blocked')
|
|
545
|
+
|
|
546
|
+
start_time = div['data-start-time']
|
|
547
|
+
area_id = div['data-area-id']
|
|
548
|
+
court_name = COURTS[area_id] || "Court #{area_id}"
|
|
549
|
+
|
|
550
|
+
slots[start_time] ||= []
|
|
551
|
+
slots[start_time] << court_name unless slots[start_time].include?(court_name)
|
|
552
|
+
end
|
|
423
553
|
end
|
|
424
554
|
|
|
425
555
|
slots
|
|
@@ -541,9 +671,128 @@ module Truenorth
|
|
|
541
671
|
result = change_activity_ajax(form_id, view_state, activity_id, form_fields, components)
|
|
542
672
|
return html unless result[:success]
|
|
543
673
|
|
|
674
|
+
# Parse the response and check for Court 3
|
|
675
|
+
parsed = Nokogiri::HTML(result[:body])
|
|
676
|
+
court3_elements = parsed.css('th[aria-label*="Court 3"], div[data-area-id="18"], div[data-area-id="32"]')
|
|
677
|
+
log "After change_activity: Found #{court3_elements.length} Court 3 elements in response"
|
|
678
|
+
|
|
679
|
+
parsed
|
|
680
|
+
end
|
|
681
|
+
|
|
682
|
+
def change_court_dropdown(html, court_id)
|
|
683
|
+
form_id = extract_form_id(html)
|
|
684
|
+
view_state = extract_view_state(html)
|
|
685
|
+
form_fields = extract_all_form_fields(html, form_id)
|
|
686
|
+
|
|
687
|
+
# Find the court dropdown field dynamically (in .activity-areas div)
|
|
688
|
+
court_dropdown = html.at_css('.activity-areas select, div[class*="area"] select[id*="j_idt"]')
|
|
689
|
+
return html unless court_dropdown
|
|
690
|
+
|
|
691
|
+
dropdown_id = court_dropdown['id']
|
|
692
|
+
dropdown_source = dropdown_id.gsub(/_input$/, '')
|
|
693
|
+
|
|
694
|
+
result = change_court_dropdown_ajax(form_id, view_state, court_id, dropdown_id, dropdown_source, form_fields)
|
|
695
|
+
return html unless result[:success]
|
|
696
|
+
|
|
697
|
+
# Parse CDATA content from response
|
|
698
|
+
cdata_content = result[:body].scan(/<!\[CDATA\[(.*?)\]\]>/m).flatten.join("\n")
|
|
699
|
+
if !cdata_content.empty?
|
|
700
|
+
cdata_html = Nokogiri::HTML(cdata_content)
|
|
701
|
+
slots = cdata_html.css('div[data-start-time]')
|
|
702
|
+
log "Court #{court_id}: Found #{slots.length} slots in CDATA response"
|
|
703
|
+
return cdata_html if slots.length > 0
|
|
704
|
+
end
|
|
705
|
+
|
|
544
706
|
Nokogiri::HTML(result[:body])
|
|
545
707
|
end
|
|
546
708
|
|
|
709
|
+
def change_court_area(html, area_id)
|
|
710
|
+
form_id = extract_form_id(html)
|
|
711
|
+
view_state = extract_view_state(html)
|
|
712
|
+
form_fields = extract_all_form_fields(html, form_id)
|
|
713
|
+
|
|
714
|
+
result = change_court_area_ajax(form_id, view_state, area_id, form_fields)
|
|
715
|
+
return html unless result[:success]
|
|
716
|
+
|
|
717
|
+
# Debug: Check response
|
|
718
|
+
parsed = Nokogiri::HTML(result[:body])
|
|
719
|
+
slot_divs = parsed.css('div[data-start-time]')
|
|
720
|
+
log "Court area #{area_id} response: #{slot_divs.length} slot divs found"
|
|
721
|
+
|
|
722
|
+
# Also check CDATA content
|
|
723
|
+
cdata_content = result[:body].scan(/<!\[CDATA\[(.*?)\]\]>/m).flatten.join("\n")
|
|
724
|
+
if !cdata_content.empty?
|
|
725
|
+
cdata_html = Nokogiri::HTML(cdata_content)
|
|
726
|
+
cdata_slots = cdata_html.css('div[data-start-time]')
|
|
727
|
+
log "Court area #{area_id} CDATA: #{cdata_slots.length} slot divs in CDATA"
|
|
728
|
+
return cdata_html if cdata_slots.length > 0
|
|
729
|
+
end
|
|
730
|
+
|
|
731
|
+
parsed
|
|
732
|
+
end
|
|
733
|
+
|
|
734
|
+
def change_date(html, date_str, activity_id = nil)
|
|
735
|
+
# Extract what we can from the HTML
|
|
736
|
+
form_id = extract_form_id(html)
|
|
737
|
+
view_state = extract_view_state(html)
|
|
738
|
+
|
|
739
|
+
# Build minimal form data manually to avoid extraction issues
|
|
740
|
+
form_data = build_minimal_form_data(html, form_id, activity_id)
|
|
741
|
+
|
|
742
|
+
result = change_date_ajax(form_id, view_state, date_str, form_data)
|
|
743
|
+
return html unless result[:success]
|
|
744
|
+
|
|
745
|
+
# Parse the AJAX response
|
|
746
|
+
ajax_body = result[:body]
|
|
747
|
+
cdata_content = ajax_body.scan(/<!\[CDATA\[(.*?)\]\]>/m).flatten.join("\n")
|
|
748
|
+
|
|
749
|
+
if !cdata_content.empty? && cdata_content.include?('slot')
|
|
750
|
+
parsed_html = Nokogiri::HTML(cdata_content)
|
|
751
|
+
|
|
752
|
+
# Debug: Check if Court 3 is in the response
|
|
753
|
+
court3_headers = parsed_html.css('th[aria-label*="Court 3"]')
|
|
754
|
+
log "Court 3 headers found in AJAX response: #{court3_headers.length}"
|
|
755
|
+
|
|
756
|
+
# Debug: Check for area ID 18 or 32 (Court 3 IDs)
|
|
757
|
+
court3_slots = parsed_html.css('div[data-area-id="18"], div[data-area-id="32"]')
|
|
758
|
+
log "Court 3 slots (area 18/32) found in AJAX response: #{court3_slots.length}"
|
|
759
|
+
|
|
760
|
+
parsed_html
|
|
761
|
+
else
|
|
762
|
+
html
|
|
763
|
+
end
|
|
764
|
+
end
|
|
765
|
+
|
|
766
|
+
def build_minimal_form_data(html, form_id, activity_id)
|
|
767
|
+
# Build the minimum required form data from scratch
|
|
768
|
+
# Only extract the fields we absolutely need
|
|
769
|
+
data = {}
|
|
770
|
+
|
|
771
|
+
# Try to find the form
|
|
772
|
+
form = html.at_css("form[id='#{form_id}']")
|
|
773
|
+
return data unless form
|
|
774
|
+
|
|
775
|
+
# Extract only the essential hidden fields
|
|
776
|
+
form.css('input[type="hidden"]').each do |input|
|
|
777
|
+
name = input['name']
|
|
778
|
+
value = input['value']
|
|
779
|
+
next unless name
|
|
780
|
+
|
|
781
|
+
# Only keep fields that belong to our form
|
|
782
|
+
if name.to_s.start_with?(form_id)
|
|
783
|
+
data[name] = value || ''
|
|
784
|
+
end
|
|
785
|
+
end
|
|
786
|
+
|
|
787
|
+
# Override activity ID if provided
|
|
788
|
+
if activity_id
|
|
789
|
+
data["#{form_id}:activityId"] = activity_id.to_s
|
|
790
|
+
data["#{form_id}:j_idt51_input"] = activity_id.to_s
|
|
791
|
+
end
|
|
792
|
+
|
|
793
|
+
data
|
|
794
|
+
end
|
|
795
|
+
|
|
547
796
|
def extract_view_state(html)
|
|
548
797
|
html.at_css('input[name="javax.faces.ViewState"]')&.[]('value')
|
|
549
798
|
end
|
|
@@ -580,7 +829,22 @@ module Truenorth
|
|
|
580
829
|
|
|
581
830
|
selected = select.at_css('option[selected]')
|
|
582
831
|
fields[select['name']] = selected['value'] if selected
|
|
832
|
+
|
|
833
|
+
# Debug: Log select options
|
|
834
|
+
if select['name']&.include?('area') || select['name']&.include?('court') || select['name']&.include?('trainer')
|
|
835
|
+
options = select.css('option').map { |opt| "#{opt.text.strip}=#{opt['value']}" }
|
|
836
|
+
log "Found selector #{select['name']}: #{options.join(', ')}"
|
|
837
|
+
end
|
|
583
838
|
end
|
|
839
|
+
|
|
840
|
+
# Force parameters to get all courts
|
|
841
|
+
fields["#{form_id}:mobileViewDisplay"] = '0' # 0 = full view
|
|
842
|
+
|
|
843
|
+
# Debug: Log all field names and key values
|
|
844
|
+
log "Form fields: #{fields.keys.join(', ')}"
|
|
845
|
+
log "activityAreaId current value: #{fields["#{form_id}:activityAreaId"]}"
|
|
846
|
+
log "showAllAreasOrTrainers: #{fields["#{form_id}:showAllAreasOrTrainers"]}"
|
|
847
|
+
|
|
584
848
|
fields
|
|
585
849
|
end
|
|
586
850
|
|
|
@@ -609,6 +873,12 @@ module Truenorth
|
|
|
609
873
|
form_data = form_fields.dup
|
|
610
874
|
form_data["#{form_id}:j_idt51_input"] = activity_id
|
|
611
875
|
form_data["#{form_id}:activityId"] = activity_id
|
|
876
|
+
form_data["#{form_id}:showAllAreasOrTrainers"] = 'true' # Show all courts!
|
|
877
|
+
|
|
878
|
+
# Debug: Log what we're sending
|
|
879
|
+
log "Sending to #{form_id}:showAllAreasOrTrainers = #{form_data["#{form_id}:showAllAreasOrTrainers"]}"
|
|
880
|
+
log "Sending to #{form_id}:mobileViewDisplay = #{form_data["#{form_id}:mobileViewDisplay"]}"
|
|
881
|
+
|
|
612
882
|
form_data.merge!(
|
|
613
883
|
'javax.faces.partial.ajax' => 'true',
|
|
614
884
|
'javax.faces.source' => activity_dropdown,
|
|
@@ -629,6 +899,90 @@ module Truenorth
|
|
|
629
899
|
end
|
|
630
900
|
end
|
|
631
901
|
|
|
902
|
+
def change_court_dropdown_ajax(form_id, view_state, court_id, dropdown_input_id, dropdown_source, form_fields)
|
|
903
|
+
ajax_url = build_ajax_url
|
|
904
|
+
encoded_url = URI.encode_www_form_component(ajax_url)
|
|
905
|
+
|
|
906
|
+
form_data = form_fields.dup
|
|
907
|
+
form_data[dropdown_input_id] = court_id
|
|
908
|
+
form_data.merge!(
|
|
909
|
+
'javax.faces.partial.ajax' => 'true',
|
|
910
|
+
'javax.faces.source' => dropdown_source,
|
|
911
|
+
'javax.faces.partial.execute' => dropdown_source,
|
|
912
|
+
'javax.faces.partial.render' => form_id,
|
|
913
|
+
'javax.faces.behavior.event' => 'change',
|
|
914
|
+
'javax.faces.partial.event' => 'change',
|
|
915
|
+
form_id => form_id,
|
|
916
|
+
'javax.faces.encodedURL' => encoded_url,
|
|
917
|
+
'javax.faces.ViewState' => view_state
|
|
918
|
+
)
|
|
919
|
+
|
|
920
|
+
response = post_ajax(ajax_url, form_data)
|
|
921
|
+
if response.is_a?(Net::HTTPSuccess)
|
|
922
|
+
{ success: true, view_state: extract_view_state_from_ajax(response.body), body: response.body }
|
|
923
|
+
else
|
|
924
|
+
{ success: false, error: "HTTP #{response.code}" }
|
|
925
|
+
end
|
|
926
|
+
end
|
|
927
|
+
|
|
928
|
+
def change_court_area_ajax(form_id, view_state, area_id, form_fields)
|
|
929
|
+
ajax_url = build_ajax_url
|
|
930
|
+
encoded_url = URI.encode_www_form_component(ajax_url)
|
|
931
|
+
|
|
932
|
+
form_data = form_fields.dup
|
|
933
|
+
form_data["#{form_id}:activityAreaId"] = area_id
|
|
934
|
+
form_data.merge!(
|
|
935
|
+
'javax.faces.partial.ajax' => 'true',
|
|
936
|
+
'javax.faces.source' => form_id,
|
|
937
|
+
'javax.faces.partial.execute' => form_id,
|
|
938
|
+
'javax.faces.partial.render' => form_id,
|
|
939
|
+
form_id => form_id,
|
|
940
|
+
'javax.faces.encodedURL' => encoded_url,
|
|
941
|
+
'javax.faces.ViewState' => view_state
|
|
942
|
+
)
|
|
943
|
+
|
|
944
|
+
response = post_ajax(ajax_url, form_data)
|
|
945
|
+
if response.is_a?(Net::HTTPSuccess)
|
|
946
|
+
{ success: true, view_state: extract_view_state_from_ajax(response.body), body: response.body }
|
|
947
|
+
else
|
|
948
|
+
{ success: false, error: "HTTP #{response.code}" }
|
|
949
|
+
end
|
|
950
|
+
end
|
|
951
|
+
|
|
952
|
+
def change_date_ajax(form_id, view_state, date_str, form_data)
|
|
953
|
+
return { success: false, error: 'No form_id' } unless form_id
|
|
954
|
+
|
|
955
|
+
date_picker = "#{form_id}:j_idt57"
|
|
956
|
+
ajax_url = build_ajax_url
|
|
957
|
+
encoded_url = URI.encode_www_form_component(ajax_url)
|
|
958
|
+
|
|
959
|
+
# Use form_data as-is (already built correctly by build_minimal_form_data)
|
|
960
|
+
# Just add our date values and AJAX control parameters
|
|
961
|
+
form_data = form_data.dup
|
|
962
|
+
form_data["#{form_id}:sheetDate"] = date_str
|
|
963
|
+
form_data["#{form_id}:j_idt57_input"] = date_str
|
|
964
|
+
form_data["#{form_id}:showAllAreasOrTrainers"] = 'true' # Show all courts!
|
|
965
|
+
|
|
966
|
+
form_data.merge!(
|
|
967
|
+
'javax.faces.partial.ajax' => 'true',
|
|
968
|
+
'javax.faces.source' => date_picker,
|
|
969
|
+
'javax.faces.partial.execute' => date_picker,
|
|
970
|
+
'javax.faces.partial.render' => form_id,
|
|
971
|
+
'javax.faces.behavior.event' => 'dateSelect',
|
|
972
|
+
'javax.faces.partial.event' => 'dateSelect',
|
|
973
|
+
form_id => form_id,
|
|
974
|
+
'javax.faces.encodedURL' => encoded_url,
|
|
975
|
+
'javax.faces.ViewState' => view_state
|
|
976
|
+
)
|
|
977
|
+
|
|
978
|
+
response = post_ajax(ajax_url, form_data)
|
|
979
|
+
if response.is_a?(Net::HTTPSuccess)
|
|
980
|
+
{ success: true, view_state: extract_view_state_from_ajax(response.body), body: response.body }
|
|
981
|
+
else
|
|
982
|
+
{ success: false, error: "HTTP #{response.code}" }
|
|
983
|
+
end
|
|
984
|
+
end
|
|
985
|
+
|
|
632
986
|
def select_slot_ajax(form_id, view_state, slot, components, form_fields)
|
|
633
987
|
source_id = components['showReservationScreen'] || "#{form_id}:j_idt146"
|
|
634
988
|
ajax_url = build_ajax_url
|
|
@@ -681,10 +1035,22 @@ module Truenorth
|
|
|
681
1035
|
|
|
682
1036
|
response = post_ajax(ajax_url, form_data)
|
|
683
1037
|
if response.is_a?(Net::HTTPSuccess)
|
|
684
|
-
body = response.body
|
|
685
|
-
|
|
1038
|
+
body = response.body
|
|
1039
|
+
body_lower = body.downcase
|
|
1040
|
+
|
|
1041
|
+
# Check for explicit error indicators
|
|
1042
|
+
if body_lower.include?('error') || body_lower.include?('failed') ||
|
|
1043
|
+
body_lower.include?('unable') || body_lower.include?('invalid')
|
|
1044
|
+
log "Booking save returned error indicators in response"
|
|
1045
|
+
{ success: false, error: 'Booking save failed' }
|
|
1046
|
+
# Check for success indicators OR assume success if reasonable response
|
|
1047
|
+
elsif body_lower.include?('success') || body_lower.include?('confirmed') ||
|
|
1048
|
+
body_lower.include?('booked') || body_lower.include?('reserved') ||
|
|
1049
|
+
(body.length < 5000 && !body_lower.include?('exception'))
|
|
1050
|
+
log "Booking save appears successful (HTTP 200)"
|
|
686
1051
|
{ success: true, confirmation: 'Booking confirmed' }
|
|
687
1052
|
else
|
|
1053
|
+
log "Uncertain booking result, response length: #{body.length}"
|
|
688
1054
|
{ success: false, error: 'No confirmation in response' }
|
|
689
1055
|
end
|
|
690
1056
|
else
|
data/lib/truenorth/config.rb
CHANGED
|
@@ -8,6 +8,7 @@ module Truenorth
|
|
|
8
8
|
CONFIG_DIR = File.expand_path('~/.config/truenorth')
|
|
9
9
|
CONFIG_FILE = File.join(CONFIG_DIR, 'config.yml')
|
|
10
10
|
CREDENTIALS_FILE = File.join(CONFIG_DIR, 'credentials.yml')
|
|
11
|
+
COOKIES_FILE = File.join(CONFIG_DIR, 'cookies.yml')
|
|
11
12
|
|
|
12
13
|
class << self
|
|
13
14
|
def load
|
|
@@ -62,8 +63,42 @@ module Truenorth
|
|
|
62
63
|
CONFIG_DIR
|
|
63
64
|
end
|
|
64
65
|
|
|
66
|
+
def cookies
|
|
67
|
+
load_cookies
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def save_cookies(cookies_hash)
|
|
71
|
+
FileUtils.mkdir_p(CONFIG_DIR)
|
|
72
|
+
File.write(COOKIES_FILE, YAML.dump({
|
|
73
|
+
'cookies' => cookies_hash,
|
|
74
|
+
'timestamp' => Time.now.to_i
|
|
75
|
+
}))
|
|
76
|
+
FileUtils.chmod(0o600, COOKIES_FILE)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def clear_cookies
|
|
80
|
+
File.delete(COOKIES_FILE) if File.exist?(COOKIES_FILE)
|
|
81
|
+
end
|
|
82
|
+
|
|
65
83
|
private
|
|
66
84
|
|
|
85
|
+
def load_cookies
|
|
86
|
+
return {} unless File.exist?(COOKIES_FILE)
|
|
87
|
+
|
|
88
|
+
data = YAML.safe_load(File.read(COOKIES_FILE)) || {}
|
|
89
|
+
timestamp = data['timestamp']
|
|
90
|
+
|
|
91
|
+
# Cookies expire after 24 hours
|
|
92
|
+
if timestamp && (Time.now.to_i - timestamp) < 86400
|
|
93
|
+
data['cookies'] || {}
|
|
94
|
+
else
|
|
95
|
+
clear_cookies
|
|
96
|
+
{}
|
|
97
|
+
end
|
|
98
|
+
rescue StandardError
|
|
99
|
+
{}
|
|
100
|
+
end
|
|
101
|
+
|
|
67
102
|
def load_config
|
|
68
103
|
return {} unless File.exist?(CONFIG_FILE)
|
|
69
104
|
|
data/lib/truenorth/version.rb
CHANGED
data/lib/truenorth.rb
CHANGED
data/truenorth.gemspec
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'lib/truenorth/version'
|
|
4
|
+
|
|
3
5
|
Gem::Specification.new do |spec|
|
|
4
6
|
spec.name = 'truenorth'
|
|
5
|
-
spec.version =
|
|
7
|
+
spec.version = Truenorth::VERSION
|
|
6
8
|
spec.authors = ['usiegj00']
|
|
7
9
|
spec.email = ['112138+usiegj00@users.noreply.github.com']
|
|
8
10
|
|
|
@@ -28,6 +30,7 @@ Gem::Specification.new do |spec|
|
|
|
28
30
|
spec.require_paths = ['lib']
|
|
29
31
|
|
|
30
32
|
spec.add_dependency 'base64', '~> 0.2'
|
|
33
|
+
spec.add_dependency 'ferrum', '~> 0.15'
|
|
31
34
|
spec.add_dependency 'nokogiri', '~> 1.15'
|
|
32
35
|
spec.add_dependency 'thor', '~> 1.3'
|
|
33
36
|
spec.add_dependency 'tty-table', '~> 0.12'
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: truenorth
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- usiegj00
|
|
@@ -24,6 +24,20 @@ dependencies:
|
|
|
24
24
|
- - "~>"
|
|
25
25
|
- !ruby/object:Gem::Version
|
|
26
26
|
version: '0.2'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: ferrum
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '0.15'
|
|
34
|
+
type: :runtime
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '0.15'
|
|
27
41
|
- !ruby/object:Gem::Dependency
|
|
28
42
|
name: nokogiri
|
|
29
43
|
requirement: !ruby/object:Gem::Requirement
|