scraping.scrape_classes_page
@file scrape_classes_page.py @brief Scrapes the public QCC website for the full credit course listing with details.
@details This module scrapes https://www.qcc.edu/classes for the complete list of QCC credit courses, then visits each course's individual detail page to extract: - Description - Credits - Prerequisites - Semesters offered
Unlike scrape_catalog.py which uses Playwright to access The Q portal, this scraper uses the Requests library and BeautifulSoup since the public QCC website renders content statically (no JavaScript required).
Field extraction uses a line-scanning approach rather than CSS selectors, because QCC's public pages present labeled fields as plain text rather than structured HTML elements with identifying CSS classes.
@date 2026
@par Input https://www.qcc.edu/classes — public QCC course listing page
@par Output qcc_classes.json — list of course dictionaries with full detail fields
@par Dependencies - requests - beautifulsoup4 - json (stdlib) - re (stdlib) - time (stdlib)
@par Usage python scrape_classes_page.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_classes_page.py 12@brief Scrapes the public QCC website for the full credit course listing with details. 13 14@details 15This module scrapes https://www.qcc.edu/classes for the complete list of QCC 16credit courses, then visits each course's individual detail page to extract: 17 - Description 18 - Credits 19 - Prerequisites 20 - Semesters offered 21 22Unlike scrape_catalog.py which uses Playwright to access The Q portal, 23this scraper uses the Requests library and BeautifulSoup since the public 24QCC website renders content statically (no JavaScript required). 25 26Field extraction uses a line-scanning approach rather than CSS selectors, 27because QCC's public pages present labeled fields as plain text rather than 28structured HTML elements with identifying CSS classes. 29 30@date 2026 31 32@par Input 33 https://www.qcc.edu/classes — public QCC course listing page 34 35@par Output 36 qcc_classes.json — list of course dictionaries with full detail fields 37 38@par Dependencies 39 - requests 40 - beautifulsoup4 41 - json (stdlib) 42 - re (stdlib) 43 - time (stdlib) 44 45@par Usage 46 python scrape_classes_page.py 47""" 48 49import json 50import re 51import time 52import requests 53from bs4 import BeautifulSoup 54from datetime import datetime 55 56## @brief Base URL for constructing absolute links from relative hrefs. 57BASE_URL = "https://www.qcc.edu" 58 59## @brief URL of the QCC public classes listing page. 60CLASSES_URL = "https://www.qcc.edu/classes" 61 62## @brief Output path for the scraped JSON file. 63OUTPUT = "qcc_classes.json" 64 65## @brief HTTP headers to send with each request to mimic a browser. 66HEADERS = { 67 "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" 68} 69 70 71def clean_text(text): 72 """ 73 @brief Removes excess whitespace from a string. 74 75 @param text The raw string to clean. 76 @return A normalized single-line string with no extra whitespace. 77 """ 78 return " ".join(text.split()).strip() 79 80 81def fetch_course_detail(url): 82 """ 83 @brief Fetches and extracts detail fields from a QCC course detail page. 84 85 @details 86 Sends an HTTP GET request to the course detail URL and parses the response 87 using BeautifulSoup. Since QCC's course pages present fields as labeled 88 plain text rather than structured HTML elements, this function splits the 89 page's main content area into clean lines and scans for labeled field patterns. 90 91 Extraction rules: 92 - Credits: finds a line matching "Credits" then reads the numeric value on the next line 93 - Semesters Offered: finds a line matching "Semester(s) Offered" then reads the next line 94 - Prerequisites: finds a line matching "Prerequisites?" then reads the next line 95 - Description: selects the longest text block over 80 characters that does not 96 match navigation or label patterns 97 98 A 0.3 second delay is applied between requests in scrape_classes() to avoid 99 overloading QCC's server. 100 101 @param url The full URL of the course detail page (e.g. https://www.qcc.edu/courses/financial-accounting-i). 102 @return A dictionary with keys: 103 - "description" (str or None) 104 - "prerequisites" (str or None) 105 - "credits" (str or None) 106 - "semesters_offered" (str or None) 107 108 @note Returns a dictionary with all None values if the request fails or 109 the page structure does not match expected patterns. 110 """ 111 result = { 112 "description": None, 113 "prerequisites": None, 114 "credits": None, 115 "semesters_offered": None, 116 } 117 try: 118 resp = requests.get(url, headers=HEADERS, timeout=15) 119 if resp.status_code != 200: 120 return result 121 122 soup = BeautifulSoup(resp.text, "html.parser") 123 main = soup.find("main") or soup.find("body") 124 if not main: 125 return result 126 127 # Split into clean lines 128 raw_lines = [clean_text(line) for line in main.get_text("\n").splitlines() if clean_text(line)] 129 130 # --- Extract Credits --- 131 for i, line in enumerate(raw_lines): 132 if re.match(r"^Credits$", line, re.IGNORECASE): 133 if i + 1 < len(raw_lines): 134 val = raw_lines[i + 1] 135 if re.match(r"^\d+(\.\d+)?$", val): 136 result["credits"] = val 137 break 138 m = re.match(r"^Credits\s+(\d+(?:\.\d+)?)$", line, re.IGNORECASE) 139 if m: 140 result["credits"] = m.group(1) 141 break 142 143 # --- Extract Semesters Offered --- 144 for i, line in enumerate(raw_lines): 145 if re.match(r"^Semester[s]?\s+Offered$", line, re.IGNORECASE): 146 if i + 1 < len(raw_lines): 147 result["semesters_offered"] = raw_lines[i + 1] 148 break 149 m = re.match(r"^Semester[s]?\s+Offered\s{2,}(.+)$", line, re.IGNORECASE) 150 if m: 151 result["semesters_offered"] = clean_text(m.group(1)) 152 break 153 m2 = re.match(r"^Semester[s]?\s+Offered[:\s]+(.+)$", line, re.IGNORECASE) 154 if m2: 155 val = clean_text(m2.group(1)) 156 if len(val) > 0: 157 result["semesters_offered"] = val 158 break 159 160 # --- Extract Prerequisites --- 161 for i, line in enumerate(raw_lines): 162 if re.match(r"^Prerequisites?$", line, re.IGNORECASE): 163 if i + 1 < len(raw_lines): 164 result["prerequisites"] = raw_lines[i + 1] 165 break 166 m = re.match(r"^Prerequisites?\s{2,}(.+)$", line, re.IGNORECASE) 167 if m: 168 result["prerequisites"] = clean_text(m.group(1)) 169 break 170 m2 = re.match(r"^Prerequisites?[:\s]+(.+)$", line, re.IGNORECASE) 171 if m2: 172 val = clean_text(m2.group(1)) 173 if len(val) > 2: 174 result["prerequisites"] = val 175 break 176 177 # --- Extract Description --- 178 label_pattern = re.compile( 179 r"^(Area|Course Number|Semester[s]? Offered|Credits|Prerequisites?|" 180 r"Skip to|Primary|Secondary|Contact|Visit|Apply|Copyright|" 181 r"Local|Life-changing|Fulltext|Open Menu|Open Search)", 182 re.IGNORECASE 183 ) 184 content = main.find("article") or main 185 text_blocks = [] 186 for el in content.find_all(["p", "div", "span"]): 187 t = clean_text(el.get_text()) 188 if t: 189 text_blocks.append(t) 190 191 candidates = [t for t in text_blocks if len(t) > 80 and not label_pattern.match(t)] 192 if candidates: 193 result["description"] = max(candidates, key=len) 194 195 except Exception: 196 pass 197 198 return result 199 200 201def scrape_classes(): 202 """ 203 @brief Scrapes the QCC classes listing page and fetches details for each course. 204 205 @details 206 Sends a GET request to CLASSES_URL and parses the response to build an 207 initial list of courses with their codes, names, and URLs. Then iterates 208 through each course, calls fetch_course_detail() to populate the detail 209 fields, and applies a 0.3 second polite delay between requests. 210 211 The classes listing page uses <h2> tags for department headings and <tr> 212 table rows for individual course entries. The course code is in the first 213 cell and the course name link is in the second cell. 214 215 @return A list of course dictionaries with fields: 216 course_code, department, name, url, description, 217 prerequisites, credits, semesters_offered, scraped_at. 218 """ 219 print(f"Fetching {CLASSES_URL}...") 220 resp = requests.get(CLASSES_URL, headers=HEADERS, timeout=30) 221 soup = BeautifulSoup(resp.text, "html.parser") 222 223 courses = [] 224 current_dept = None 225 226 main = soup.find("main") or soup.find("body") 227 228 for el in main.find_all(["h2", "tr"]): 229 if el.name == "h2": 230 current_dept = clean_text(el.get_text()) 231 232 elif el.name == "tr": 233 cells = el.find_all("td") 234 if len(cells) < 2: 235 continue 236 237 code = clean_text(cells[0].get_text()) 238 if not re.match(r"[A-Z]{2,4}\s+\d+", code): 239 continue 240 241 link = cells[1].find("a") 242 if not link: 243 continue 244 245 name = clean_text(link.get_text()) 246 href = link.get("href", "") 247 full_url = BASE_URL + href if href.startswith("/") else href 248 249 courses.append({ 250 "course_code": code, 251 "department": current_dept, 252 "name": name, 253 "url": full_url, 254 "description": None, 255 "prerequisites": None, 256 "credits": None, 257 "semesters_offered": None, 258 "scraped_at": datetime.now().isoformat(), 259 }) 260 261 print(f"Found {len(courses)} courses. Now fetching detail pages...\n") 262 263 for i, course in enumerate(courses): 264 print(f"[{i+1}/{len(courses)}] {course['course_code']} — {course['name']}") 265 detail = fetch_course_detail(course["url"]) 266 course["description"] = detail["description"] 267 course["prerequisites"] = detail["prerequisites"] 268 course["credits"] = detail["credits"] 269 course["semesters_offered"] = detail["semesters_offered"] 270 time.sleep(0.3) 271 272 return courses 273 274 275def main(): 276 """ 277 @brief Entry point — runs the classes scraper and saves results to JSON. 278 279 @details 280 Calls scrape_classes() to collect all course data, writes the results 281 to qcc_classes.json, and prints a summary including total courses scraped, 282 credits populated count, semesters offered populated count, and elapsed time. 283 """ 284 start = datetime.now() 285 print("=" * 50) 286 print(" QCC Classes Page Scraper") 287 print(f" {start.strftime('%Y-%m-%d %H:%M:%S')}") 288 print("=" * 50) 289 290 courses = scrape_classes() 291 292 with open(OUTPUT, "w", encoding="utf-8") as f: 293 json.dump(courses, f, indent=2) 294 295 elapsed = (datetime.now() - start).seconds 296 filled_credits = sum(1 for c in courses if c["credits"] is not None) 297 filled_semesters = sum(1 for c in courses if c["semesters_offered"] is not None) 298 print(f"\n✅ Done in {elapsed}s") 299 print(f" Scraped {len(courses)} courses") 300 print(f" Credits populated: {filled_credits}/{len(courses)}") 301 print(f" Semesters offered populated: {filled_semesters}/{len(courses)}") 302 print(f" Saved to {OUTPUT}") 303 print("=" * 50) 304 305 306if __name__ == "__main__": 307 main()
72def clean_text(text): 73 """ 74 @brief Removes excess whitespace from a string. 75 76 @param text The raw string to clean. 77 @return A normalized single-line string with no extra whitespace. 78 """ 79 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.
82def fetch_course_detail(url): 83 """ 84 @brief Fetches and extracts detail fields from a QCC course detail page. 85 86 @details 87 Sends an HTTP GET request to the course detail URL and parses the response 88 using BeautifulSoup. Since QCC's course pages present fields as labeled 89 plain text rather than structured HTML elements, this function splits the 90 page's main content area into clean lines and scans for labeled field patterns. 91 92 Extraction rules: 93 - Credits: finds a line matching "Credits" then reads the numeric value on the next line 94 - Semesters Offered: finds a line matching "Semester(s) Offered" then reads the next line 95 - Prerequisites: finds a line matching "Prerequisites?" then reads the next line 96 - Description: selects the longest text block over 80 characters that does not 97 match navigation or label patterns 98 99 A 0.3 second delay is applied between requests in scrape_classes() to avoid 100 overloading QCC's server. 101 102 @param url The full URL of the course detail page (e.g. https://www.qcc.edu/courses/financial-accounting-i). 103 @return A dictionary with keys: 104 - "description" (str or None) 105 - "prerequisites" (str or None) 106 - "credits" (str or None) 107 - "semesters_offered" (str or None) 108 109 @note Returns a dictionary with all None values if the request fails or 110 the page structure does not match expected patterns. 111 """ 112 result = { 113 "description": None, 114 "prerequisites": None, 115 "credits": None, 116 "semesters_offered": None, 117 } 118 try: 119 resp = requests.get(url, headers=HEADERS, timeout=15) 120 if resp.status_code != 200: 121 return result 122 123 soup = BeautifulSoup(resp.text, "html.parser") 124 main = soup.find("main") or soup.find("body") 125 if not main: 126 return result 127 128 # Split into clean lines 129 raw_lines = [clean_text(line) for line in main.get_text("\n").splitlines() if clean_text(line)] 130 131 # --- Extract Credits --- 132 for i, line in enumerate(raw_lines): 133 if re.match(r"^Credits$", line, re.IGNORECASE): 134 if i + 1 < len(raw_lines): 135 val = raw_lines[i + 1] 136 if re.match(r"^\d+(\.\d+)?$", val): 137 result["credits"] = val 138 break 139 m = re.match(r"^Credits\s+(\d+(?:\.\d+)?)$", line, re.IGNORECASE) 140 if m: 141 result["credits"] = m.group(1) 142 break 143 144 # --- Extract Semesters Offered --- 145 for i, line in enumerate(raw_lines): 146 if re.match(r"^Semester[s]?\s+Offered$", line, re.IGNORECASE): 147 if i + 1 < len(raw_lines): 148 result["semesters_offered"] = raw_lines[i + 1] 149 break 150 m = re.match(r"^Semester[s]?\s+Offered\s{2,}(.+)$", line, re.IGNORECASE) 151 if m: 152 result["semesters_offered"] = clean_text(m.group(1)) 153 break 154 m2 = re.match(r"^Semester[s]?\s+Offered[:\s]+(.+)$", line, re.IGNORECASE) 155 if m2: 156 val = clean_text(m2.group(1)) 157 if len(val) > 0: 158 result["semesters_offered"] = val 159 break 160 161 # --- Extract Prerequisites --- 162 for i, line in enumerate(raw_lines): 163 if re.match(r"^Prerequisites?$", line, re.IGNORECASE): 164 if i + 1 < len(raw_lines): 165 result["prerequisites"] = raw_lines[i + 1] 166 break 167 m = re.match(r"^Prerequisites?\s{2,}(.+)$", line, re.IGNORECASE) 168 if m: 169 result["prerequisites"] = clean_text(m.group(1)) 170 break 171 m2 = re.match(r"^Prerequisites?[:\s]+(.+)$", line, re.IGNORECASE) 172 if m2: 173 val = clean_text(m2.group(1)) 174 if len(val) > 2: 175 result["prerequisites"] = val 176 break 177 178 # --- Extract Description --- 179 label_pattern = re.compile( 180 r"^(Area|Course Number|Semester[s]? Offered|Credits|Prerequisites?|" 181 r"Skip to|Primary|Secondary|Contact|Visit|Apply|Copyright|" 182 r"Local|Life-changing|Fulltext|Open Menu|Open Search)", 183 re.IGNORECASE 184 ) 185 content = main.find("article") or main 186 text_blocks = [] 187 for el in content.find_all(["p", "div", "span"]): 188 t = clean_text(el.get_text()) 189 if t: 190 text_blocks.append(t) 191 192 candidates = [t for t in text_blocks if len(t) > 80 and not label_pattern.match(t)] 193 if candidates: 194 result["description"] = max(candidates, key=len) 195 196 except Exception: 197 pass 198 199 return result
@brief Fetches and extracts detail fields from a QCC course detail page.
@details Sends an HTTP GET request to the course detail URL and parses the response using BeautifulSoup. Since QCC's course pages present fields as labeled plain text rather than structured HTML elements, this function splits the page's main content area into clean lines and scans for labeled field patterns.
Extraction rules:
- Credits: finds a line matching "Credits" then reads the numeric value on the next line
- Semesters Offered: finds a line matching "Semester(s) Offered" then reads the next line
- Prerequisites: finds a line matching "Prerequisites?" then reads the next line
- Description: selects the longest text block over 80 characters that does not match navigation or label patterns
A 0.3 second delay is applied between requests in scrape_classes() to avoid overloading QCC's server.
@param url The full URL of the course detail page (e.g. https://www.qcc.edu/courses/financial-accounting-i). @return A dictionary with keys: - "description" (str or None) - "prerequisites" (str or None) - "credits" (str or None) - "semesters_offered" (str or None)
@note Returns a dictionary with all None values if the request fails or the page structure does not match expected patterns.
202def scrape_classes(): 203 """ 204 @brief Scrapes the QCC classes listing page and fetches details for each course. 205 206 @details 207 Sends a GET request to CLASSES_URL and parses the response to build an 208 initial list of courses with their codes, names, and URLs. Then iterates 209 through each course, calls fetch_course_detail() to populate the detail 210 fields, and applies a 0.3 second polite delay between requests. 211 212 The classes listing page uses <h2> tags for department headings and <tr> 213 table rows for individual course entries. The course code is in the first 214 cell and the course name link is in the second cell. 215 216 @return A list of course dictionaries with fields: 217 course_code, department, name, url, description, 218 prerequisites, credits, semesters_offered, scraped_at. 219 """ 220 print(f"Fetching {CLASSES_URL}...") 221 resp = requests.get(CLASSES_URL, headers=HEADERS, timeout=30) 222 soup = BeautifulSoup(resp.text, "html.parser") 223 224 courses = [] 225 current_dept = None 226 227 main = soup.find("main") or soup.find("body") 228 229 for el in main.find_all(["h2", "tr"]): 230 if el.name == "h2": 231 current_dept = clean_text(el.get_text()) 232 233 elif el.name == "tr": 234 cells = el.find_all("td") 235 if len(cells) < 2: 236 continue 237 238 code = clean_text(cells[0].get_text()) 239 if not re.match(r"[A-Z]{2,4}\s+\d+", code): 240 continue 241 242 link = cells[1].find("a") 243 if not link: 244 continue 245 246 name = clean_text(link.get_text()) 247 href = link.get("href", "") 248 full_url = BASE_URL + href if href.startswith("/") else href 249 250 courses.append({ 251 "course_code": code, 252 "department": current_dept, 253 "name": name, 254 "url": full_url, 255 "description": None, 256 "prerequisites": None, 257 "credits": None, 258 "semesters_offered": None, 259 "scraped_at": datetime.now().isoformat(), 260 }) 261 262 print(f"Found {len(courses)} courses. Now fetching detail pages...\n") 263 264 for i, course in enumerate(courses): 265 print(f"[{i+1}/{len(courses)}] {course['course_code']} — {course['name']}") 266 detail = fetch_course_detail(course["url"]) 267 course["description"] = detail["description"] 268 course["prerequisites"] = detail["prerequisites"] 269 course["credits"] = detail["credits"] 270 course["semesters_offered"] = detail["semesters_offered"] 271 time.sleep(0.3) 272 273 return courses
@brief Scrapes the QCC classes listing page and fetches details for each course.
@details Sends a GET request to CLASSES_URL and parses the response to build an initial list of courses with their codes, names, and URLs. Then iterates through each course, calls fetch_course_detail() to populate the detail fields, and applies a 0.3 second polite delay between requests.
The classes listing page uses
tags for department headings and
table rows for individual course entries. The course code is in the first
cell and the course name link is in the second cell.
@return A list of course dictionaries with fields:
course_code, department, name, url, description,
prerequisites, credits, semesters_offered, scraped_at.
def
main():
276def main():
277 """
278 @brief Entry point — runs the classes scraper and saves results to JSON.
279
280 @details
281 Calls scrape_classes() to collect all course data, writes the results
282 to qcc_classes.json, and prints a summary including total courses scraped,
283 credits populated count, semesters offered populated count, and elapsed time.
284 """
285 start = datetime.now()
286 print("=" * 50)
287 print(" QCC Classes Page Scraper")
288 print(f" {start.strftime('%Y-%m-%d %H:%M:%S')}")
289 print("=" * 50)
290
291 courses = scrape_classes()
292
293 with open(OUTPUT, "w", encoding="utf-8") as f:
294 json.dump(courses, f, indent=2)
295
296 elapsed = (datetime.now() - start).seconds
297 filled_credits = sum(1 for c in courses if c["credits"] is not None)
298 filled_semesters = sum(1 for c in courses if c["semesters_offered"] is not None)
299 print(f"\n✅ Done in {elapsed}s")
300 print(f" Scraped {len(courses)} courses")
301 print(f" Credits populated: {filled_credits}/{len(courses)}")
302 print(f" Semesters offered populated: {filled_semesters}/{len(courses)}")
303 print(f" Saved to {OUTPUT}")
304 print("=" * 50)
@brief Entry point — runs the classes scraper and saves results to JSON.
@details
Calls scrape_classes() to collect all course data, writes the results
to qcc_classes.json, and prints a summary including total courses scraped,
credits populated count, semesters offered populated count, and elapsed time.
@return A list of course dictionaries with fields: course_code, department, name, url, description, prerequisites, credits, semesters_offered, scraped_at.
276def main(): 277 """ 278 @brief Entry point — runs the classes scraper and saves results to JSON. 279 280 @details 281 Calls scrape_classes() to collect all course data, writes the results 282 to qcc_classes.json, and prints a summary including total courses scraped, 283 credits populated count, semesters offered populated count, and elapsed time. 284 """ 285 start = datetime.now() 286 print("=" * 50) 287 print(" QCC Classes Page Scraper") 288 print(f" {start.strftime('%Y-%m-%d %H:%M:%S')}") 289 print("=" * 50) 290 291 courses = scrape_classes() 292 293 with open(OUTPUT, "w", encoding="utf-8") as f: 294 json.dump(courses, f, indent=2) 295 296 elapsed = (datetime.now() - start).seconds 297 filled_credits = sum(1 for c in courses if c["credits"] is not None) 298 filled_semesters = sum(1 for c in courses if c["semesters_offered"] is not None) 299 print(f"\n✅ Done in {elapsed}s") 300 print(f" Scraped {len(courses)} courses") 301 print(f" Credits populated: {filled_credits}/{len(courses)}") 302 print(f" Semesters offered populated: {filled_semesters}/{len(courses)}") 303 print(f" Saved to {OUTPUT}") 304 print("=" * 50)
@brief Entry point — runs the classes scraper and saves results to JSON.
@details Calls scrape_classes() to collect all course data, writes the results to qcc_classes.json, and prints a summary including total courses scraped, credits populated count, semesters offered populated count, and elapsed time.