scraping.scrape_catalog

@file scrape_catalog.py @brief Scrapes the QCC course catalog from The Q portal for unique course details.

@details This module uses Playwright to automate a headless Chromium browser and navigate The Q portal's course search page. For each unique course code discovered across all departments, it clicks into the course detail page and extracts: - Course name - Course description (with SQL apostrophe artifacts cleaned) - Credits - Prerequisites - Semesters offered

Only one section per unique base course code is scraped (e.g. ACC 101, not ACC 101-01 and ACC 101-02 separately), making this a catalog scraper rather than a section scraper.

@date 2026

@par Input The Q portal — live web scraping via Playwright

@par Output course_catalog.json — list of unique course dictionaries

@par Dependencies - playwright (pip install playwright && playwright install chromium) - beautifulsoup4 - json (stdlib) - re (stdlib) - asyncio (stdlib)

@par Usage python scrape_catalog.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@file scrape_catalog.py
 12@brief Scrapes the QCC course catalog from The Q portal for unique course details.
 13
 14@details
 15This module uses Playwright to automate a headless Chromium browser and navigate
 16The Q portal's course search page. For each unique course code discovered across
 17all departments, it clicks into the course detail page and extracts:
 18    - Course name
 19    - Course description (with SQL apostrophe artifacts cleaned)
 20    - Credits
 21    - Prerequisites
 22    - Semesters offered
 23
 24Only one section per unique base course code is scraped (e.g. ACC 101, not ACC 101-01
 25and ACC 101-02 separately), making this a catalog scraper rather than a section scraper.
 26
 27@date 2026
 28
 29@par Input
 30    The Q portal — live web scraping via Playwright
 31
 32@par Output
 33    course_catalog.json — list of unique course dictionaries
 34
 35@par Dependencies
 36    - playwright (pip install playwright && playwright install chromium)
 37    - beautifulsoup4
 38    - json (stdlib)
 39    - re (stdlib)
 40    - asyncio (stdlib)
 41
 42@par Usage
 43    python scrape_catalog.py
 44"""
 45
 46import json
 47import re
 48import asyncio
 49from datetime import datetime
 50from bs4 import BeautifulSoup
 51from playwright.async_api import async_playwright
 52
 53## @brief URL of The Q portal's advanced course search page.
 54SEARCH_URL = "https://theq.qcc.edu/ICS/Course_Offerings_and_Schedule.jnz?portlet=AddDrop_Courses&screen=Advanced+Course+Search&screenType=next"
 55
 56## @brief Academic term label to select from the portal dropdown.
 57TERM       = "Spring 2026"
 58
 59## @brief Output path for the scraped catalog JSON file.
 60OUTPUT     = "course_catalog.json"
 61
 62## @brief Whether to run the browser in headless (no UI) mode.
 63HEADLESS   = True
 64
 65
 66def clean_text(text):
 67    """
 68    @brief Removes excess whitespace from a string.
 69
 70    @param text The raw string to clean.
 71    @return A normalized single-line string with no extra whitespace.
 72    """
 73    return " ".join(text.split()).strip()
 74
 75
 76def fix_apostrophes(text):
 77    """
 78    @brief Fixes SQL-escaped double apostrophes from The Q portal database.
 79
 80    @details
 81    The Q portal stores course descriptions in a Jenzabar SQL database that
 82    escapes apostrophes as double apostrophes ('').  These artifacts bleed
 83    through into the scraped text (e.g. "company''s assets"). This function
 84    replaces all occurrences of '' with a single apostrophe.
 85
 86    @param text The raw string potentially containing double apostrophes.
 87    @return The cleaned string with proper single apostrophes, or None if input is None.
 88
 89    @par Example
 90    @code
 91    fix_apostrophes("company''s assets") -> "company's assets"
 92    fix_apostrophes(None)                -> None
 93    @endcode
 94    """
 95    if text:
 96        return text.replace("''", "'")
 97    return text
 98
 99
100def parse_detail_page(html):
101    """
102    @brief Parses a course detail page from The Q portal and extracts course fields.
103
104    @details
105    The Q portal renders course details inside a span with id "pg0_V_lblCourseDescValue".
106    The content is a mix of text nodes and <br> tags forming a block of labeled fields.
107
108    Extraction strategy:
109    - Course name: from the <h5> tag (format: "Course Name (DEPT NUM-SECTION)")
110    - Description: all text lines before the "Credits:" label
111    - Credits: regex match on "Credits: N" or "N Credits"
112    - Prerequisites: regex match on "Prerequisites: ..." stopping before "Semester Offered"
113    - Semesters offered: regex match on "Semester Offered: ..."
114
115    Double apostrophes in description and prerequisites are cleaned via fix_apostrophes().
116
117    @param html Raw HTML string of the course detail page.
118    @return A dictionary with keys:
119            - "name" (str or None)
120            - "description" (str or None)
121            - "credits" (str or None)
122            - "prerequisites" (str or None)
123            - "semesters_offered" (str or None)
124    """
125    soup = BeautifulSoup(html, "html.parser")
126    result = {
127        "name":              None,
128        "description":       None,
129        "credits":           None,
130        "prerequisites":     None,
131        "semesters_offered": None,
132    }
133
134    # Course name from the h5 tag (e.g. "Financial Accounting I (ACC 101-01)")
135    h5 = soup.find("h5")
136    if h5:
137        name_text = clean_text(h5.get_text())
138        name_match = re.match(r"^(.+?)\s*\([A-Z]", name_text)
139        if name_match:
140            result["name"] = name_match.group(1).strip()
141
142    # Description span
143    desc_span = soup.find("span", id="pg0_V_lblCourseDescValue")
144    if not desc_span:
145        return result
146
147    # Get text lines split by <br> tags
148    lines = []
149    for item in desc_span.children:
150        if hasattr(item, "get_text"):
151            text = clean_text(item.get_text())
152        else:
153            text = clean_text(str(item))
154        if text:
155            lines.append(text)
156
157    full = " ".join(lines)
158
159    # Description = all lines before "Credits:"
160    desc_lines = []
161    for line in lines:
162        if re.match(r"credits:", line, re.IGNORECASE):
163            break
164        desc_lines.append(line)
165    if desc_lines:
166        result["description"] = fix_apostrophes(" ".join(desc_lines))
167
168    # Credits — match "Credits: 3" or "3 Credits" or "3 credit hours"
169    credit_match = re.search(
170        r"Credits?:\s*(\d+(?:\.\d+)?)|(\d+(?:\.\d+)?)\s*[Cc]redit",
171        full
172    )
173    if credit_match:
174        result["credits"] = credit_match.group(1) or credit_match.group(2)
175
176    # Prerequisites — stop at Semester Offered or Credits
177    prereq_match = re.search(
178        r"Prerequisite[s]?:\s*(.+?)(?=\s*Semester Offered|\s*Credits?:|$)",
179        full, re.IGNORECASE
180    )
181    if prereq_match:
182        prereq = clean_text(prereq_match.group(1))
183        if "semester offered" not in prereq.lower():
184            result["prerequisites"] = fix_apostrophes(prereq)
185
186    # Semesters offered — allow upper and lowercase, stop at Credits
187    sem_match = re.search(
188        r"Semester[s]? Offered:\s*([A-Za-z/,\s]+?)(?=\s*Credits?:|$)",
189        full, re.IGNORECASE
190    )
191    if sem_match:
192        result["semesters_offered"] = clean_text(sem_match.group(1))
193
194    return result
195
196
197async def get_search_results(page, dept_value, dept_label):
198    """
199    @brief Loads the search results page for a department and returns course section links.
200
201    @details
202    Navigates to the course search page, selects the given term and department,
203    submits the search, and collects all course section links from the results table.
204    Retries up to 3 times on failure with a 2-second delay between attempts.
205
206    @param page The Playwright page object.
207    @param dept_value The dropdown option value for the department.
208    @param dept_label The human-readable department label (used for logging).
209    @return A list of (section_code, href) tuples for all course links found,
210            or an empty list if all attempts fail.
211    """
212    for attempt in range(3):
213        try:
214            await page.goto(SEARCH_URL, timeout=120000)
215            await page.wait_for_load_state("networkidle", timeout=120000)
216            await page.wait_for_selector("#pg0_V_ddlDept", timeout=60000)
217            await page.select_option("#pg0_V_ddlTerm", label=TERM)
218            await page.select_option("#pg0_V_ddlDept", value=dept_value)
219            await page.click("#pg0_V_btnSearch")
220            await page.wait_for_load_state("networkidle", timeout=120000)
221            await page.wait_for_timeout(1500)
222
223            course_links = await page.query_selector_all("table a")
224            results = []
225            for link in course_links:
226                text = (await link.inner_text()).strip()
227                href = await link.get_attribute("href") or ""
228                if re.match(r"[A-Z]{2,4}\s+\d{3}-\w+", text) and "lnkCourse" in href:
229                    results.append((text, href))
230            return results
231        except Exception as e:
232            if attempt < 2:
233                print(f"  ⚠ Retry {attempt+1}...")
234                await asyncio.sleep(2)
235            else:
236                print(f"  ✗ Failed: {e}")
237                return []
238
239
240async def scrape_catalog():
241    """
242    @brief Asynchronously scrapes all unique courses from The Q portal catalog.
243
244    @details
245    Iterates through all departments on The Q portal. For each department,
246    retrieves the list of course sections and identifies unique base course codes
247    (e.g. "ACC 101" from "ACC 101-01"). For each unique code not yet scraped,
248    navigates to the course detail page using a JavaScript postback and extracts
249    course details via parse_detail_page().
250
251    Courses already scraped in a previous department are skipped to avoid
252    duplicates in the output catalog.
253
254    @return A list of unique course dictionaries with fields:
255            course_code, department, course_number, name, description,
256            credits, prerequisites, semesters_offered, scraped_at.
257    """
258    catalog = {}
259
260    async with async_playwright() as p:
261        browser = await p.chromium.launch(headless=HEADLESS)
262        page = await browser.new_page()
263
264        print("Loading QCC course search page...")
265        await page.goto(SEARCH_URL, timeout=120000)
266        await page.wait_for_load_state("networkidle", timeout=120000)
267
268        # Get departments
269        dept_select = await page.query_selector("#pg0_V_ddlDept")
270        dept_options = await dept_select.query_selector_all("option")
271        departments = []
272        for opt in dept_options:
273            value = await opt.get_attribute("value")
274            label = (await opt.inner_text()).strip()
275            if label.lower() != "all" and label:
276                departments.append((value, label))
277
278        print(f"Found {len(departments)} departments.\n")
279
280        for dept_idx, (dept_value, dept_label) in enumerate(departments):
281            print(f"[{dept_idx+1}/{len(departments)}] {dept_label}")
282
283            course_links = await get_search_results(page, dept_value, dept_label)
284            print(f"  Found {len(course_links)} sections")
285
286            scraped_base = set()
287
288            for section_code, href in course_links:
289                base_match = re.match(r"([A-Z]{2,4}\s+\d+)", section_code)
290                if not base_match:
291                    continue
292                base_code = base_match.group(1)
293
294                if base_code in scraped_base or base_code in catalog:
295                    scraped_base.add(base_code)
296                    continue
297                scraped_base.add(base_code)
298
299                # Re-load search results before each course click
300                course_links_fresh = await get_search_results(page, dept_value, dept_label)
301
302                # Find this course's link in fresh results
303                target_href = None
304                for sc, href2 in course_links_fresh:
305                    bm = re.match(r"([A-Z]{2,4}\s+\d+)", sc)
306                    if bm and bm.group(1) == base_code:
307                        target_href = href2
308                        break
309
310                if not target_href:
311                    continue
312
313                try:
314                    postback_match = re.search(r"__doPostBack\('([^']+)'", target_href)
315                    if not postback_match:
316                        continue
317                    target = postback_match.group(1)
318
319                    await page.evaluate(f"__doPostBack('{target}', '')")
320                    await page.wait_for_load_state("networkidle", timeout=30000)
321                    try:
322                        await page.wait_for_selector("#pg0_V_lblCourseDescValue", timeout=8000)
323                    except Exception:
324                        pass
325
326                    html = await page.content()
327                    detail = parse_detail_page(html)
328
329                    dept_match = re.match(r"([A-Z]+)", base_code)
330                    dept_code = dept_match.group(1) if dept_match else None
331                    num_match = re.search(r"\d+", base_code)
332                    course_num = num_match.group(0) if num_match else None
333
334                    catalog[base_code] = {
335                        "course_code":       base_code,
336                        "department":        dept_code,
337                        "course_number":     course_num,
338                        "name":              detail["name"],
339                        "description":       detail["description"],
340                        "credits":           detail["credits"],
341                        "prerequisites":     detail["prerequisites"],
342                        "semesters_offered": detail["semesters_offered"],
343                        "scraped_at":        datetime.now().isoformat(),
344                    }
345
346                    desc_preview = detail["description"][:60] if detail["description"] else "no description"
347                    print(f"    ✓ {base_code}{desc_preview}...")
348
349                except Exception as e:
350                    print(f"    ✗ Error on {base_code}: {e}")
351                    continue
352
353        await browser.close()
354
355    return list(catalog.values())
356
357
358async def main():
359    """
360    @brief Entry point — runs the catalog scraper and saves results to JSON.
361
362    @details
363    Calls scrape_catalog() to collect all unique course data, writes the
364    results to course_catalog.json, and prints a summary including total
365    courses scraped and elapsed time.
366    """
367    start = datetime.now()
368    print("=" * 50)
369    print("  QCC Course Catalog Scraper")
370    print(f"  {start.strftime('%Y-%m-%d %H:%M:%S')}")
371    print("=" * 50)
372
373    courses = await scrape_catalog()
374
375    with open(OUTPUT, "w", encoding="utf-8") as f:
376        json.dump(courses, f, indent=2)
377
378    elapsed = (datetime.now() - start).seconds
379    print(f"\n✅ Done in {elapsed}s")
380    print(f"   Scraped {len(courses)} unique courses")
381    print(f"   Saved to {OUTPUT}")
382    print("=" * 50)
383
384
385if __name__ == "__main__":
386    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'
OUTPUT = 'course_catalog.json'
HEADLESS = True
def clean_text(text):
67def clean_text(text):
68    """
69    @brief Removes excess whitespace from a string.
70
71    @param text The raw string to clean.
72    @return A normalized single-line string with no extra whitespace.
73    """
74    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.

def fix_apostrophes(text):
77def fix_apostrophes(text):
78    """
79    @brief Fixes SQL-escaped double apostrophes from The Q portal database.
80
81    @details
82    The Q portal stores course descriptions in a Jenzabar SQL database that
83    escapes apostrophes as double apostrophes ('').  These artifacts bleed
84    through into the scraped text (e.g. "company''s assets"). This function
85    replaces all occurrences of '' with a single apostrophe.
86
87    @param text The raw string potentially containing double apostrophes.
88    @return The cleaned string with proper single apostrophes, or None if input is None.
89
90    @par Example
91    @code
92    fix_apostrophes("company''s assets") -> "company's assets"
93    fix_apostrophes(None)                -> None
94    @endcode
95    """
96    if text:
97        return text.replace("''", "'")
98    return text

@brief Fixes SQL-escaped double apostrophes from The Q portal database.

@details The Q portal stores course descriptions in a Jenzabar SQL database that escapes apostrophes as double apostrophes (''). These artifacts bleed through into the scraped text (e.g. "company''s assets"). This function replaces all occurrences of '' with a single apostrophe.

@param text The raw string potentially containing double apostrophes. @return The cleaned string with proper single apostrophes, or None if input is None.

@par Example @code fix_apostrophes("company''s assets") -> "company's assets" fix_apostrophes(None) -> None @endcode

def parse_detail_page(html):
101def parse_detail_page(html):
102    """
103    @brief Parses a course detail page from The Q portal and extracts course fields.
104
105    @details
106    The Q portal renders course details inside a span with id "pg0_V_lblCourseDescValue".
107    The content is a mix of text nodes and <br> tags forming a block of labeled fields.
108
109    Extraction strategy:
110    - Course name: from the <h5> tag (format: "Course Name (DEPT NUM-SECTION)")
111    - Description: all text lines before the "Credits:" label
112    - Credits: regex match on "Credits: N" or "N Credits"
113    - Prerequisites: regex match on "Prerequisites: ..." stopping before "Semester Offered"
114    - Semesters offered: regex match on "Semester Offered: ..."
115
116    Double apostrophes in description and prerequisites are cleaned via fix_apostrophes().
117
118    @param html Raw HTML string of the course detail page.
119    @return A dictionary with keys:
120            - "name" (str or None)
121            - "description" (str or None)
122            - "credits" (str or None)
123            - "prerequisites" (str or None)
124            - "semesters_offered" (str or None)
125    """
126    soup = BeautifulSoup(html, "html.parser")
127    result = {
128        "name":              None,
129        "description":       None,
130        "credits":           None,
131        "prerequisites":     None,
132        "semesters_offered": None,
133    }
134
135    # Course name from the h5 tag (e.g. "Financial Accounting I (ACC 101-01)")
136    h5 = soup.find("h5")
137    if h5:
138        name_text = clean_text(h5.get_text())
139        name_match = re.match(r"^(.+?)\s*\([A-Z]", name_text)
140        if name_match:
141            result["name"] = name_match.group(1).strip()
142
143    # Description span
144    desc_span = soup.find("span", id="pg0_V_lblCourseDescValue")
145    if not desc_span:
146        return result
147
148    # Get text lines split by <br> tags
149    lines = []
150    for item in desc_span.children:
151        if hasattr(item, "get_text"):
152            text = clean_text(item.get_text())
153        else:
154            text = clean_text(str(item))
155        if text:
156            lines.append(text)
157
158    full = " ".join(lines)
159
160    # Description = all lines before "Credits:"
161    desc_lines = []
162    for line in lines:
163        if re.match(r"credits:", line, re.IGNORECASE):
164            break
165        desc_lines.append(line)
166    if desc_lines:
167        result["description"] = fix_apostrophes(" ".join(desc_lines))
168
169    # Credits — match "Credits: 3" or "3 Credits" or "3 credit hours"
170    credit_match = re.search(
171        r"Credits?:\s*(\d+(?:\.\d+)?)|(\d+(?:\.\d+)?)\s*[Cc]redit",
172        full
173    )
174    if credit_match:
175        result["credits"] = credit_match.group(1) or credit_match.group(2)
176
177    # Prerequisites — stop at Semester Offered or Credits
178    prereq_match = re.search(
179        r"Prerequisite[s]?:\s*(.+?)(?=\s*Semester Offered|\s*Credits?:|$)",
180        full, re.IGNORECASE
181    )
182    if prereq_match:
183        prereq = clean_text(prereq_match.group(1))
184        if "semester offered" not in prereq.lower():
185            result["prerequisites"] = fix_apostrophes(prereq)
186
187    # Semesters offered — allow upper and lowercase, stop at Credits
188    sem_match = re.search(
189        r"Semester[s]? Offered:\s*([A-Za-z/,\s]+?)(?=\s*Credits?:|$)",
190        full, re.IGNORECASE
191    )
192    if sem_match:
193        result["semesters_offered"] = clean_text(sem_match.group(1))
194
195    return result

@brief Parses a course detail page from The Q portal and extracts course fields.

@details The Q portal renders course details inside a span with id "pg0_V_lblCourseDescValue". The content is a mix of text nodes and
tags forming a block of labeled fields.

Extraction strategy:

  • Course name: from the
    tag (format: "Course Name (DEPT NUM-SECTION)")
  • Description: all text lines before the "Credits:" label
  • Credits: regex match on "Credits: N" or "N Credits"
  • Prerequisites: regex match on "Prerequisites: ..." stopping before "Semester Offered"
  • Semesters offered: regex match on "Semester Offered: ..."

Double apostrophes in description and prerequisites are cleaned via fix_apostrophes().

@param html Raw HTML string of the course detail page. @return A dictionary with keys: - "name" (str or None) - "description" (str or None) - "credits" (str or None) - "prerequisites" (str or None) - "semesters_offered" (str or None)

async def get_search_results(page, dept_value, dept_label):
198async def get_search_results(page, dept_value, dept_label):
199    """
200    @brief Loads the search results page for a department and returns course section links.
201
202    @details
203    Navigates to the course search page, selects the given term and department,
204    submits the search, and collects all course section links from the results table.
205    Retries up to 3 times on failure with a 2-second delay between attempts.
206
207    @param page The Playwright page object.
208    @param dept_value The dropdown option value for the department.
209    @param dept_label The human-readable department label (used for logging).
210    @return A list of (section_code, href) tuples for all course links found,
211            or an empty list if all attempts fail.
212    """
213    for attempt in range(3):
214        try:
215            await page.goto(SEARCH_URL, timeout=120000)
216            await page.wait_for_load_state("networkidle", timeout=120000)
217            await page.wait_for_selector("#pg0_V_ddlDept", timeout=60000)
218            await page.select_option("#pg0_V_ddlTerm", label=TERM)
219            await page.select_option("#pg0_V_ddlDept", value=dept_value)
220            await page.click("#pg0_V_btnSearch")
221            await page.wait_for_load_state("networkidle", timeout=120000)
222            await page.wait_for_timeout(1500)
223
224            course_links = await page.query_selector_all("table a")
225            results = []
226            for link in course_links:
227                text = (await link.inner_text()).strip()
228                href = await link.get_attribute("href") or ""
229                if re.match(r"[A-Z]{2,4}\s+\d{3}-\w+", text) and "lnkCourse" in href:
230                    results.append((text, href))
231            return results
232        except Exception as e:
233            if attempt < 2:
234                print(f"  ⚠ Retry {attempt+1}...")
235                await asyncio.sleep(2)
236            else:
237                print(f"  ✗ Failed: {e}")
238                return []

@brief Loads the search results page for a department and returns course section links.

@details Navigates to the course search page, selects the given term and department, submits the search, and collects all course section links from the results table. Retries up to 3 times on failure with a 2-second delay between attempts.

@param page The Playwright page object. @param dept_value The dropdown option value for the department. @param dept_label The human-readable department label (used for logging). @return A list of (section_code, href) tuples for all course links found, or an empty list if all attempts fail.

async def scrape_catalog():
241async def scrape_catalog():
242    """
243    @brief Asynchronously scrapes all unique courses from The Q portal catalog.
244
245    @details
246    Iterates through all departments on The Q portal. For each department,
247    retrieves the list of course sections and identifies unique base course codes
248    (e.g. "ACC 101" from "ACC 101-01"). For each unique code not yet scraped,
249    navigates to the course detail page using a JavaScript postback and extracts
250    course details via parse_detail_page().
251
252    Courses already scraped in a previous department are skipped to avoid
253    duplicates in the output catalog.
254
255    @return A list of unique course dictionaries with fields:
256            course_code, department, course_number, name, description,
257            credits, prerequisites, semesters_offered, scraped_at.
258    """
259    catalog = {}
260
261    async with async_playwright() as p:
262        browser = await p.chromium.launch(headless=HEADLESS)
263        page = await browser.new_page()
264
265        print("Loading QCC course search page...")
266        await page.goto(SEARCH_URL, timeout=120000)
267        await page.wait_for_load_state("networkidle", timeout=120000)
268
269        # Get departments
270        dept_select = await page.query_selector("#pg0_V_ddlDept")
271        dept_options = await dept_select.query_selector_all("option")
272        departments = []
273        for opt in dept_options:
274            value = await opt.get_attribute("value")
275            label = (await opt.inner_text()).strip()
276            if label.lower() != "all" and label:
277                departments.append((value, label))
278
279        print(f"Found {len(departments)} departments.\n")
280
281        for dept_idx, (dept_value, dept_label) in enumerate(departments):
282            print(f"[{dept_idx+1}/{len(departments)}] {dept_label}")
283
284            course_links = await get_search_results(page, dept_value, dept_label)
285            print(f"  Found {len(course_links)} sections")
286
287            scraped_base = set()
288
289            for section_code, href in course_links:
290                base_match = re.match(r"([A-Z]{2,4}\s+\d+)", section_code)
291                if not base_match:
292                    continue
293                base_code = base_match.group(1)
294
295                if base_code in scraped_base or base_code in catalog:
296                    scraped_base.add(base_code)
297                    continue
298                scraped_base.add(base_code)
299
300                # Re-load search results before each course click
301                course_links_fresh = await get_search_results(page, dept_value, dept_label)
302
303                # Find this course's link in fresh results
304                target_href = None
305                for sc, href2 in course_links_fresh:
306                    bm = re.match(r"([A-Z]{2,4}\s+\d+)", sc)
307                    if bm and bm.group(1) == base_code:
308                        target_href = href2
309                        break
310
311                if not target_href:
312                    continue
313
314                try:
315                    postback_match = re.search(r"__doPostBack\('([^']+)'", target_href)
316                    if not postback_match:
317                        continue
318                    target = postback_match.group(1)
319
320                    await page.evaluate(f"__doPostBack('{target}', '')")
321                    await page.wait_for_load_state("networkidle", timeout=30000)
322                    try:
323                        await page.wait_for_selector("#pg0_V_lblCourseDescValue", timeout=8000)
324                    except Exception:
325                        pass
326
327                    html = await page.content()
328                    detail = parse_detail_page(html)
329
330                    dept_match = re.match(r"([A-Z]+)", base_code)
331                    dept_code = dept_match.group(1) if dept_match else None
332                    num_match = re.search(r"\d+", base_code)
333                    course_num = num_match.group(0) if num_match else None
334
335                    catalog[base_code] = {
336                        "course_code":       base_code,
337                        "department":        dept_code,
338                        "course_number":     course_num,
339                        "name":              detail["name"],
340                        "description":       detail["description"],
341                        "credits":           detail["credits"],
342                        "prerequisites":     detail["prerequisites"],
343                        "semesters_offered": detail["semesters_offered"],
344                        "scraped_at":        datetime.now().isoformat(),
345                    }
346
347                    desc_preview = detail["description"][:60] if detail["description"] else "no description"
348                    print(f"    ✓ {base_code}{desc_preview}...")
349
350                except Exception as e:
351                    print(f"    ✗ Error on {base_code}: {e}")
352                    continue
353
354        await browser.close()
355
356    return list(catalog.values())

@brief Asynchronously scrapes all unique courses from The Q portal catalog.

@details Iterates through all departments on The Q portal. For each department, retrieves the list of course sections and identifies unique base course codes (e.g. "ACC 101" from "ACC 101-01"). For each unique code not yet scraped, navigates to the course detail page using a JavaScript postback and extracts course details via parse_detail_page().

Courses already scraped in a previous department are skipped to avoid duplicates in the output catalog.

@return A list of unique course dictionaries with fields: course_code, department, course_number, name, description, credits, prerequisites, semesters_offered, scraped_at.

async def main():
359async def main():
360    """
361    @brief Entry point — runs the catalog scraper and saves results to JSON.
362
363    @details
364    Calls scrape_catalog() to collect all unique course data, writes the
365    results to course_catalog.json, and prints a summary including total
366    courses scraped and elapsed time.
367    """
368    start = datetime.now()
369    print("=" * 50)
370    print("  QCC Course Catalog Scraper")
371    print(f"  {start.strftime('%Y-%m-%d %H:%M:%S')}")
372    print("=" * 50)
373
374    courses = await scrape_catalog()
375
376    with open(OUTPUT, "w", encoding="utf-8") as f:
377        json.dump(courses, f, indent=2)
378
379    elapsed = (datetime.now() - start).seconds
380    print(f"\n✅ Done in {elapsed}s")
381    print(f"   Scraped {len(courses)} unique courses")
382    print(f"   Saved to {OUTPUT}")
383    print("=" * 50)

@brief Entry point — runs the catalog scraper and saves results to JSON.

@details Calls scrape_catalog() to collect all unique course data, writes the results to course_catalog.json, and prints a summary including total courses scraped and elapsed time.