import { firefox } from 'playwright'; import type { Browser, BrowserContext, Page } from 'playwright'; import { existsSync, mkdirSync, writeFileSync } from 'fs'; import pino from 'pino'; import express from 'express'; const app = express(); app.use(express.json()); const logger = pino({ transport: { target: "pino-pretty", options: { colorize: true, }, }, }); const OUTPUT_DIR = 'output'; const TARGET_CATEGORIES = ['NW1', 'NW2', 'SE1', 'NW3', 'NW4', 'SE2']; // Browser pool to reuse browser instances let sharedBrowser: Browser | null = null; const activeSessions = new Set(); async function getSharedBrowser(): Promise { if (!sharedBrowser) { logger.info('Creating shared browser instance...'); sharedBrowser = await firefox.launch({ headless: true, firefoxUserPrefs: { 'dom.webdriver.enabled': false, 'media.peerconnection.enabled': false, 'useAutomationExtension': false, }, }); // Handle browser disconnection sharedBrowser.on('disconnected', () => { logger.warn('Browser disconnected, clearing shared instance'); sharedBrowser = null; }); } return sharedBrowser; } // API endpoint to fetch grades app.post('/fetch-grades', async (req, res) => { const { username, password } = req.body; if (!username || !password) { return res.status(400).json({ error: 'Username and password are required', message: 'Please provide both username and password in request body' }); } // Check if user already has an active session if (activeSessions.has(username)) { return res.status(429).json({ success: false, error: 'Request already in progress', message: 'Please wait for your previous request to complete' }); } logger.info(`Received request to fetch grades for user: ${username}`); // Mark session as active activeSessions.add(username); try { const grades = await runSkywardBot(username, password); writeFileSync(`${OUTPUT_DIR}/grades_${username}_${Date.now()}.json`, JSON.stringify(grades, null, 2)); res.json({ success: true, totalClasses: grades.length, grades: grades }); } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); logger.error(`Error fetching grades: ${errorMsg}`); res.status(500).json({ success: false, error: errorMsg, message: 'Failed to fetch grades from Skyward' }); } finally { // Remove session from active sessions activeSessions.delete(username); } }); // Health check endpoint app.get('/health', (req, res) => { res.json({ status: 'ok', message: 'Skyward API is running', activeSessions: activeSessions.size }); }); const PORT = process.env.PORT || 3000; app.listen(PORT, () => { logger.info(`šŸš€ Skyward API server running on port ${PORT}`); logger.info(`šŸ“” POST /fetch-grades - Fetch grades (requires username and password)`); logger.info(`šŸ’š GET /health - Health check`); }); app.post('/check-auth', async (req, res) => { const { username, password } = req.body; if (!username || !password) { return res.status(400).json({ success: false, error: 'Username and password required', }); } let context: BrowserContext | null = null; let page: Page | null = null; try { const browser = await getSharedBrowser(); context = await browser.newContext({ userAgent: 'Mozilla/5.0 (X11; Linux x86_64; rv:117.0) Gecko/20100101 Firefox/117.0', viewport: { width: 1280, height: 720 }, }); page = await context.newPage(); const authenticated = await checkAuth(page, username, password, context, []); if (!authenticated) { return res.status(401).json({ success: false, error: 'Authentication failed', }); } return res.json({ success: true }); } catch (err) { const msg = err instanceof Error ? err.message : String(err); return res.status(401).json({ success: false, error: msg, }); } finally { if (page && !page.isClosed()) { await page.close().catch(() => {}); } if (context) { await context.close().catch(() => {}); } } }); const runSkywardBot = async (username: string, password: string) => { const allClassGrades: { className: string; teacher: string; period: string; category: string; grades: Array<{ name: string; dueDate: string; score: string; attempts: string; isMajorGrade: boolean; }>; }[] = []; logger.info('Starting Skyward bot with Firefox on Linux...'); // Create screenshots directory if it doesn't exist if (!existsSync(OUTPUT_DIR)) { mkdirSync(OUTPUT_DIR); } const browser = await getSharedBrowser(); let context: BrowserContext | null = null; let page: Page | null = null; try { context = await browser.newContext({ userAgent: 'Mozilla/5.0 (X11; Linux x86_64; rv:117.0) Gecko/20100101 Firefox/117.0', viewport: { width: 1280, height: 720 }, }); page = await context.newPage(); await loginToSkyward(page, username, password, context, allClassGrades); logger.info('Waiting 3 seconds before finishing...'); await page.waitForTimeout(3000); return allClassGrades; } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); logger.error(`Fatal error in main: ${errorMsg}`); throw error; } finally { // Clean up page and context but NOT the browser if (page && !page.isClosed()) { await page.close().catch((err) => logger.warn('Error closing page:', err)); } if (context) { await context.close().catch((err) => logger.warn('Error closing context:', err)); } logger.info('Session cleaned up, browser kept alive.'); } }; async function checkAuth( page: Page, username: string, password: string, context: any, allClassGrades: any[] ): Promise { logger.info('Starting login process...'); // Step 1: Navigate to ClassLink launchpad logger.info('Step 1: Navigating to ClassLink launchpad...'); await page.goto('https://launchpad.classlink.com/pisd', { waitUntil: 'domcontentloaded', timeout: 30000, }); await page.waitForTimeout(2000); // Step 2: Click "PISD Network Login" button logger.info('Step 2: Looking for PISD Network Login button...'); const pisdLoginButtonSelectors = [ 'button.saml.UseTMS:has-text("PISD Network Login")', 'button[title="Sign in with SAML"]:has-text("PISD Network Login")', 'button.btn-primary:has-text("PISD Network Login")', 'button:has-text("PISD Network Login")', ]; let pisdButton = null; for (const selector of pisdLoginButtonSelectors) { try { pisdButton = await page.$(selector); if (pisdButton) { const isVisible = await pisdButton.isVisible(); if (isVisible) { logger.info(`Found PISD Network Login button with selector: ${selector}`); break; } } } catch (err) { continue; } } if (!pisdButton) { logger.error('Could not find PISD Network Login button'); throw new Error('PISD Network Login button not found'); } await pisdButton.click(); logger.info('Clicked PISD Network Login button'); await page.waitForTimeout(2000); // Step 3: Enter username logger.info('Step 3: Entering username...'); const usernameSelector = 'input#userNameInput'; await page.waitForSelector(usernameSelector, { state: 'visible', timeout: 15000 }); await page.click(usernameSelector); await page.waitForTimeout(500); await page.type(usernameSelector, username, { delay: 100 }); logger.info('Username entered'); // Step 4: Enter password logger.info('Step 4: Entering password...'); const passwordSelector = 'input#passwordInput'; await page.waitForSelector(passwordSelector, { state: 'visible', timeout: 15000 }); await page.click(passwordSelector); await page.waitForTimeout(500); await page.type(passwordSelector, password, { delay: 100 }); logger.info('Password entered'); // Step 5: Click submit button logger.info('Step 5: Clicking submit button...'); await page.waitForTimeout(1000); const submitSelectors = [ 'input#submitButton', 'button#submitButton', 'input[type="submit"]', 'button[type="submit"]', 'input[value="Sign in"]', 'button:has-text("Sign in")', 'text="Sign in"', '[type="submit"]', '.submit-button', 'button.btn', 'input.btn', ]; let submitButton = null; for (const selector of submitSelectors) { try { logger.info(`Trying selector: ${selector}`); submitButton = await page.$(selector); if (submitButton) { const isVisible = await submitButton.isVisible(); const isEnabled = await submitButton.isEnabled(); logger.info(`Found element with ${selector} - visible: ${isVisible}, enabled: ${isEnabled}`); if (isVisible && isEnabled) { logger.info(`Found submit button with selector: ${selector}`); break; } else { submitButton = null; } } } catch (err) { logger.warn(`Selector ${selector} failed: ${err}`); continue; } } // If still not found, try getting all buttons and find one with "Sign in" text if (!submitButton) { logger.info('Trying to find button by text content...'); const allButtons = await page.$$('button, input[type="submit"]'); for (const btn of allButtons) { try { const text = await btn.textContent(); const value = await btn.getAttribute('value'); const ariaLabel = await btn.getAttribute('aria-label'); logger.info(`Button found - text: "${text}", value: "${value}", aria-label: "${ariaLabel}"`); if ( (text && text.toLowerCase().includes('sign in')) || (value && value.toLowerCase().includes('sign in')) || (ariaLabel && ariaLabel.toLowerCase().includes('sign in')) ) { const isVisible = await btn.isVisible(); const isEnabled = await btn.isEnabled(); if (isVisible && isEnabled) { submitButton = btn; logger.info(`Found submit button by text content`); break; } } } catch (err) { continue; } } } if (!submitButton) { logger.error('Could not find submit button'); throw new Error('Submit button not found'); } await submitButton.click(); logger.info('Submit button clicked'); await page.waitForTimeout(3000); // Wait for redirect back to launchpad logger.info('Waiting for redirect to launchpad...'); await page.waitForTimeout(3000); const currentUrl = page.url(); logger.info(`Current URL after redirect: ${currentUrl}`); try { await page.waitForURL('**/myapps.classlink.com/**', { timeout: 10000 }); logger.info('Successfully matched myapps.classlink.com URL pattern'); return true; } catch (err) { logger.warn(`URL pattern didn't match, but we're at: ${page.url()}`); logger.warn('Continuing anyway...'); return false; } } async function loginToSkyward(page: Page, username: string, password: string, context: any, allClassGrades: any[]) { logger.info('Starting login process...'); // Step 1: Navigate to ClassLink launchpad logger.info('Step 1: Navigating to ClassLink launchpad...'); await page.goto('https://launchpad.classlink.com/pisd', { waitUntil: 'domcontentloaded', timeout: 30000, }); await page.waitForTimeout(2000); // Step 2: Click "PISD Network Login" button logger.info('Step 2: Looking for PISD Network Login button...'); const pisdLoginButtonSelectors = [ 'button.saml.UseTMS:has-text("PISD Network Login")', 'button[title="Sign in with SAML"]:has-text("PISD Network Login")', 'button.btn-primary:has-text("PISD Network Login")', 'button:has-text("PISD Network Login")', ]; let pisdButton = null; for (const selector of pisdLoginButtonSelectors) { try { pisdButton = await page.$(selector); if (pisdButton) { const isVisible = await pisdButton.isVisible(); if (isVisible) { logger.info(`Found PISD Network Login button with selector: ${selector}`); break; } } } catch (err) { continue; } } if (!pisdButton) { logger.error('Could not find PISD Network Login button'); throw new Error('PISD Network Login button not found'); } await pisdButton.click(); logger.info('Clicked PISD Network Login button'); await page.waitForTimeout(2000); // Step 3: Enter username logger.info('Step 3: Entering username...'); const usernameSelector = 'input#userNameInput'; await page.waitForSelector(usernameSelector, { state: 'visible', timeout: 15000 }); await page.click(usernameSelector); await page.waitForTimeout(500); await page.type(usernameSelector, username, { delay: 100 }); logger.info('Username entered'); // Step 4: Enter password logger.info('Step 4: Entering password...'); const passwordSelector = 'input#passwordInput'; await page.waitForSelector(passwordSelector, { state: 'visible', timeout: 15000 }); await page.click(passwordSelector); await page.waitForTimeout(500); await page.type(passwordSelector, password, { delay: 100 }); logger.info('Password entered'); // Step 5: Click submit button logger.info('Step 5: Clicking submit button...'); await page.waitForTimeout(1000); const submitSelectors = [ 'input#submitButton', 'button#submitButton', 'input[type="submit"]', 'button[type="submit"]', 'input[value="Sign in"]', 'button:has-text("Sign in")', 'text="Sign in"', '[type="submit"]', '.submit-button', 'button.btn', 'input.btn', ]; let submitButton = null; for (const selector of submitSelectors) { try { logger.info(`Trying selector: ${selector}`); submitButton = await page.$(selector); if (submitButton) { const isVisible = await submitButton.isVisible(); const isEnabled = await submitButton.isEnabled(); logger.info(`Found element with ${selector} - visible: ${isVisible}, enabled: ${isEnabled}`); if (isVisible && isEnabled) { logger.info(`Found submit button with selector: ${selector}`); break; } else { submitButton = null; } } } catch (err) { logger.warn(`Selector ${selector} failed: ${err}`); continue; } } if (!submitButton) { logger.info('Trying to find button by text content...'); const allButtons = await page.$$('button, input[type="submit"]'); for (const btn of allButtons) { try { const text = await btn.textContent(); const value = await btn.getAttribute('value'); const ariaLabel = await btn.getAttribute('aria-label'); logger.info(`Button found - text: "${text}", value: "${value}", aria-label: "${ariaLabel}"`); if ( (text && text.toLowerCase().includes('sign in')) || (value && value.toLowerCase().includes('sign in')) || (ariaLabel && ariaLabel.toLowerCase().includes('sign in')) ) { const isVisible = await btn.isVisible(); const isEnabled = await btn.isEnabled(); if (isVisible && isEnabled) { submitButton = btn; logger.info(`Found submit button by text content`); break; } } } catch (err) { continue; } } } if (!submitButton) { logger.error('Could not find submit button'); throw new Error('Submit button not found'); } await submitButton.click(); logger.info('Submit button clicked'); await page.waitForTimeout(3000); logger.info('Waiting for redirect to launchpad...'); await page.waitForTimeout(3000); const currentUrl = page.url(); logger.info(`Current URL after redirect: ${currentUrl}`); try { await page.waitForURL('**/myapps.classlink.com/**', { timeout: 10000 }); logger.info('Successfully matched myapps.classlink.com URL pattern'); } catch (err) { logger.warn(`URL pattern didn't match, but we're at: ${page.url()}`); logger.warn('Continuing anyway...'); } await page.waitForTimeout(2000); await openSkywardFromLaunchpad(page, context, allClassGrades); } async function openSkywardFromLaunchpad(page: Page, context: any, allClassGrades: any[]) { let newPage: Page | null = null; try { logger.info('Step 6: Searching for Skyward app...'); const searchSelectors = [ 'input[aria-label="Search All Apps"]', 'input#searchbar-filter-input', 'input[data-cy="Input-Input"]', 'input[placeholder*="Search"]', 'input[type="search"]', 'input#appsSearch', 'input.search-input', ]; let searchInput = null; for (const selector of searchSelectors) { try { logger.info(`Trying search selector: ${selector}`); searchInput = await page.$(selector); if (searchInput) { const isVisible = await searchInput.isVisible(); if (isVisible) { logger.info(`Found search input with selector: ${selector}`); break; } } } catch (err) { continue; } } if (!searchInput) { logger.error('Could not find search input'); throw new Error('Search input not found'); } await searchInput.click(); await page.waitForTimeout(500); await searchInput.type('Skyward', { delay: 100 }); logger.info('Typed "Skyward" into search'); await page.waitForTimeout(1000); logger.info('Step 7: Pressing Enter to select Skyward...'); const newPagePromise = new Promise((resolve) => { context.once('page', async (np: Page) => { logger.info(`New tab opened with URL: ${np.url()}`); resolve(np); }); }); await searchInput.press('Enter'); logger.info('āœ“ Pressed Enter'); logger.info('Waiting for new tab to open...'); newPage = await Promise.race([ newPagePromise, page.waitForTimeout(10000).then(() => null), ]); if (!newPage) { logger.error('No new tab opened'); throw new Error('New tab did not open'); } logger.info(`New tab URL: ${newPage.url()}`); await newPage.waitForLoadState('domcontentloaded'); await newPage.waitForTimeout(3000); const finalUrl = newPage.url(); logger.info(`Final URL in new tab: ${finalUrl}`); if (finalUrl.includes('skyward')) { logger.info('āœ“ Successfully opened Skyward in new tab'); } else { logger.warn(`New tab URL doesn't contain 'skyward': ${finalUrl}`); logger.info('Continuing anyway...'); } await navigateToGrading(newPage, allClassGrades); } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); logger.error(`Error opening Skyward from launchpad: ${errorMsg}`); throw error; } finally { // Always close the Skyward tab to prevent resource leaks if (newPage && !newPage.isClosed()) { await newPage.close().catch((err) => logger.warn('Error closing Skyward tab:', err) ); logger.info('Closed Skyward tab'); } } } async function navigateToGrading(page: Page, allClassGrades: any[]) { logger.info('Step 8: Navigating to Grading section...'); await page.waitForTimeout(3000); const gradingSelectors = [ 'text="Grading"', 'a:has-text("Grading")', 'button:has-text("Grading")', 'div:has-text("Grading")', '[role="link"]:has-text("Grading")', 'span:has-text("Grading")', ]; let gradingElement = null; for (const selector of gradingSelectors) { try { const elements = await page.$$(selector); for (const element of elements) { const text = (await element.textContent())?.trim(); if (text === 'Grading') { const isVisible = await element.isVisible(); if (isVisible) { logger.info(`Found Grading element with selector: ${selector}`); gradingElement = element; break; } } } if (gradingElement) break; } catch (err) { continue; } } if (!gradingElement) { logger.error('Could not find Grading element with exact text'); throw new Error('Grading element not found'); } await gradingElement.scrollIntoViewIfNeeded(); await page.waitForTimeout(500); await gradingElement.click(); logger.info('Clicked Grading element'); await page.waitForTimeout(3000); logger.info('Successfully navigated to Grading page'); logger.info('Step 9: Finding grades table...'); await page.waitForTimeout(2000); const tableBody = await page.$('.unlockedBrowseBody.dynamicHeight'); if (!tableBody) { logger.error('Could not find grades table with class "unlockedBrowseBody dynamicHeight"'); throw new Error('Grades table not found'); } logger.info('āœ“ Found grades table'); const gradeCells = await tableBody.$$('a.linkPanelButton'); logger.info(`Found ${gradeCells.length} clickable grade cells`); if (gradeCells.length === 0) { logger.warn('No clickable grade cells found'); return; } for (let i = 0; i < gradeCells.length; i++) { try { // Check if page is still open if (page.isClosed()) { logger.error('Page was closed during processing'); break; } logger.info(`\n=== Processing grade cell ${i + 1}/${gradeCells.length} ===`); const allCells = await tableBody.$$('a.linkPanelButton'); const cell = allCells[i]; if (!cell) { logger.warn(` Cell ${i + 1} not found, skipping`); continue; } const gradeText = (await cell.textContent())?.trim(); const isVisible = await cell.isVisible(); const dataRow = await cell.getAttribute('data-row'); const dataColumn = await cell.getAttribute('data-column'); const primaryKey = await cell.getAttribute('data-primarykey'); logger.info(` Grade: "${gradeText}"`); logger.info(` Row: ${dataRow}, Column: ${dataColumn}`); logger.info(` Primary Key: ${primaryKey}`); logger.info(` Visible: ${isVisible}`); if (!isVisible) { logger.warn(` Cell ${i + 1} not visible, skipping`); continue; } await cell.scrollIntoViewIfNeeded(); await page.waitForTimeout(500); await cell.click(); logger.info(` āœ“ Clicked grade cell`); await page.waitForTimeout(2000); const gradePercentageLabel = await page.$('label.labelTag.visualLabel.header'); let overallGrade = ''; if (gradePercentageLabel) { const percentageText = (await gradePercentageLabel.textContent())?.trim(); overallGrade = percentageText || ''; logger.info(` šŸ“Š Grade Percentage: ${percentageText}`); } else { logger.info(` ℹ No grade percentage label found`); } const categoryContainer = await page.$('span.SegmentedAssignmentsInformationCell:not(.left) label.labelTag.visualLabel'); if (!categoryContainer) { logger.info(` ℹ No category label found in SegmentedAssignmentsInformationCell, skipping`); } else { const categoryText = (await categoryContainer.textContent())?.trim(); logger.info(` Category label found: "${categoryText}"`); if (categoryText && TARGET_CATEGORIES.includes(categoryText)) { logger.info(` āœ“ Target category "${categoryText}" detected - reading grades`); const classInfoSpan = await page.$('span.SegmentedAssignmentsInformationCell.left'); let className = ''; let teacher = ''; let period = ''; if (classInfoSpan) { const classLabel = await classInfoSpan.$('label.labelTag.visualLabel'); if (classLabel) { className = (await classLabel.textContent())?.trim() || ''; logger.info(` Class: "${className}"`); } const teacherLink = await classInfoSpan.$('a span.anchorText'); if (teacherLink) { teacher = (await teacherLink.textContent())?.trim() || ''; logger.info(` Teacher: "${teacher}"`); } const labels = await classInfoSpan.$$('label.labelTag.visualLabel'); if (labels.length >= 2 && labels[1]) { period = (await labels[1].textContent())?.trim() || ''; logger.info(` Period: "${period}"`); } } const detailsPanel = await page.$('#detailsPanel_SegmentedGradeBucketAssignments_unlockedBody'); if (!detailsPanel) { logger.error(` āœ— Could not find detailsPanel_SegmentedGradeBucketAssignments_unlockedBody`); } else { logger.info(` āœ“ Found details panel, extracting grades...`); const rows = await detailsPanel.$$('tr'); logger.info(` Found ${rows.length} total rows`); const grades = []; let currentGradeType = null; for (let j = 0; j < rows.length; j++) { const row = rows[j]; if (!row) continue; try { const gradeTypeSpan = await row.$('span.anchorText'); if (gradeTypeSpan) { const gradeTypeText = (await gradeTypeSpan.textContent())?.trim(); logger.info(` Row ${j}: Found span with text "${gradeTypeText}"`); if (gradeTypeText === 'Major Grades') { currentGradeType = 'major'; logger.info(` → Set current grade type to MAJOR`); continue; } else if (gradeTypeText === 'Minor Grades') { currentGradeType = 'minor'; logger.info(` → Set current grade type to MINOR`); continue; } else if (gradeTypeText === 'Semester Exam') { currentGradeType = 'minor'; logger.info(` → Semester Exam Detected → defaulting to MINOR`); continue; } } if (!currentGradeType) { logger.info(` Row ${j}: Skipping (no grade type set yet)`); currentGradeType = 'minor'; continue; } const nameLink = await row.$('a'); if (!nameLink) { logger.info(` Row ${j}: No link found, skipping`); continue; } const name = (await nameLink.textContent())?.trim() || ''; logger.info(` Row ${j}: Found assignment "${name}"`); let dueDate = ''; let score = ''; let pointsEarned = ''; let attempts = ''; const tdCells = await row.$$('td'); logger.info(` Row ${j}: Found ${tdCells.length} td cells`); if (tdCells && tdCells.length > 1 && tdCells[1]) { dueDate = (await tdCells[1].textContent())?.trim() || ''; } const disabledSpans = await row.$$('span.disabled-link-column'); logger.info(` Row ${j}: Found ${disabledSpans ? disabledSpans.length : 0} disabled-link-column spans`); if (disabledSpans && disabledSpans.length >= 1 && disabledSpans[0]) { score = (await disabledSpans[0].textContent())?.trim() || ''; } if (disabledSpans && disabledSpans.length >= 2 && disabledSpans[1]) { pointsEarned = (await disabledSpans[1].textContent())?.trim() || ''; } const allLinks = await row.$$('a'); const lastLink = allLinks && allLinks.length > 1 ? allLinks[allLinks.length - 1] : null; if (lastLink) { attempts = (await lastLink.textContent())?.trim() || ''; } const gradeEntry = { name: name, dueDate: score, score: pointsEarned, attempts: attempts, isMajorGrade: currentGradeType === 'major', }; grades.push(gradeEntry); logger.info(` āœ“ ${gradeEntry.isMajorGrade ? 'MAJOR' : 'MINOR'}: ${name} | Due: ${dueDate} | Score: ${score} | Attempts: ${attempts}`); } catch (err) { const errorMsg = err instanceof Error ? err.message : String(err); logger.warn(` Row ${j}: Error processing row: ${errorMsg}`); continue; } } logger.info(` āœ“ Extracted ${grades.length} grades for ${categoryText}`); allClassGrades.push({ className: className, teacher: teacher, period: period, category: categoryText, overallGrade: overallGrade, grades: grades, }); logger.info(` āœ“ Added grades to collection (Total classes: ${allClassGrades.length})`); } } else { logger.info(` ℹ Category "${categoryText}" not in target list, skipping`); } } const closeSelectors = [ 'button[aria-label="Close"]', 'button[aria-label*="close"]', 'button.close', 'button[data-dismiss="modal"]', 'button:has-text("Close")', '[aria-label="Close panel"]', 'button.panel-close', ]; let closed = false; for (const closeSelector of closeSelectors) { try { const closeButton = await page.$(closeSelector); if (closeButton) { const isCloseVisible = await closeButton.isVisible(); if (isCloseVisible) { await closeButton.click(); logger.info(` āœ“ Closed panel with selector: ${closeSelector}`); closed = true; await page.waitForTimeout(1000); break; } } } catch (err) { continue; } } if (!closed) { logger.info(` Trying Escape key to close panel`); await page.keyboard.press('Escape'); await page.waitForTimeout(1000); } await page.waitForTimeout(500); } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); logger.error(` āœ— Error processing grade cell ${i + 1}: ${errorMsg}`); try { await page.keyboard.press('Escape'); await page.waitForTimeout(1000); } catch (closeErr) { logger.warn(` Could not close modal: ${closeErr}`); } continue; } } logger.info(`\nšŸŽ‰ Finished processing all grade cells!`); try { const gradesFilePath = `${OUTPUT_DIR}/all_grades.json`; writeFileSync(gradesFilePath, JSON.stringify(allClassGrades, null, 2)); logger.info(`\nšŸ“„ Saved all grades to: ${gradesFilePath}`); logger.info(` Total classes processed: ${allClassGrades.length}`); } catch (err) { const errorMsg = err instanceof Error ? err.message : String(err); logger.error(`Failed to write grades file: ${errorMsg}`); } }