Files
spaceward-backend/index.ts

999 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
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({
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<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;
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<boolean> {
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<Page>((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}`);
}
}