@@ -1,20 +1,10 @@
import { firefox } from 'playwright' ;
import type { Browser , BrowserContext , Page } from 'playwright' ;
import { existsSync , mkdirSync , writeFileSync } from 'fs' ;
import pino from 'pino' ;
import { Page } from 'playwright' ;
import express from 'express' ;
import cors from 'cors' ; // ADD THIS
const app = express ( ) ;
app . use ( cors ( {
origin : [ 'http://localhost:5173' , 'http://localhost:4000' ] ,
credentials : true ,
methods : [ 'GET' , 'POST' , 'PUT' , 'DELETE' , 'OPTIONS' ] ,
allowedHeaders : [ 'Content-Type' , 'Authorization' ]
} ) ) ;
app . use ( express . json ( ) ) ;
const logger = pino ( {
@@ -26,34 +16,9 @@ const logger = pino({
} ,
} ) ;
const OUTPUT _DIR = 'output ' ;
const SCREENSHOTS _DIR = 'skyward_screenshots ' ;
const TARGET_CATEGORIES = [ 'NW1' , 'NW2' , 'SE1' , 'NW3' , 'NW4' , 'SE2' ] ;
// Browser pool to reuse browser instances
let sharedBrowser : Browser | null = null ;
const activeSessions = new Set < string > ( ) ;
async function getSharedBrowser ( ) : Promise < Browser > {
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 ;
@@ -65,26 +30,11 @@ app.post('/fetch-grades', async (req, res) => {
} ) ;
}
// 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 ,
@@ -99,19 +49,12 @@ app.post('/fetch-grades', async (req, res) => {
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
} ) ;
res . json ( { status : 'ok' , message : 'Skyward API is running' } ) ;
} ) ;
const PORT = process . env . PORT || 3000 ;
@@ -131,17 +74,11 @@ app.post('/check-auth', async (req, res) => {
} ) ;
}
let context : BrowserContext | null = null ;
let page : Page | null = null ;
const browser = await firefox . launch ( { headless : true } ) ;
const context = await browser . newContext ( ) ;
const page = await context . newPage ( ) ;
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 ) {
@@ -160,12 +97,7 @@ app.post('/check-auth', async (req, res) => {
error : msg ,
} ) ;
} finally {
if ( page && ! page . isC losed ( ) ) {
await page . close ( ) . catch ( ( ) = > { } ) ;
}
if ( context ) {
await context . close ( ) . catch ( ( ) = > { } ) ;
}
await browser . c lose( ) ;
}
} ) ;
@@ -187,17 +119,24 @@ const runSkywardBot = async (username: string, password: string) => {
logger . info ( 'Starting Skyward bot with Firefox on Linux...' ) ;
// Create screenshots directory if it doesn't exist
if ( ! existsSync ( OUTPUT _DIR) ) {
mkdirSync ( OUTPUT _DIR) ;
if ( ! existsSync ( SCREENSHOTS _DIR) ) {
mkdirSync ( SCREENSHOTS _DIR) ;
}
const browser = await getSharedBrowser ( ) ;
let context : BrowserContext | null = null ;
let page : Page | null = null ;
const browser = await firefox . launch ( {
headless : true ,
firefoxUserPrefs : {
'dom.webdriver.enabled' : false ,
'media.peerconnection.enabled' : false ,
'useAutomationExtension' : false ,
} ,
} ) ;
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' ,
const 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 } ,
} ) ;
@@ -212,16 +151,17 @@ const runSkywardBot = async (username: string, password: string) => {
} catch ( error ) {
const errorMsg = error instanceof Error ? error.message : String ( error ) ;
logger . error ( ` Fatal error in main: ${ errorMsg } ` ) ;
if ( page ) {
// try {
// await page.screenshot({ path: `${SCREENSHOTS_DIR}/FATAL_ERROR.png` });
// } catch (screenshotError) {
// logger.warn('Could not take error screenshot');
// }
}
throw error ;
} finally {
// Clean up page and context but NOT the browser
if ( page && ! pa ge. 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.' ) ;
await browser . close ( ) ;
log ger . info ( 'Browser closed, done.' ) ;
}
} ;
@@ -241,6 +181,7 @@ async function checkAuth(
timeout : 30000 ,
} ) ;
await page . waitForTimeout ( 2000 ) ;
// await page.screenshot({ path: `${SCREENSHOTS_DIR}/01-launchpad-page.png` });
// Step 2: Click "PISD Network Login" button
logger . info ( 'Step 2: Looking for PISD Network Login button...' ) ;
@@ -275,6 +216,7 @@ async function checkAuth(
await pisdButton . click ( ) ;
logger . info ( 'Clicked PISD Network Login button' ) ;
await page . waitForTimeout ( 2000 ) ;
// await page.screenshot({ path: `${SCREENSHOTS_DIR}/02-after-pisd-button-click.png` });
// Step 3: Enter username
logger . info ( 'Step 3: Entering username...' ) ;
@@ -284,6 +226,7 @@ async function checkAuth(
await page . waitForTimeout ( 500 ) ;
await page . type ( usernameSelector , username , { delay : 100 } ) ;
logger . info ( 'Username entered' ) ;
// await page.screenshot({ path: `${SCREENSHOTS_DIR}/03-username-entered.png` });
// Step 4: Enter password
logger . info ( 'Step 4: Entering password...' ) ;
@@ -293,9 +236,12 @@ async function checkAuth(
await page . waitForTimeout ( 500 ) ;
await page . type ( passwordSelector , password , { delay : 100 } ) ;
logger . info ( 'Password entered' ) ;
// await page.screenshot({ path: `${SCREENSHOTS_DIR}/04-password-entered.png` });
// Step 5: Click submit button
logger . info ( 'Step 5: Clicking submit button...' ) ;
// First wait a bit for the page to be ready
await page . waitForTimeout ( 1000 ) ;
const submitSelectors = [
@@ -367,16 +313,18 @@ async function checkAuth(
if ( ! submitButton ) {
logger . error ( 'Could not find submit button' ) ;
// await page.screenshot({ path: `${SCREENSHOTS_DIR}/05-submit-not-found.png`, fullPage: true });
throw new Error ( 'Submit button not found' ) ;
}
await submitButton . click ( ) ;
logger . info ( 'Submit button clicked' ) ;
await page . waitForTimeout ( 3000 ) ;
// await page.screenshot({ path: `${SCREENSHOTS_DIR}/05-after-submit.png` });
// Wait for redirect back to launchpad
logger . info ( 'Waiting for redirect to launchpad...' ) ;
await page . waitForTimeout ( 3000 ) ;
await page . waitForTimeout ( 3000 ) ; // Wait a bit for redirect to happen
const currentUrl = page . url ( ) ;
logger . info ( ` Current URL after redirect: ${ currentUrl } ` ) ;
@@ -385,11 +333,15 @@ async function checkAuth(
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 [ ] ) {
@@ -402,6 +354,7 @@ async function loginToSkyward(page: Page, username: string, password: string, co
timeout : 30000 ,
} ) ;
await page . waitForTimeout ( 2000 ) ;
// await page.screenshot({ path: `${SCREENSHOTS_DIR}/01-launchpad-page.png` });
// Step 2: Click "PISD Network Login" button
logger . info ( 'Step 2: Looking for PISD Network Login button...' ) ;
@@ -436,6 +389,7 @@ async function loginToSkyward(page: Page, username: string, password: string, co
await pisdButton . click ( ) ;
logger . info ( 'Clicked PISD Network Login button' ) ;
await page . waitForTimeout ( 2000 ) ;
// await page.screenshot({ path: `${SCREENSHOTS_DIR}/02-after-pisd-button-click.png` });
// Step 3: Enter username
logger . info ( 'Step 3: Entering username...' ) ;
@@ -445,6 +399,7 @@ async function loginToSkyward(page: Page, username: string, password: string, co
await page . waitForTimeout ( 500 ) ;
await page . type ( usernameSelector , username , { delay : 100 } ) ;
logger . info ( 'Username entered' ) ;
// await page.screenshot({ path: `${SCREENSHOTS_DIR}/03-username-entered.png` });
// Step 4: Enter password
logger . info ( 'Step 4: Entering password...' ) ;
@@ -454,9 +409,12 @@ async function loginToSkyward(page: Page, username: string, password: string, co
await page . waitForTimeout ( 500 ) ;
await page . type ( passwordSelector , password , { delay : 100 } ) ;
logger . info ( 'Password entered' ) ;
// await page.screenshot({ path: `${SCREENSHOTS_DIR}/04-password-entered.png` });
// Step 5: Click submit button
logger . info ( 'Step 5: Clicking submit button...' ) ;
// First wait a bit for the page to be ready
await page . waitForTimeout ( 1000 ) ;
const submitSelectors = [
@@ -495,6 +453,7 @@ async function loginToSkyward(page: Page, username: string, password: string, co
}
}
// 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"]' ) ;
@@ -527,15 +486,18 @@ async function loginToSkyward(page: Page, username: string, password: string, co
if ( ! submitButton ) {
logger . error ( 'Could not find submit button' ) ;
// await page.screenshot({ path: `${SCREENSHOTS_DIR}/05-submit-not-found.png`, fullPage: true });
throw new Error ( 'Submit button not found' ) ;
}
await submitButton . click ( ) ;
logger . info ( 'Submit button clicked' ) ;
await page . waitForTimeout ( 3000 ) ;
// await page.screenshot({ path: `${SCREENSHOTS_DIR}/05-after-submit.png` });
// Wait for redirect back to launchpad
logger . info ( 'Waiting for redirect to launchpad...' ) ;
await page . waitForTimeout ( 3000 ) ;
await page . waitForTimeout ( 3000 ) ; // Wait a bit for redirect to happen
const currentUrl = page . url ( ) ;
logger . info ( ` Current URL after redirect: ${ currentUrl } ` ) ;
@@ -549,16 +511,17 @@ async function loginToSkyward(page: Page, username: string, password: string, co
}
await page . waitForTimeout ( 2000 ) ;
// await page.screenshot({ path: `${SCREENSHOTS_DIR}/06-back-at-launchpad.png` });
await openSkywardFromLaunchpad ( page , context , allClassGrades ) ;
}
async function openSkywardFromLaunchpad ( page : Page , context : any , allClassGrades : any [ ] ) {
let newPage : Page | null = null ;
try {
// Step 6: Search for Skyward
logger . info ( 'Step 6: Searching for Skyward app...' ) ;
// Look for search input
const searchSelectors = [
'input[aria-label="Search All Apps"]' ,
'input#searchbar-filter-input' ,
@@ -588,6 +551,7 @@ async function openSkywardFromLaunchpad(page: Page, context: any, allClassGrades
if ( ! searchInput ) {
logger . error ( 'Could not find search input' ) ;
// await page.screenshot({ path: `${SCREENSHOTS_DIR}/07-search-not-found.png`, fullPage: true });
throw new Error ( 'Search input not found' ) ;
}
@@ -596,38 +560,46 @@ async function openSkywardFromLaunchpad(page: Page, context: any, allClassGrades
await searchInput . type ( 'Skyward' , { delay : 100 } ) ;
logger . info ( 'Typed "Skyward" into search' ) ;
await page . waitForTimeout ( 1000 ) ;
// await page.screenshot({ path: `${SCREENSHOTS_DIR}/07-skyward-search.png` });
// Step 7: Press Enter to select Skyward (opens in new tab)
logger . info ( 'Step 7: Pressing Enter to select Skyward...' ) ;
// Set up listener for new tab BEFORE pressing Enter
const newPagePromise = new Promise < Page > ( ( resolve ) = > {
context . once ( 'page' , async ( np : Page ) = > {
logger . info ( ` New tab opened with URL: ${ np . url ( ) } ` ) ;
resolve ( np ) ;
context . once ( 'page' , async ( new Page ) = > {
logger . info ( ` New tab opened with URL: ${ newPage . url ( ) } ` ) ;
resolve ( newPage ) ;
} ) ;
} ) ;
await searchInput . press ( 'Enter' ) ;
logger . info ( '✓ Pressed Enter' ) ;
// Wait for new tab to open
logger . info ( 'Waiting for new tab to open...' ) ;
newPage = await Promise . race ( [
const newPage = await Promise . race ( [
newPagePromise ,
page . waitForTimeout ( 10000 ) . then ( ( ) = > null ) ,
] ) ;
if ( ! newPage ) {
logger . error ( 'No new tab opened' ) ;
// await page.screenshot({ path: `${SCREENSHOTS_DIR}/08-no-new-tab.png` });
throw new Error ( 'New tab did not open' ) ;
}
logger . info ( ` New tab URL: ${ newPage . url ( ) } ` ) ;
// Wait for the new page to load
await newPage . waitForLoadState ( 'domcontentloaded' ) ;
await newPage . waitForTimeout ( 3000 ) ;
const finalUrl = newPage . url ( ) ;
logger . info ( ` Final URL in new tab: ${ finalUrl } ` ) ;
await newPage . screenshot ( { path : ` ${ SCREENSHOTS_DIR } /08-new-tab-skyward.png ` } ) ;
// Check if we're on Skyward
if ( finalUrl . includes ( 'skyward' ) ) {
logger . info ( '✓ Successfully opened Skyward in new tab' ) ;
} else {
@@ -635,19 +607,12 @@ async function openSkywardFromLaunchpad(page: Page, context: any, allClassGrades
logger . info ( 'Continuing anyway...' ) ;
}
// Now navigate to grading on the NEW page
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' ) ;
}
}
}
@@ -655,8 +620,10 @@ async function openSkywardFromLaunchpad(page: Page, context: any, allClassGrades
async function navigateToGrading ( page : Page , allClassGrades : any [ ] ) {
logger . info ( 'Step 8: Navigating to Grading section...' ) ;
// Wait for page to fully load
await page . waitForTimeout ( 3000 ) ;
// Look for element with exact text "Grading"
const gradingSelectors = [
'text="Grading"' ,
'a:has-text("Grading")' ,
@@ -671,6 +638,7 @@ async function navigateToGrading(page: Page, allClassGrades: any[]) {
try {
const elements = await page . $ $ ( selector ) ;
// Check each element to find one with EXACT text "Grading"
for ( const element of elements ) {
const text = ( await element . textContent ( ) ) ? . trim ( ) ;
if ( text === 'Grading' ) {
@@ -691,78 +659,55 @@ async function navigateToGrading(page: Page, allClassGrades: any[]) {
if ( ! gradingElement ) {
logger . error ( 'Could not find Grading element with exact text' ) ;
// await page.screenshot({ path: `${SCREENSHOTS_DIR}/10-grading-not-found.png`, fullPage: true });
throw new Error ( 'Grading element not found' ) ;
}
// Scroll element into view
await gradingElement . scrollIntoViewIfNeeded ( ) ;
await page . waitForTimeout ( 500 ) ;
await gradingElement . click ( ) ;
logger . info ( 'Clicked Grading element' ) ;
await page . waitForTimeout ( 3000 ) ;
// await page.screenshot({ path: `${SCREENSHOTS_DIR}/10-grading-page.png`, fullPage: true });
logger . info ( 'Successfully navigated to Grading page' ) ;
// ADD THIS SECTION HERE - Click "All Year" radio button
logger . info ( 'Step 8.5: Clicking "All Year" radio button...' ) ;
try {
const allYearRadio = await page . $ ( 'input[type="radio"][name="DateRangeModechild"][value="AllYear"]' ) ;
if ( ! allYearRadio ) {
logger . warn ( 'Could not find "All Year" radio button' ) ;
} else {
const isVisible = await allYearRadio . isVisible ( ) ;
logger . info ( ` All Year radio button found - visible: ${ isVisible } ` ) ;
if ( isVisible ) {
await allYearRadio . scrollIntoViewIfNeeded ( ) ;
await page . waitForTimeout ( 500 ) ;
await allYearRadio . click ( ) ;
logger . info ( '✓ Clicked "All Year" radio button' ) ;
await page . waitForTimeout ( 2000 ) ; // Wait for page to update with all year data
} else {
logger . warn ( 'All Year radio button not visible, skipping' ) ;
}
}
} catch ( err ) {
const errorMsg = err instanceof Error ? err.message : String ( err ) ;
logger . warn ( ` Error clicking All Year radio button: ${ errorMsg } ` ) ;
logger . info ( 'Continuing anyway...' ) ;
}
// END OF NEW SECTION
// Now find the grades table
logger . info ( 'Step 9: Finding grades table...' ) ;
// Wait for the table to load
await page . waitForTimeout ( 2000 ) ;
// Find the table body
const tableBody = await page . $ ( '.unlockedBrowseBody.dynamicHeight' ) ;
if ( ! tableBody ) {
logger . error ( 'Could not find grades table with class "unlockedBrowseBody dynamicHeight"' ) ;
// await page.screenshot({ path: `${SCREENSHOTS_DIR}/11-table-not-found.png`, fullPage: true });
throw new Error ( 'Grades table not found' ) ;
}
logger . info ( '✓ Found grades table' ) ;
// Find all clickable grade cells (links with numbers)
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' ) ;
// await page.screenshot({ path: `${SCREENSHOTS_DIR}/11-no-grade-cells.png`, fullPage: true });
return ;
}
// Click each grade cell
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 } === ` ) ;
// Re-query the cell each time (in case DOM has changed)
const allCells = await tableBody . $ $ ( 'a.linkPanelButton' ) ;
const cell = allCells [ i ] ;
@@ -771,6 +716,7 @@ async function navigateToGrading(page: Page, allClassGrades: any[]) {
continue ;
}
// Get cell details
const gradeText = ( await cell . textContent ( ) ) ? . trim ( ) ;
const isVisible = await cell . isVisible ( ) ;
const dataRow = await cell . getAttribute ( 'data-row' ) ;
@@ -787,27 +733,17 @@ async function navigateToGrading(page: Page, allClassGrades: any[]) {
continue ;
}
// Scroll into view
await cell . scrollIntoViewIfNeeded ( ) ;
await page . waitForTimeout ( 500 ) ;
// Click the cell
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 ` ) ;
}
// Check for the category label - look inside SegmentedAssignmentsInformationCell span (not the one with "left")
const categoryContainer = await page . $ ( 'span.SegmentedAssignmentsInformationCell:not(.left) label.labelTag.visualLabel' ) ;
if ( ! categoryContainer ) {
@@ -816,9 +752,11 @@ async function navigateToGrading(page: Page, allClassGrades: any[]) {
const categoryText = ( await categoryContainer . textContent ( ) ) ? . trim ( ) ;
logger . info ( ` Category label found: " ${ categoryText } " ` ) ;
// Check if this is one of our target categories
if ( categoryText && TARGET_CATEGORIES . includes ( categoryText ) ) {
logger . info ( ` ✓ Target category " ${ categoryText } " detected - reading grades ` ) ;
// Get the class information from the left span
const classInfoSpan = await page . $ ( 'span.SegmentedAssignmentsInformationCell.left' ) ;
let className = '' ;
let teacher = '' ;
@@ -844,6 +782,7 @@ async function navigateToGrading(page: Page, allClassGrades: any[]) {
}
}
// Find the details panel with grades
const detailsPanel = await page . $ ( '#detailsPanel_SegmentedGradeBucketAssignments_unlockedBody' ) ;
if ( ! detailsPanel ) {
@@ -851,17 +790,19 @@ async function navigateToGrading(page: Page, allClassGrades: any[]) {
} else {
logger . info ( ` ✓ Found details panel, extracting grades... ` ) ;
// Get all rows in the table
const rows = await detailsPanel . $ $ ( 'tr' ) ;
logger . info ( ` Found ${ rows . length } total rows ` ) ;
const grades = [ ] ;
let currentGradeType = null ;
let currentGradeType = null ; // 'major' or 'minor'
for ( let j = 0 ; j < rows . length ; j ++ ) {
const row = rows [ j ] ;
if ( ! row ) continue ;
try {
// Check if this row is a grade type header (Major Grades / Minor Grades)
const gradeTypeSpan = await row . $ ( 'span.anchorText' ) ;
if ( gradeTypeSpan ) {
@@ -876,19 +817,16 @@ async function navigateToGrading(page: Page, allClassGrades: any[]) {
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 ;
}
}
// Skip if we haven't found a grade type yet
if ( ! currentGradeType ) {
logger . info ( ` Row ${ j } : Skipping (no grade type set yet) ` ) ;
currentGradeType = 'minor' ;
continue ;
}
// Try to extract grade data from this row
// Look for any link in the row (could be linkDetails or other classes)
const nameLink = await row . $ ( 'a' ) ;
if ( ! nameLink ) {
logger . info ( ` Row ${ j } : No link found, skipping ` ) ;
@@ -903,15 +841,18 @@ async function navigateToGrading(page: Page, allClassGrades: any[]) {
let pointsEarned = '' ;
let attempts = '' ;
// Get all td cells
const tdCells = await row . $ $ ( 'td' ) ;
logger . info ( ` Row ${ j } : Found ${ tdCells . length } td cells ` ) ;
// Due Date is typically in the second column (index 1)
if ( tdCells && tdCells . length > 1 && tdCells [ 1 ] ) {
dueDate = ( await tdCells [ 1 ] . textContent ( ) ) ? . trim ( ) || '' ;
}
// Score and Points are in disabled-link-column spans
const disabledSpans = await row . $ $ ( 'span.disabled-link-column' ) ;
logger . info ( ` Row ${ j } : Found ${ disabledSpans ? disabledSpans.length : 0 } disabled-link-column spans ` ) ;
logger . info ( ` Row ${ j } : Found ${ disabledSpans . length } disabled-link-column spans ` ) ;
if ( disabledSpans && disabledSpans . length >= 1 && disabledSpans [ 0 ] ) {
score = ( await disabledSpans [ 0 ] . textContent ( ) ) ? . trim ( ) || '' ;
@@ -920,6 +861,7 @@ async function navigateToGrading(page: Page, allClassGrades: any[]) {
pointsEarned = ( await disabledSpans [ 1 ] . textContent ( ) ) ? . trim ( ) || '' ;
}
// Attempts - look for any number link in last columns
const allLinks = await row . $ $ ( 'a' ) ;
const lastLink = allLinks && allLinks . length > 1 ? allLinks [ allLinks . length - 1 ] : null ;
if ( lastLink ) {
@@ -945,22 +887,31 @@ async function navigateToGrading(page: Page, allClassGrades: any[]) {
logger . info ( ` ✓ Extracted ${ grades . length } grades for ${ categoryText } ` ) ;
// Add to the global collection
allClassGrades . push ( {
className : className ,
teacher : teacher ,
period : period ,
category : categoryText ,
overallGrade : overallGrade ,
grades : grades ,
} ) ;
logger . info ( ` ✓ Added grades to collection (Total classes: ${ allClassGrades . length } ) ` ) ;
// Take screenshot with category-based name
// const screenshotName = `${categoryText}_${className.replace(/\s+/g, '_')}_row${dataRow}.png`.replace(/\s+/g, '_');
// await page.screenshot({
// path: `${SCREENSHOTS_DIR}/${screenshotName}`,
// fullPage: true
// });
// logger.info(` 📸 Screenshot saved: ${screenshotName}`);
}
} else {
logger . info ( ` ℹ Category " ${ categoryText } " not in target list, skipping ` ) ;
}
}
// Close the modal/panel
const closeSelectors = [
'button[aria-label="Close"]' ,
'button[aria-label*="close"]' ,
@@ -991,17 +942,29 @@ async function navigateToGrading(page: Page, allClassGrades: any[]) {
}
if ( ! closed ) {
// Try pressing Escape key
logger . info ( ` Trying Escape key to close panel ` ) ;
await page . keyboard . press ( 'Escape' ) ;
await page . waitForTimeout ( 1000 ) ;
}
// Small delay between cells
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.screenshot({
// path: `${SCREENSHOTS_DIR}/grade_${i + 1}_ERROR.png`,
// fullPage: true
// });
// } catch (screenshotErr) {
// logger.warn(` Could not take error screenshot: ${screenshotErr}`);
// }
// Try to close any open modals before continuing
try {
await page . keyboard . press ( 'Escape' ) ;
await page . waitForTimeout ( 1000 ) ;
@@ -1009,14 +972,22 @@ async function navigateToGrading(page: Page, allClassGrades: any[]) {
logger . warn ( ` Could not close modal: ${ closeErr } ` ) ;
}
// Continue to next cell instead of crashing
continue ;
}
}
logger . info ( ` \ n🎉 Finished processing all grade cells! ` ) ;
// try {
// await page.screenshot({ path: `${SCREENSHOTS_DIR}/12-all-grades-complete.png`, fullPage: true });
// } catch (err) {
// logger.warn(`Could not take final screenshot: ${err}`);
// }
// Write all grades to a JSON file
try {
const gradesFilePath = ` ${ OUTPUT _DIR} /all_grades.json ` ;
const gradesFilePath = ` ${ SCREENSHOTS _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 } ` ) ;