1+ # app/services/leopard_web_service.rb
2+ class LeopardWebService < ApplicationService
3+ require "faraday"
4+ require "nokogiri"
5+
6+ BASE_URL = "https://selfservice.wit.edu/StudentRegistrationSsb/ssb/searchResults/" . freeze
7+
8+ attr_reader :term , :course_reference_number , :action
9+
10+ def initialize ( action :, term : nil , course_reference_number : nil )
11+ @action = action
12+ @term = term
13+ @course_reference_number = course_reference_number
14+ super ( )
15+ end
16+
17+ def call
18+ case action
19+ when :get_class_details
20+ get_class_details
21+ when :get_enrollment_info
22+ get_enrollment_info
23+ when :get_faculty_meeting_times
24+ get_faculty_meeting_times
25+ else
26+ raise ArgumentError , "Unknown action: #{ action } "
27+ end
28+ end
29+
30+ def call!
31+ call
32+ end
33+
34+ # Class method convenience wrappers
35+ def self . get_class_details ( term :, course_reference_number :)
36+ new (
37+ action : :get_class_details ,
38+ term : term ,
39+ course_reference_number : course_reference_number
40+ ) . call
41+ end
42+
43+ def self . get_enrollment_info
44+ new ( action : :get_enrollment_info ) . call
45+ end
46+
47+ def self . get_faculty_meeting_times ( term :, course_reference_number :)
48+ new (
49+ action : :get_faculty_meeting_times ,
50+ term : term ,
51+ course_reference_number : course_reference_number
52+ ) . call
53+ end
54+
55+ private
56+
57+ def get_class_details
58+ raise ArgumentError , "term is required" unless term
59+ raise ArgumentError , "course_reference_number is required" unless course_reference_number
60+
61+ response = connection . get ( "getClassDetails" , {
62+ term : term ,
63+ courseReferenceNumber : course_reference_number
64+ } )
65+
66+ handle_response ( response , :class_details )
67+ end
68+
69+ def get_enrollment_info
70+ response = connection . get ( "getEnrollmentInfo" )
71+ handle_response ( response , :enrollment_info )
72+ end
73+
74+ def get_faculty_meeting_times
75+ raise ArgumentError , "term is required" unless term
76+ raise ArgumentError , "course_reference_number is required" unless course_reference_number
77+
78+ response = connection . get ( "getFacultyMeetingTimes" , {
79+ term : term ,
80+ courseReferenceNumber : course_reference_number
81+ } )
82+
83+ handle_response ( response , :json )
84+ end
85+
86+ def connection
87+ @connection ||= Faraday . new ( url : BASE_URL ) do |faraday |
88+ faraday . request :url_encoded
89+ faraday . response :json , content_type : /\b json$/
90+ faraday . adapter Faraday . default_adapter
91+ end
92+ end
93+
94+ def handle_response ( response , format = :json )
95+ if response . success?
96+ case format
97+ when :class_details
98+ parse_class_details_html ( response . body )
99+ when :enrollment_info
100+ parse_enrollment_info_html ( response . body )
101+ when :json
102+ parse_json_response ( response . body )
103+ else
104+ parse_response_body ( response . body )
105+ end
106+ else
107+ handle_error ( response )
108+ end
109+ end
110+
111+ def parse_response_body ( body )
112+ body
113+ end
114+
115+ def parse_json_response ( body )
116+ # If Faraday has already parsed it, return as-is
117+ # Otherwise, parse it
118+ body . is_a? ( String ) ? JSON . parse ( body ) : body
119+ end
120+
121+ def parse_class_details_html ( html )
122+ doc = Nokogiri ::HTML ( html )
123+ section = doc . at_css ( 'section[aria-labelledby="classDetails"]' )
124+
125+ return nil unless section
126+
127+ {
128+ associated_term : extract_labeled_value ( section , "Associated Term:" ) ,
129+ crn : section . at_css ( '#courseReferenceNumber' ) . text . strip ,
130+ campus : extract_labeled_value ( section , "Campus:" ) ,
131+ schedule_type : extract_labeled_value ( section , "Schedule Type:" ) ,
132+ section_number : section . at_css ( '#sectionNumber' ) . text . strip ,
133+ subject : section . at_css ( '#subject' ) . text . strip ,
134+ course_number : section . at_css ( '#courseNumber' ) . text . strip ,
135+ title : section . at_css ( '#courseTitle' ) . text . strip ,
136+ credit_hours : extract_labeled_value ( section , "Credit Hours:" ) &.to_i ,
137+ grade_mode : extract_labeled_value ( section , "Grade Mode:" )
138+ }
139+ end
140+
141+ def parse_enrollment_info_html ( html )
142+ doc = Nokogiri ::HTML ( html )
143+ section = doc . at_css ( 'section[aria-labelledby="enrollmentInfo"]' )
144+
145+ return nil unless section
146+
147+ {
148+ enrollment : {
149+ actual : extract_span_value ( section , "Enrollment Actual:" ) &.to_i ,
150+ maximum : extract_span_value ( section , "Enrollment Maximum:" ) &.to_i ,
151+ seats_available : extract_span_value ( section , "Enrollment Seats Available:" ) &.to_i
152+ } ,
153+ waitlist : {
154+ capacity : extract_span_value ( section , "Waitlist Capacity:" ) &.to_i ,
155+ actual : extract_span_value ( section , "Waitlist Actual:" ) &.to_i ,
156+ seats_available : extract_span_value ( section , "Waitlist Seats Available:" ) &.to_i
157+ }
158+ }
159+ end
160+
161+ def extract_labeled_value ( section , label )
162+ # Find the span with the label text
163+ label_span = section . xpath ( ".//span[@class='status-bold'][contains(text(), '#{ label } ')]" ) . first
164+ return nil unless label_span
165+
166+ # Get the text that comes after the label span and before the next <br> or <span>
167+ next_node = label_span . next_sibling
168+ value = ""
169+
170+ while next_node && next_node . name != "br" && next_node . name != "span"
171+ value += next_node . text if next_node . text?
172+ next_node = next_node . next_sibling
173+ end
174+
175+ value . strip . empty? ? nil : value . strip
176+ end
177+
178+ def extract_span_value ( section , label )
179+ # Find the bold label span
180+ label_span = section . xpath ( ".//span[@class='status-bold'][contains(text(), '#{ label } ')]" ) . first
181+ return nil unless label_span
182+
183+ # Find the next span with dir="ltr" which contains the value
184+ value_span = label_span . xpath ( "following-sibling::span[@dir='ltr'][1]" ) . first
185+ return nil unless value_span
186+
187+ value_span . text . strip
188+ end
189+
190+ def handle_error ( response )
191+ raise "Request failed with status #{ response . status } : #{ response . body } "
192+ end
193+ end
0 commit comments