truenorth 0.2.7 → 0.3.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/client.rb +238 -54
- data/lib/truenorth/version.rb +1 -1
- data/truenorth.gemspec +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8d89d28373a48164f8f6cf1f93672dd70d4f8aaf8035ae40bdc144fe76a8d6be
|
|
4
|
+
data.tar.gz: 266756fa35b43d17ad657bb11ab46d0411c63da6620bf630594a08ac7f4d5226
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e1c0d586aa1b9146bedb498c81036d1446f23a9f6ee92cae1d14887007227196986e4ed245511cd39a58375729fa55831174f1536721f5d57a39ca8db03983bb
|
|
7
|
+
data.tar.gz: a764f05fe8325d6457f03467ba1c3a169b9422b8bbaa8d2030264a0f8bf5bcacda0e346fd913c0d415880f26d1f6dde03c6ef3fd58525c80e8e5ef2153b862c3
|
data/lib/truenorth/client.rb
CHANGED
|
@@ -5,6 +5,7 @@ require 'uri'
|
|
|
5
5
|
require 'nokogiri'
|
|
6
6
|
require 'json'
|
|
7
7
|
require 'base64'
|
|
8
|
+
require 'date'
|
|
8
9
|
|
|
9
10
|
module Truenorth
|
|
10
11
|
# HTTP client for NorthStar facility booking systems
|
|
@@ -109,17 +110,24 @@ module Truenorth
|
|
|
109
110
|
response = get(BOOKING_PATH)
|
|
110
111
|
html = Nokogiri::HTML(response.body)
|
|
111
112
|
|
|
112
|
-
#
|
|
113
|
+
# Navigate to the requested date first if needed
|
|
114
|
+
current_date = html.at_css('input[name*="sheetDate"]')&.[]('value')
|
|
115
|
+
requested_date = date.strftime('%m/%d/%Y')
|
|
113
116
|
activity_id = ACTIVITIES[activity.to_s.downcase] || '5'
|
|
117
|
+
|
|
118
|
+
if current_date != requested_date
|
|
119
|
+
log "Navigating from #{current_date} to #{requested_date}"
|
|
120
|
+
html = change_date(html, requested_date, activity_id)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Then change activity type - this should now return slots for the correct date
|
|
114
124
|
current_activity = html.at_css('input[name*="activityId"]')&.[]('value')
|
|
115
125
|
|
|
116
126
|
if current_activity && current_activity != activity_id
|
|
117
|
-
|
|
127
|
+
log "Changing activity from #{current_activity} to #{activity_id}"
|
|
118
128
|
html = change_activity(html, activity_id)
|
|
119
129
|
end
|
|
120
130
|
|
|
121
|
-
# TODO: Handle date navigation if needed
|
|
122
|
-
|
|
123
131
|
slots = parse_slots(html)
|
|
124
132
|
log "Found #{slots.count} available time slots"
|
|
125
133
|
|
|
@@ -131,6 +139,7 @@ module Truenorth
|
|
|
131
139
|
}
|
|
132
140
|
end
|
|
133
141
|
|
|
142
|
+
|
|
134
143
|
# Book a slot
|
|
135
144
|
def book(time, date: Date.today, court: nil, activity: 'squash', dry_run: false)
|
|
136
145
|
ensure_logged_in!
|
|
@@ -283,7 +292,8 @@ module Truenorth
|
|
|
283
292
|
|
|
284
293
|
raise BookingError, 'Could not extract view state' unless view_state
|
|
285
294
|
|
|
286
|
-
#
|
|
295
|
+
# Step 1: Click the cancel button to open the confirmation dialog
|
|
296
|
+
# The dialog button will be enabled in the AJAX response
|
|
287
297
|
ajax_url = "#{@base_url}#{RESERVATIONS_PATH}?p_p_id=memberReservations_WAR_northstarportlet" \
|
|
288
298
|
'&p_p_lifecycle=2&p_p_state=normal&p_p_mode=view' \
|
|
289
299
|
'&p_p_cacheability=cacheLevelPage' \
|
|
@@ -301,50 +311,98 @@ module Truenorth
|
|
|
301
311
|
|
|
302
312
|
response = post_ajax(ajax_url, form_data)
|
|
303
313
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
]
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
314
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
315
|
+
return { success: false, error: "HTTP #{response.code}" }
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
body = response.body
|
|
319
|
+
log "Step 1 response length: #{body.length}"
|
|
320
|
+
|
|
321
|
+
# Check if a confirmation dialog was opened
|
|
322
|
+
if body.include?("PF('cancelReservationDialog').show()") || body.include?('.show()')
|
|
323
|
+
log "Confirmation dialog opened"
|
|
324
|
+
|
|
325
|
+
# Extract the updated ViewState from the AJAX response
|
|
326
|
+
new_view_state = extract_view_state_from_ajax(body) || view_state
|
|
327
|
+
|
|
328
|
+
# Parse the AJAX response to find the enabled YES button
|
|
329
|
+
response_html = Nokogiri::HTML(body)
|
|
330
|
+
|
|
331
|
+
# Look for the YES button in the dialog (not in the reservation list!)
|
|
332
|
+
# The YES button has specific characteristics:
|
|
333
|
+
# 1. Contains text "YES"
|
|
334
|
+
# 2. Has class "ui-area-btn-danger"
|
|
335
|
+
# 3. Is inside the cancel dialog (j_idt274)
|
|
336
|
+
confirm_button = response_html.css('div[id*="j_idt274"] a.ui-area-btn-danger').find do |link|
|
|
337
|
+
link.text.strip.upcase.include?('YES')
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
# Fallback: look for any YES button with the right classes
|
|
341
|
+
unless confirm_button
|
|
342
|
+
confirm_button = response_html.css('a.ui-commandlink').find do |link|
|
|
343
|
+
link_text = link.text.strip.upcase
|
|
344
|
+
link_class = link['class'].to_s
|
|
345
|
+
link_text == 'YES' && link_class.include?('ui-area-btn-danger') &&
|
|
346
|
+
!link_class.include?('disabled')
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
unless confirm_button
|
|
351
|
+
log "Could not find enabled YES button in response"
|
|
352
|
+
log "Response preview: #{body[0..2000]}"
|
|
353
|
+
return { success: false, error: 'Could not find confirmation button' }
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
confirm_button_id = confirm_button['id']
|
|
357
|
+
log "Found confirmation button in response: #{confirm_button_id}"
|
|
358
|
+
|
|
359
|
+
# Step 2: Click the "Yes" button to actually cancel
|
|
360
|
+
confirm_data = {
|
|
361
|
+
'javax.faces.partial.ajax' => 'true',
|
|
362
|
+
'javax.faces.source' => confirm_button_id,
|
|
363
|
+
'javax.faces.partial.execute' => '@all',
|
|
364
|
+
'javax.faces.partial.render' => form_id,
|
|
365
|
+
form_id => form_id,
|
|
366
|
+
'javax.faces.ViewState' => new_view_state,
|
|
367
|
+
confirm_button_id => confirm_button_id
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
log "Clicking confirmation button: #{confirm_button_id}"
|
|
371
|
+
confirm_response = post_ajax(ajax_url, confirm_data)
|
|
372
|
+
|
|
373
|
+
if confirm_response.is_a?(Net::HTTPSuccess)
|
|
374
|
+
confirm_body = confirm_response.body
|
|
375
|
+
log "Step 2 response length: #{confirm_body.length}"
|
|
376
|
+
log "Step 2 response preview: #{confirm_body[0..500]}"
|
|
377
|
+
|
|
378
|
+
# Check for success indicators
|
|
379
|
+
if confirm_body =~ /cancelled.*successfully/i ||
|
|
380
|
+
confirm_body =~ /reservation.*cancelled/i ||
|
|
381
|
+
confirm_body =~ /successfully.*cancelled/i ||
|
|
382
|
+
confirm_body.include?('growl') ||
|
|
383
|
+
confirm_body.length < 1000
|
|
384
|
+
log 'Cancellation confirmed successfully'
|
|
385
|
+
{ success: true, message: 'Reservation cancelled' }
|
|
386
|
+
else
|
|
387
|
+
log "Warning: Uncertain confirmation response"
|
|
388
|
+
{ success: true, message: 'Cancellation likely succeeded (please verify)' }
|
|
389
|
+
end
|
|
390
|
+
else
|
|
391
|
+
{ success: false, error: "Confirmation failed: HTTP #{confirm_response.code}" }
|
|
392
|
+
end
|
|
393
|
+
else
|
|
394
|
+
# No dialog - check if it was directly cancelled
|
|
395
|
+
log "No confirmation dialog detected"
|
|
396
|
+
|
|
397
|
+
if body =~ /cancelled.*successfully/i ||
|
|
398
|
+
body =~ /reservation.*cancelled/i ||
|
|
399
|
+
body.length < 500
|
|
400
|
+
log 'Direct cancellation successful'
|
|
339
401
|
{ success: true, message: 'Reservation cancelled' }
|
|
340
402
|
else
|
|
341
|
-
|
|
342
|
-
log "Ambiguous response (#{body.length} bytes)"
|
|
343
|
-
log "Full response: #{body}"
|
|
403
|
+
log "Uncertain result (#{body.length} bytes)"
|
|
344
404
|
{ success: false, error: 'Uncertain if cancellation succeeded - please verify' }
|
|
345
405
|
end
|
|
346
|
-
else
|
|
347
|
-
{ success: false, error: "HTTP #{response.code}" }
|
|
348
406
|
end
|
|
349
407
|
end
|
|
350
408
|
|
|
@@ -359,17 +417,58 @@ module Truenorth
|
|
|
359
417
|
def parse_slots(html)
|
|
360
418
|
slots = {}
|
|
361
419
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
420
|
+
# Find ALL slots with data-start-time
|
|
421
|
+
html.css('td.slot div[data-start-time]').each do |div|
|
|
422
|
+
td = div.parent
|
|
423
|
+
while td && td.name != 'td'
|
|
424
|
+
td = td.parent
|
|
425
|
+
end
|
|
426
|
+
next unless td
|
|
366
427
|
|
|
367
|
-
|
|
368
|
-
area_id = div['data-area-id']
|
|
369
|
-
court_name = COURTS[area_id] || "Court #{area_id}"
|
|
428
|
+
classes = td['class'].to_s
|
|
370
429
|
|
|
371
|
-
|
|
372
|
-
|
|
430
|
+
# Skip definitively unavailable slots
|
|
431
|
+
next if classes.include?('reserved')
|
|
432
|
+
next if classes.include?('restrict')
|
|
433
|
+
next if classes.include?('blocked')
|
|
434
|
+
|
|
435
|
+
# Include ANY slot that's marked as "open", even if it has past-time
|
|
436
|
+
# The website shows past-time slots for future dates as bookable
|
|
437
|
+
if classes.include?('open')
|
|
438
|
+
start_time = div['data-start-time']
|
|
439
|
+
area_id = div['data-area-id']
|
|
440
|
+
court_name = COURTS[area_id] || "Court #{area_id}"
|
|
441
|
+
|
|
442
|
+
slots[start_time] ||= []
|
|
443
|
+
slots[start_time] << court_name unless slots[start_time].include?(court_name)
|
|
444
|
+
end
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
# If we found very few slots, be more aggressive
|
|
448
|
+
if slots.length < 10
|
|
449
|
+
log "Found only #{slots.length} slots with strict parsing, trying relaxed parsing..."
|
|
450
|
+
|
|
451
|
+
# Try including ALL non-reserved slots
|
|
452
|
+
html.css('td.slot div[data-start-time]').each do |div|
|
|
453
|
+
td = div.parent
|
|
454
|
+
while td && td.name != 'td'
|
|
455
|
+
td = td.parent
|
|
456
|
+
end
|
|
457
|
+
next unless td
|
|
458
|
+
|
|
459
|
+
classes = td['class'].to_s
|
|
460
|
+
# Only skip if explicitly reserved/restricted/blocked
|
|
461
|
+
next if classes.include?('reserved') && !classes.include?('open')
|
|
462
|
+
next if classes.include?('restrict')
|
|
463
|
+
next if classes.include?('blocked')
|
|
464
|
+
|
|
465
|
+
start_time = div['data-start-time']
|
|
466
|
+
area_id = div['data-area-id']
|
|
467
|
+
court_name = COURTS[area_id] || "Court #{area_id}"
|
|
468
|
+
|
|
469
|
+
slots[start_time] ||= []
|
|
470
|
+
slots[start_time] << court_name unless slots[start_time].include?(court_name)
|
|
471
|
+
end
|
|
373
472
|
end
|
|
374
473
|
|
|
375
474
|
slots
|
|
@@ -494,6 +593,58 @@ module Truenorth
|
|
|
494
593
|
Nokogiri::HTML(result[:body])
|
|
495
594
|
end
|
|
496
595
|
|
|
596
|
+
def change_date(html, date_str, activity_id = nil)
|
|
597
|
+
# Extract what we can from the HTML
|
|
598
|
+
form_id = extract_form_id(html)
|
|
599
|
+
view_state = extract_view_state(html)
|
|
600
|
+
|
|
601
|
+
# Build minimal form data manually to avoid extraction issues
|
|
602
|
+
form_data = build_minimal_form_data(html, form_id, activity_id)
|
|
603
|
+
|
|
604
|
+
result = change_date_ajax(form_id, view_state, date_str, form_data)
|
|
605
|
+
return html unless result[:success]
|
|
606
|
+
|
|
607
|
+
# Parse the AJAX response
|
|
608
|
+
ajax_body = result[:body]
|
|
609
|
+
cdata_content = ajax_body.scan(/<!\[CDATA\[(.*?)\]\]>/m).flatten.join("\n")
|
|
610
|
+
|
|
611
|
+
if !cdata_content.empty? && cdata_content.include?('slot')
|
|
612
|
+
Nokogiri::HTML(cdata_content)
|
|
613
|
+
else
|
|
614
|
+
html
|
|
615
|
+
end
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
def build_minimal_form_data(html, form_id, activity_id)
|
|
619
|
+
# Build the minimum required form data from scratch
|
|
620
|
+
# Only extract the fields we absolutely need
|
|
621
|
+
data = {}
|
|
622
|
+
|
|
623
|
+
# Try to find the form
|
|
624
|
+
form = html.at_css("form[id='#{form_id}']")
|
|
625
|
+
return data unless form
|
|
626
|
+
|
|
627
|
+
# Extract only the essential hidden fields
|
|
628
|
+
form.css('input[type="hidden"]').each do |input|
|
|
629
|
+
name = input['name']
|
|
630
|
+
value = input['value']
|
|
631
|
+
next unless name
|
|
632
|
+
|
|
633
|
+
# Only keep fields that belong to our form
|
|
634
|
+
if name.to_s.start_with?(form_id)
|
|
635
|
+
data[name] = value || ''
|
|
636
|
+
end
|
|
637
|
+
end
|
|
638
|
+
|
|
639
|
+
# Override activity ID if provided
|
|
640
|
+
if activity_id
|
|
641
|
+
data["#{form_id}:activityId"] = activity_id.to_s
|
|
642
|
+
data["#{form_id}:j_idt51_input"] = activity_id.to_s
|
|
643
|
+
end
|
|
644
|
+
|
|
645
|
+
data
|
|
646
|
+
end
|
|
647
|
+
|
|
497
648
|
def extract_view_state(html)
|
|
498
649
|
html.at_css('input[name="javax.faces.ViewState"]')&.[]('value')
|
|
499
650
|
end
|
|
@@ -579,6 +730,39 @@ module Truenorth
|
|
|
579
730
|
end
|
|
580
731
|
end
|
|
581
732
|
|
|
733
|
+
def change_date_ajax(form_id, view_state, date_str, form_data)
|
|
734
|
+
return { success: false, error: 'No form_id' } unless form_id
|
|
735
|
+
|
|
736
|
+
date_picker = "#{form_id}:j_idt57"
|
|
737
|
+
ajax_url = build_ajax_url
|
|
738
|
+
encoded_url = URI.encode_www_form_component(ajax_url)
|
|
739
|
+
|
|
740
|
+
# Use form_data as-is (already built correctly by build_minimal_form_data)
|
|
741
|
+
# Just add our date values and AJAX control parameters
|
|
742
|
+
form_data = form_data.dup
|
|
743
|
+
form_data["#{form_id}:sheetDate"] = date_str
|
|
744
|
+
form_data["#{form_id}:j_idt57_input"] = date_str
|
|
745
|
+
|
|
746
|
+
form_data.merge!(
|
|
747
|
+
'javax.faces.partial.ajax' => 'true',
|
|
748
|
+
'javax.faces.source' => date_picker,
|
|
749
|
+
'javax.faces.partial.execute' => date_picker,
|
|
750
|
+
'javax.faces.partial.render' => form_id,
|
|
751
|
+
'javax.faces.behavior.event' => 'dateSelect',
|
|
752
|
+
'javax.faces.partial.event' => 'dateSelect',
|
|
753
|
+
form_id => form_id,
|
|
754
|
+
'javax.faces.encodedURL' => encoded_url,
|
|
755
|
+
'javax.faces.ViewState' => view_state
|
|
756
|
+
)
|
|
757
|
+
|
|
758
|
+
response = post_ajax(ajax_url, form_data)
|
|
759
|
+
if response.is_a?(Net::HTTPSuccess)
|
|
760
|
+
{ success: true, view_state: extract_view_state_from_ajax(response.body), body: response.body }
|
|
761
|
+
else
|
|
762
|
+
{ success: false, error: "HTTP #{response.code}" }
|
|
763
|
+
end
|
|
764
|
+
end
|
|
765
|
+
|
|
582
766
|
def select_slot_ajax(form_id, view_state, slot, components, form_fields)
|
|
583
767
|
source_id = components['showReservationScreen'] || "#{form_id}:j_idt146"
|
|
584
768
|
ajax_url = build_ajax_url
|
data/lib/truenorth/version.rb
CHANGED
data/truenorth.gemspec
CHANGED