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())
SEARCH_URL = 'https://theq.qcc.edu/ICS/Course_Offerings_and_Schedule.jnz?portlet=AddDrop_Courses&screen=Advanced+Course+Search&screenType=next'
TERM = 'Spring 2026'
JSON_FILE = 'registration_sections.json'
DB_FILE = 'registration.db'
HEADLESS = True
def clean_text(text):
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

def parse_seats(s):
 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

def parse_details(s):
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"

def parse_course_code(code):
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

def parse_html(html):
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.

async def scrape():
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

def update_db(courses):
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

async def main():
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.