scraping.refresh_registration
@file refresh_registration.py @brief Live scraper that refreshes QCC registration data from The Q portal.
@details This module is the primary live scraping tool for the QCC registration data pipeline. It uses Playwright to automate a headless Chromium browser, iterates through all departments on The Q portal, scrapes all course sections for the current term, saves the results to registration_sections.json, and upserts them into registration.db.
This script handles the full refresh cycle in three steps: 1. Scrape all departments from The Q portal using Playwright 2. Save results to registration_sections.json 3. Upsert results into registration.db (insert new, update existing)
For parsing a pre-saved HTML snapshot instead of live scraping, see scrape_registration_sections.py.
@date 2026
@par Configuration - SEARCH_URL : URL of The Q course offerings search page - TERM : Academic term label to select (e.g. "Spring 2026") - JSON_FILE : Output path for the JSON file - DB_FILE : Path to the SQLite database file - HEADLESS : Run browser in headless mode (True/False)
@par Dependencies - playwright (pip install playwright && playwright install chromium) - beautifulsoup4 - sqlite3 (stdlib) - asyncio (stdlib)
@par Usage python refresh_registration.py
1# ============================================================================= 2# 3# Copyright 2026 4# 5# Author: Noel Mensah 6# GitHub: https://github.com/LSilver17/CSC212---AI-Agent 7# 8# ============================================================================= 9 10 11""" 12@file refresh_registration.py 13@brief Live scraper that refreshes QCC registration data from The Q portal. 14 15@details 16This module is the primary live scraping tool for the QCC registration data pipeline. 17It uses Playwright to automate a headless Chromium browser, iterates through all 18departments on The Q portal, scrapes all course sections for the current term, 19saves the results to registration_sections.json, and upserts them into registration.db. 20 21This script handles the full refresh cycle in three steps: 22 1. Scrape all departments from The Q portal using Playwright 23 2. Save results to registration_sections.json 24 3. Upsert results into registration.db (insert new, update existing) 25 26For parsing a pre-saved HTML snapshot instead of live scraping, 27see scrape_registration_sections.py. 28 29@date 2026 30 31@par Configuration 32 - SEARCH_URL : URL of The Q course offerings search page 33 - TERM : Academic term label to select (e.g. "Spring 2026") 34 - JSON_FILE : Output path for the JSON file 35 - DB_FILE : Path to the SQLite database file 36 - HEADLESS : Run browser in headless mode (True/False) 37 38@par Dependencies 39 - playwright (pip install playwright && playwright install chromium) 40 - beautifulsoup4 41 - sqlite3 (stdlib) 42 - asyncio (stdlib) 43 44@par Usage 45 python refresh_registration.py 46""" 47 48import json 49import re 50import sqlite3 51import asyncio 52import os 53from datetime import datetime 54from bs4 import BeautifulSoup 55from playwright.async_api import async_playwright 56 57## @brief URL of The Q portal's advanced course search page. 58SEARCH_URL = "https://theq.qcc.edu/ICS/Course_Offerings_and_Schedule.jnz?portlet=AddDrop_Courses&screen=Advanced+Course+Search&screenType=next" 59 60## @brief Academic term label to select from the portal dropdown. 61TERM = "Spring 2026" 62 63## @brief Output path for the scraped JSON file. 64JSON_FILE = "registration_sections.json" 65 66## @brief Path to the SQLite database file. 67DB_FILE = "registration.db" 68 69## @brief Whether to run the browser in headless (no UI) mode. 70HEADLESS = True 71 72 73# ── Parsing helpers ─────────────────────────────────────── 74 75def clean_text(text): 76 """ 77 @brief Removes excess whitespace from a string. 78 79 @param text The raw string to clean. 80 @return A normalized single-line string with no extra whitespace. 81 82 @par Example 83 @code 84 clean_text(" ACC 101 ") -> "ACC 101" 85 @endcode 86 """ 87 return " ".join(text.split()).strip() 88 89 90def parse_seats(s): 91 """ 92 @brief Parses a seat availability string into open and total seat counts. 93 94 @details 95 Handles both standard slash "/" and Unicode division slash "∕" formats 96 used by The Q portal (e.g. "1 / 24" or "1 ∕ 24"). 97 98 @param s The raw seats string from the registration table. 99 @return A dictionary with keys "open" (int or None) and "total" (int or None). 100 101 @par Example 102 @code 103 parse_seats("1 ∕ 24") -> {"open": 1, "total": 24} 104 parse_seats("") -> {"open": None, "total": None} 105 @endcode 106 """ 107 m = re.search("(\d+)\s*[/∕]\s*(\d+)", s) 108 return {"open": int(m.group(1)), "total": int(m.group(2))} if m else {"open": None, "total": None} 109 110 111def parse_details(s): 112 """ 113 @brief Parses the details column into instructor, schedule, location, and method. 114 115 @details 116 The details column combines multiple fields into a slash-delimited string. 117 Expected format: "Instructor / Days Time; Location / Method" 118 119 @param s The raw details string from the registration table. 120 @return A dictionary with keys: 121 - "instructor" (str or None) 122 - "days_time" (str or None): e.g. "MW 08:00-09:15AM" 123 - "location" (str or None): e.g. "MAIN Campus, Room 312" 124 - "method" (str or None): e.g. "Lecture" 125 """ 126 result = {"instructor": None, "days_time": None, "location": None, "method": None} 127 parts = [p.strip() for p in s.split("/")] 128 if len(parts) >= 1: 129 result["instructor"] = clean_text(parts[0]) 130 if len(parts) >= 2: 131 result["days_time"] = clean_text(parts[1].split(";")[0]) 132 if ";" in parts[1]: 133 result["location"] = clean_text(parts[1].split(";")[1]) 134 if len(parts) >= 3: 135 result["method"] = clean_text(parts[2]) 136 return result 137 138 139def parse_course_code(code): 140 """ 141 @brief Splits a full course code string into department, number, and section. 142 143 @param code The full course code string (e.g. "ACC 101-01"). 144 @return A dictionary with keys "department", "number", and "section", 145 all str or None if the format does not match. 146 147 @par Example 148 @code 149 parse_course_code("ACC 101-01") -> {"department": "ACC", "number": "101", "section": "01"} 150 @endcode 151 """ 152 m = re.match("([A-Z]+)\s+(\d+)-(\w+)", code) 153 return {"department": m.group(1), "number": m.group(2), "section": m.group(3)} if m else {"department": None, "number": None, "section": None} 154 155 156def parse_html(html): 157 """ 158 @brief Parses raw HTML from The Q portal and extracts course section data. 159 160 @details 161 Uses BeautifulSoup to find all table rows, identifies rows containing a 162 valid course code (matching "DEPT NUM-SECTION"), and extracts all course 163 fields using the confirmed 13-column layout of The Q portal. 164 165 Column layout (auto-detected by course code position): 166 - [idx+0] course_code 167 - [idx+1] name 168 - [idx+4] seats 169 - [idx+5] status 170 - [idx+6] details (instructor/schedule/method) 171 - [idx+7] credits 172 - [idx+8] begin_date 173 - [idx+9] end_date 174 175 @param html Raw HTML string from the browser page content. 176 @return A list of course section dictionaries with all extracted fields. 177 """ 178 soup = BeautifulSoup(html, "html.parser") 179 courses = [] 180 for row in soup.select("table tr"): 181 cols = row.find_all(["td", "th"]) 182 if len(cols) < 4: 183 continue 184 values = [clean_text(col.get_text(" ", strip=True)) for col in cols] 185 joined = " | ".join(values).lower() 186 if "course code" in joined and "name" in joined: 187 continue 188 if all(not v for v in values): 189 continue 190 idx = next((i for i, v in enumerate(values) if re.match(r"[A-Z]{2,4}\s+\d{3}-\w+", v)), None) 191 if idx is None: 192 continue 193 try: 194 code_str = values[idx] 195 seats = parse_seats(values[idx + 4]) 196 details = parse_details(values[idx + 6]) 197 code_map = parse_course_code(code_str) 198 courses.append({ 199 "course_code": code_str, 200 "department": code_map["department"], 201 "course_number": code_map["number"], 202 "section": code_map["section"], 203 "name": values[idx + 1], 204 "status": values[idx + 5], 205 "seats_open": seats["open"], 206 "seats_total": seats["total"], 207 "credits": values[idx + 7], 208 "instructor": details["instructor"], 209 "days_time": details["days_time"], 210 "location": details["location"], 211 "method": details["method"], 212 "begin_date": values[idx + 8] if len(values) > idx + 8 else None, 213 "end_date": values[idx + 9] if len(values) > idx + 9 else None, 214 "scraped_at": datetime.now().isoformat(), 215 }) 216 except IndexError: 217 continue 218 return courses 219 220 221# ── Scraper ─────────────────────────────────────────────── 222 223async def scrape(): 224 """ 225 @brief Asynchronously scrapes all course sections from The Q portal. 226 227 @details 228 Launches a headless Chromium browser using Playwright, navigates to The Q 229 portal's course search page, retrieves all department options from the 230 dropdown, and iterates through each department to collect course sections. 231 232 Each department is attempted up to 3 times with a 2-second delay between 233 retries to handle intermittent portal timeouts. 234 235 @return A list of all course section dictionaries scraped across all departments. 236 237 @note Requires Playwright and Chromium to be installed: 238 pip install playwright && playwright install chromium 239 """ 240 all_courses = [] 241 242 async with async_playwright() as p: 243 browser = await p.chromium.launch(headless=HEADLESS) 244 page = await browser.new_page() 245 246 print("Loading QCC course search page...") 247 await page.goto(SEARCH_URL) 248 await page.wait_for_load_state("networkidle") 249 250 dept_select = await page.query_selector("#pg0_V_ddlDept") 251 if not dept_select: 252 print("❌ Could not find department dropdown. Check if page loaded correctly.") 253 await browser.close() 254 return [] 255 256 dept_options = await dept_select.query_selector_all("option") 257 departments = [] 258 for opt in dept_options: 259 value = await opt.get_attribute("value") 260 label = (await opt.inner_text()).strip() 261 if label.lower() != "all" and label: 262 departments.append((value, label)) 263 264 print(f"Found {len(departments)} departments.\n") 265 266 for i, (value, label) in enumerate(departments): 267 print(f"[{i+1}/{len(departments)}] Scraping: {label}...") 268 for attempt in range(3): 269 try: 270 await page.goto(SEARCH_URL, timeout=60000) 271 await page.wait_for_load_state("networkidle", timeout=60000) 272 await page.wait_for_selector("#pg0_V_ddlDept", timeout=60000) 273 await page.select_option("#pg0_V_ddlTerm", label=TERM) 274 await page.select_option("#pg0_V_ddlDept", value=value) 275 await page.click("#pg0_V_btnSearch") 276 await page.wait_for_load_state("networkidle", timeout=60000) 277 courses = parse_html(await page.content()) 278 all_courses.extend(courses) 279 print(f" → {len(courses)} sections") 280 break 281 except Exception as e: 282 if attempt < 2: 283 print(f" ⚠ Attempt {attempt+1} failed, retrying...") 284 await asyncio.sleep(2) 285 else: 286 print(f" ✗ Failed after 3 attempts: {e}") 287 288 await browser.close() 289 290 return all_courses 291 292 293# ── Database update ─────────────────────────────────────── 294 295def update_db(courses): 296 """ 297 @brief Creates or updates registration.db with the scraped course data. 298 299 @details 300 Connects to (or creates) the SQLite database at DB_FILE. Creates the 301 courses table and indexes if they do not exist. Upserts each course 302 record — inserting new records and updating existing ones on conflict 303 with the unique course_code constraint. 304 305 The following indexes are created for query performance: 306 - idx_department on courses(department) 307 - idx_course_code on courses(course_code) 308 - idx_status on courses(status) 309 - idx_instructor on courses(instructor) 310 311 @param courses A list of course section dictionaries to upsert. 312 @return A tuple (inserted, errors, total) where: 313 - inserted (int): number of records successfully processed 314 - errors (int): number of records that failed 315 - total (int): total records now in the database 316 """ 317 if not os.path.exists(DB_FILE): 318 print(f"\n⚠️ {DB_FILE} not found — creating it now...") 319 320 conn = sqlite3.connect(DB_FILE) 321 cursor = conn.cursor() 322 323 cursor.executescript(""" 324 CREATE TABLE IF NOT EXISTS courses ( 325 id INTEGER PRIMARY KEY AUTOINCREMENT, 326 course_code TEXT NOT NULL, 327 department TEXT, 328 course_number TEXT, 329 section TEXT, 330 name TEXT, 331 status TEXT, 332 seats_open INTEGER, 333 seats_total INTEGER, 334 credits TEXT, 335 instructor TEXT, 336 days_time TEXT, 337 location TEXT, 338 method TEXT, 339 begin_date TEXT, 340 end_date TEXT, 341 scraped_at TEXT, 342 UNIQUE(course_code) 343 ); 344 CREATE INDEX IF NOT EXISTS idx_department ON courses(department); 345 CREATE INDEX IF NOT EXISTS idx_course_code ON courses(course_code); 346 CREATE INDEX IF NOT EXISTS idx_status ON courses(status); 347 CREATE INDEX IF NOT EXISTS idx_instructor ON courses(instructor); 348 """) 349 350 inserted = errors = 0 351 for c in courses: 352 try: 353 cursor.execute(""" 354 INSERT INTO courses ( 355 course_code, department, course_number, section, 356 name, status, seats_open, seats_total, credits, 357 instructor, days_time, location, method, 358 begin_date, end_date, scraped_at 359 ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) 360 ON CONFLICT(course_code) DO UPDATE SET 361 status = excluded.status, 362 seats_open = excluded.seats_open, 363 seats_total = excluded.seats_total, 364 instructor = excluded.instructor, 365 days_time = excluded.days_time, 366 location = excluded.location, 367 method = excluded.method, 368 begin_date = excluded.begin_date, 369 end_date = excluded.end_date, 370 scraped_at = excluded.scraped_at 371 """, ( 372 c.get("course_code"), c.get("department"), c.get("course_number"), 373 c.get("section"), c.get("name"), c.get("status"), 374 c.get("seats_open"), c.get("seats_total"), c.get("credits"), 375 c.get("instructor"), c.get("days_time"), c.get("location"), 376 c.get("method"), c.get("begin_date"), c.get("end_date"), 377 c.get("scraped_at"), 378 )) 379 inserted += 1 380 except sqlite3.Error as e: 381 print(f" DB error for {c.get('course_code')}: {e}") 382 errors += 1 383 384 conn.commit() 385 total = conn.execute("SELECT COUNT(*) FROM courses").fetchone()[0] 386 conn.close() 387 return inserted, errors, total 388 389 390# ── Main ────────────────────────────────────────────────── 391 392async def main(): 393 """ 394 @brief Entry point — runs the full three-step registration refresh pipeline. 395 396 @details 397 Executes the pipeline in sequence: 398 1. Scrapes all departments from The Q portal via scrape() 399 2. Saves results to registration_sections.json 400 3. Upserts results into registration.db via update_db() 401 402 Prints a summary on completion including total sections scraped, 403 database record count, error count, and elapsed time. 404 """ 405 start = datetime.now() 406 print("=" * 50) 407 print(" QCC Registration Refresh") 408 print(f" {start.strftime('%Y-%m-%d %H:%M:%S')}") 409 print("=" * 50) 410 411 print("\n[1/3] Scraping QCC course offerings...") 412 courses = await scrape() 413 print(f"\n Total scraped: {len(courses)} sections") 414 415 print("\n[2/3] Saving to JSON...") 416 with open(JSON_FILE, "w", encoding="utf-8") as f: 417 json.dump(courses, f, indent=2) 418 print(f" Saved {len(courses)} sections to {JSON_FILE}") 419 420 print("\n[3/3] Updating database...") 421 inserted, errors, total = update_db(courses) 422 print(f" Processed: {len(courses)} | Total in DB: {total} | Errors: {errors}") 423 424 elapsed = (datetime.now() - start).seconds 425 print(f"\n✅ Done in {elapsed}s — {total} courses in database.") 426 print("=" * 50) 427 428 429if __name__ == "__main__": 430 asyncio.run(main())
76def clean_text(text): 77 """ 78 @brief Removes excess whitespace from a string. 79 80 @param text The raw string to clean. 81 @return A normalized single-line string with no extra whitespace. 82 83 @par Example 84 @code 85 clean_text(" ACC 101 ") -> "ACC 101" 86 @endcode 87 """ 88 return " ".join(text.split()).strip()
@brief Removes excess whitespace from a string.
@param text The raw string to clean. @return A normalized single-line string with no extra whitespace.
@par Example @code clean_text(" ACC 101 ") -> "ACC 101" @endcode
91def parse_seats(s): 92 """ 93 @brief Parses a seat availability string into open and total seat counts. 94 95 @details 96 Handles both standard slash "/" and Unicode division slash "∕" formats 97 used by The Q portal (e.g. "1 / 24" or "1 ∕ 24"). 98 99 @param s The raw seats string from the registration table. 100 @return A dictionary with keys "open" (int or None) and "total" (int or None). 101 102 @par Example 103 @code 104 parse_seats("1 ∕ 24") -> {"open": 1, "total": 24} 105 parse_seats("") -> {"open": None, "total": None} 106 @endcode 107 """ 108 m = re.search("(\d+)\s*[/∕]\s*(\d+)", s) 109 return {"open": int(m.group(1)), "total": int(m.group(2))} if m else {"open": None, "total": None}
@brief Parses a seat availability string into open and total seat counts.
@details Handles both standard slash "/" and Unicode division slash "∕" formats used by The Q portal (e.g. "1 / 24" or "1 ∕ 24").
@param s The raw seats string from the registration table. @return A dictionary with keys "open" (int or None) and "total" (int or None).
@par Example @code parse_seats("1 ∕ 24") -> {"open": 1, "total": 24} parse_seats("") -> {"open": None, "total": None} @endcode
112def parse_details(s): 113 """ 114 @brief Parses the details column into instructor, schedule, location, and method. 115 116 @details 117 The details column combines multiple fields into a slash-delimited string. 118 Expected format: "Instructor / Days Time; Location / Method" 119 120 @param s The raw details string from the registration table. 121 @return A dictionary with keys: 122 - "instructor" (str or None) 123 - "days_time" (str or None): e.g. "MW 08:00-09:15AM" 124 - "location" (str or None): e.g. "MAIN Campus, Room 312" 125 - "method" (str or None): e.g. "Lecture" 126 """ 127 result = {"instructor": None, "days_time": None, "location": None, "method": None} 128 parts = [p.strip() for p in s.split("/")] 129 if len(parts) >= 1: 130 result["instructor"] = clean_text(parts[0]) 131 if len(parts) >= 2: 132 result["days_time"] = clean_text(parts[1].split(";")[0]) 133 if ";" in parts[1]: 134 result["location"] = clean_text(parts[1].split(";")[1]) 135 if len(parts) >= 3: 136 result["method"] = clean_text(parts[2]) 137 return result
@brief Parses the details column into instructor, schedule, location, and method.
@details The details column combines multiple fields into a slash-delimited string. Expected format: "Instructor / Days Time; Location / Method"
@param s The raw details string from the registration table. @return A dictionary with keys: - "instructor" (str or None) - "days_time" (str or None): e.g. "MW 08:00-09:15AM" - "location" (str or None): e.g. "MAIN Campus, Room 312" - "method" (str or None): e.g. "Lecture"
140def parse_course_code(code): 141 """ 142 @brief Splits a full course code string into department, number, and section. 143 144 @param code The full course code string (e.g. "ACC 101-01"). 145 @return A dictionary with keys "department", "number", and "section", 146 all str or None if the format does not match. 147 148 @par Example 149 @code 150 parse_course_code("ACC 101-01") -> {"department": "ACC", "number": "101", "section": "01"} 151 @endcode 152 """ 153 m = re.match("([A-Z]+)\s+(\d+)-(\w+)", code) 154 return {"department": m.group(1), "number": m.group(2), "section": m.group(3)} if m else {"department": None, "number": None, "section": None}
@brief Splits a full course code string into department, number, and section.
@param code The full course code string (e.g. "ACC 101-01"). @return A dictionary with keys "department", "number", and "section", all str or None if the format does not match.
@par Example @code parse_course_code("ACC 101-01") -> {"department": "ACC", "number": "101", "section": "01"} @endcode
157def parse_html(html): 158 """ 159 @brief Parses raw HTML from The Q portal and extracts course section data. 160 161 @details 162 Uses BeautifulSoup to find all table rows, identifies rows containing a 163 valid course code (matching "DEPT NUM-SECTION"), and extracts all course 164 fields using the confirmed 13-column layout of The Q portal. 165 166 Column layout (auto-detected by course code position): 167 - [idx+0] course_code 168 - [idx+1] name 169 - [idx+4] seats 170 - [idx+5] status 171 - [idx+6] details (instructor/schedule/method) 172 - [idx+7] credits 173 - [idx+8] begin_date 174 - [idx+9] end_date 175 176 @param html Raw HTML string from the browser page content. 177 @return A list of course section dictionaries with all extracted fields. 178 """ 179 soup = BeautifulSoup(html, "html.parser") 180 courses = [] 181 for row in soup.select("table tr"): 182 cols = row.find_all(["td", "th"]) 183 if len(cols) < 4: 184 continue 185 values = [clean_text(col.get_text(" ", strip=True)) for col in cols] 186 joined = " | ".join(values).lower() 187 if "course code" in joined and "name" in joined: 188 continue 189 if all(not v for v in values): 190 continue 191 idx = next((i for i, v in enumerate(values) if re.match(r"[A-Z]{2,4}\s+\d{3}-\w+", v)), None) 192 if idx is None: 193 continue 194 try: 195 code_str = values[idx] 196 seats = parse_seats(values[idx + 4]) 197 details = parse_details(values[idx + 6]) 198 code_map = parse_course_code(code_str) 199 courses.append({ 200 "course_code": code_str, 201 "department": code_map["department"], 202 "course_number": code_map["number"], 203 "section": code_map["section"], 204 "name": values[idx + 1], 205 "status": values[idx + 5], 206 "seats_open": seats["open"], 207 "seats_total": seats["total"], 208 "credits": values[idx + 7], 209 "instructor": details["instructor"], 210 "days_time": details["days_time"], 211 "location": details["location"], 212 "method": details["method"], 213 "begin_date": values[idx + 8] if len(values) > idx + 8 else None, 214 "end_date": values[idx + 9] if len(values) > idx + 9 else None, 215 "scraped_at": datetime.now().isoformat(), 216 }) 217 except IndexError: 218 continue 219 return courses
@brief Parses raw HTML from The Q portal and extracts course section data.
@details Uses BeautifulSoup to find all table rows, identifies rows containing a valid course code (matching "DEPT NUM-SECTION"), and extracts all course fields using the confirmed 13-column layout of The Q portal.
Column layout (auto-detected by course code position):
- [idx+0] course_code
- [idx+1] name
- [idx+4] seats
- [idx+5] status
- [idx+6] details (instructor/schedule/method)
- [idx+7] credits
- [idx+8] begin_date
- [idx+9] end_date
@param html Raw HTML string from the browser page content. @return A list of course section dictionaries with all extracted fields.
224async def scrape(): 225 """ 226 @brief Asynchronously scrapes all course sections from The Q portal. 227 228 @details 229 Launches a headless Chromium browser using Playwright, navigates to The Q 230 portal's course search page, retrieves all department options from the 231 dropdown, and iterates through each department to collect course sections. 232 233 Each department is attempted up to 3 times with a 2-second delay between 234 retries to handle intermittent portal timeouts. 235 236 @return A list of all course section dictionaries scraped across all departments. 237 238 @note Requires Playwright and Chromium to be installed: 239 pip install playwright && playwright install chromium 240 """ 241 all_courses = [] 242 243 async with async_playwright() as p: 244 browser = await p.chromium.launch(headless=HEADLESS) 245 page = await browser.new_page() 246 247 print("Loading QCC course search page...") 248 await page.goto(SEARCH_URL) 249 await page.wait_for_load_state("networkidle") 250 251 dept_select = await page.query_selector("#pg0_V_ddlDept") 252 if not dept_select: 253 print("❌ Could not find department dropdown. Check if page loaded correctly.") 254 await browser.close() 255 return [] 256 257 dept_options = await dept_select.query_selector_all("option") 258 departments = [] 259 for opt in dept_options: 260 value = await opt.get_attribute("value") 261 label = (await opt.inner_text()).strip() 262 if label.lower() != "all" and label: 263 departments.append((value, label)) 264 265 print(f"Found {len(departments)} departments.\n") 266 267 for i, (value, label) in enumerate(departments): 268 print(f"[{i+1}/{len(departments)}] Scraping: {label}...") 269 for attempt in range(3): 270 try: 271 await page.goto(SEARCH_URL, timeout=60000) 272 await page.wait_for_load_state("networkidle", timeout=60000) 273 await page.wait_for_selector("#pg0_V_ddlDept", timeout=60000) 274 await page.select_option("#pg0_V_ddlTerm", label=TERM) 275 await page.select_option("#pg0_V_ddlDept", value=value) 276 await page.click("#pg0_V_btnSearch") 277 await page.wait_for_load_state("networkidle", timeout=60000) 278 courses = parse_html(await page.content()) 279 all_courses.extend(courses) 280 print(f" → {len(courses)} sections") 281 break 282 except Exception as e: 283 if attempt < 2: 284 print(f" ⚠ Attempt {attempt+1} failed, retrying...") 285 await asyncio.sleep(2) 286 else: 287 print(f" ✗ Failed after 3 attempts: {e}") 288 289 await browser.close() 290 291 return all_courses
@brief Asynchronously scrapes all course sections from The Q portal.
@details Launches a headless Chromium browser using Playwright, navigates to The Q portal's course search page, retrieves all department options from the dropdown, and iterates through each department to collect course sections.
Each department is attempted up to 3 times with a 2-second delay between retries to handle intermittent portal timeouts.
@return A list of all course section dictionaries scraped across all departments.
@note Requires Playwright and Chromium to be installed: pip install playwright && playwright install chromium
296def update_db(courses): 297 """ 298 @brief Creates or updates registration.db with the scraped course data. 299 300 @details 301 Connects to (or creates) the SQLite database at DB_FILE. Creates the 302 courses table and indexes if they do not exist. Upserts each course 303 record — inserting new records and updating existing ones on conflict 304 with the unique course_code constraint. 305 306 The following indexes are created for query performance: 307 - idx_department on courses(department) 308 - idx_course_code on courses(course_code) 309 - idx_status on courses(status) 310 - idx_instructor on courses(instructor) 311 312 @param courses A list of course section dictionaries to upsert. 313 @return A tuple (inserted, errors, total) where: 314 - inserted (int): number of records successfully processed 315 - errors (int): number of records that failed 316 - total (int): total records now in the database 317 """ 318 if not os.path.exists(DB_FILE): 319 print(f"\n⚠️ {DB_FILE} not found — creating it now...") 320 321 conn = sqlite3.connect(DB_FILE) 322 cursor = conn.cursor() 323 324 cursor.executescript(""" 325 CREATE TABLE IF NOT EXISTS courses ( 326 id INTEGER PRIMARY KEY AUTOINCREMENT, 327 course_code TEXT NOT NULL, 328 department TEXT, 329 course_number TEXT, 330 section TEXT, 331 name TEXT, 332 status TEXT, 333 seats_open INTEGER, 334 seats_total INTEGER, 335 credits TEXT, 336 instructor TEXT, 337 days_time TEXT, 338 location TEXT, 339 method TEXT, 340 begin_date TEXT, 341 end_date TEXT, 342 scraped_at TEXT, 343 UNIQUE(course_code) 344 ); 345 CREATE INDEX IF NOT EXISTS idx_department ON courses(department); 346 CREATE INDEX IF NOT EXISTS idx_course_code ON courses(course_code); 347 CREATE INDEX IF NOT EXISTS idx_status ON courses(status); 348 CREATE INDEX IF NOT EXISTS idx_instructor ON courses(instructor); 349 """) 350 351 inserted = errors = 0 352 for c in courses: 353 try: 354 cursor.execute(""" 355 INSERT INTO courses ( 356 course_code, department, course_number, section, 357 name, status, seats_open, seats_total, credits, 358 instructor, days_time, location, method, 359 begin_date, end_date, scraped_at 360 ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) 361 ON CONFLICT(course_code) DO UPDATE SET 362 status = excluded.status, 363 seats_open = excluded.seats_open, 364 seats_total = excluded.seats_total, 365 instructor = excluded.instructor, 366 days_time = excluded.days_time, 367 location = excluded.location, 368 method = excluded.method, 369 begin_date = excluded.begin_date, 370 end_date = excluded.end_date, 371 scraped_at = excluded.scraped_at 372 """, ( 373 c.get("course_code"), c.get("department"), c.get("course_number"), 374 c.get("section"), c.get("name"), c.get("status"), 375 c.get("seats_open"), c.get("seats_total"), c.get("credits"), 376 c.get("instructor"), c.get("days_time"), c.get("location"), 377 c.get("method"), c.get("begin_date"), c.get("end_date"), 378 c.get("scraped_at"), 379 )) 380 inserted += 1 381 except sqlite3.Error as e: 382 print(f" DB error for {c.get('course_code')}: {e}") 383 errors += 1 384 385 conn.commit() 386 total = conn.execute("SELECT COUNT(*) FROM courses").fetchone()[0] 387 conn.close() 388 return inserted, errors, total
@brief Creates or updates registration.db with the scraped course data.
@details Connects to (or creates) the SQLite database at DB_FILE. Creates the courses table and indexes if they do not exist. Upserts each course record — inserting new records and updating existing ones on conflict with the unique course_code constraint.
The following indexes are created for query performance:
- idx_department on courses(department)
- idx_course_code on courses(course_code)
- idx_status on courses(status)
- idx_instructor on courses(instructor)
@param courses A list of course section dictionaries to upsert. @return A tuple (inserted, errors, total) where: - inserted (int): number of records successfully processed - errors (int): number of records that failed - total (int): total records now in the database
393async def main(): 394 """ 395 @brief Entry point — runs the full three-step registration refresh pipeline. 396 397 @details 398 Executes the pipeline in sequence: 399 1. Scrapes all departments from The Q portal via scrape() 400 2. Saves results to registration_sections.json 401 3. Upserts results into registration.db via update_db() 402 403 Prints a summary on completion including total sections scraped, 404 database record count, error count, and elapsed time. 405 """ 406 start = datetime.now() 407 print("=" * 50) 408 print(" QCC Registration Refresh") 409 print(f" {start.strftime('%Y-%m-%d %H:%M:%S')}") 410 print("=" * 50) 411 412 print("\n[1/3] Scraping QCC course offerings...") 413 courses = await scrape() 414 print(f"\n Total scraped: {len(courses)} sections") 415 416 print("\n[2/3] Saving to JSON...") 417 with open(JSON_FILE, "w", encoding="utf-8") as f: 418 json.dump(courses, f, indent=2) 419 print(f" Saved {len(courses)} sections to {JSON_FILE}") 420 421 print("\n[3/3] Updating database...") 422 inserted, errors, total = update_db(courses) 423 print(f" Processed: {len(courses)} | Total in DB: {total} | Errors: {errors}") 424 425 elapsed = (datetime.now() - start).seconds 426 print(f"\n✅ Done in {elapsed}s — {total} courses in database.") 427 print("=" * 50)
@brief Entry point — runs the full three-step registration refresh pipeline.
@details Executes the pipeline in sequence: 1. Scrapes all departments from The Q portal via scrape() 2. Saves results to registration_sections.json 3. Upserts results into registration.db via update_db()
Prints a summary on completion including total sections scraped, database record count, error count, and elapsed time.