Files
spaceward-backend/index.ts
2025-12-17 21:16:06 -06:00

1005 lines
34 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 { existsSync, mkdirSync, writeFileSync } from 'fs';
import pino from 'pino';
import { Page } from 'playwright';
import express from 'express';
const app = express();
app.use(express.json());
const logger = pino({
transport: {
target: "pino-pretty",
options: {
colorize: true,
},
},
});
const SCREENSHOTS_DIR = 'skyward_screenshots';
const TARGET_CATEGORIES = ['NW1', 'NW2', 'SE1', 'NW3', 'NW4', 'SE2'];
// 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'
});
}
logger.info(`Received request to fetch grades for user: ${username}`);
try {
const grades = await runSkywardBot(username, password);
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'
});
}
});
// Health check endpoint
app.get('/health', (req, res) => {
res.json({ status: 'ok', message: 'Skyward API is running' });
});
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',
});
}
const browser = await firefox.launch({ headless: true });
const context = await browser.newContext();
const page = await context.newPage();
try {
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 {
await browser.close();
}
});
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(SCREENSHOTS_DIR)) {
mkdirSync(SCREENSHOTS_DIR);
}
const browser = await firefox.launch({
headless: true,
firefoxUserPrefs: {
'dom.webdriver.enabled': false,
'media.peerconnection.enabled': false,
'useAutomationExtension': false,
},
});
let page: Page | null = null;
try {
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 },
});
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}`);
if (page) {
// try {
// await page.screenshot({ path: `${SCREENSHOTS_DIR}/FATAL_ERROR.png` });
// } catch (screenshotError) {
// logger.warn('Could not take error screenshot');
// }
}
throw error;
} finally {
await browser.close();
logger.info('Browser closed, done.');
}
};
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);
// 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...');
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);
// await page.screenshot({ path: `${SCREENSHOTS_DIR}/02-after-pisd-button-click.png` });
// 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');
// await page.screenshot({ path: `${SCREENSHOTS_DIR}/03-username-entered.png` });
// 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');
// 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 = [
'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');
// 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); // Wait a bit for redirect to happen
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);
// 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...');
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);
// await page.screenshot({ path: `${SCREENSHOTS_DIR}/02-after-pisd-button-click.png` });
// 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');
// await page.screenshot({ path: `${SCREENSHOTS_DIR}/03-username-entered.png` });
// 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');
// 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 = [
'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');
// 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); // Wait a bit for redirect to happen
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 page.screenshot({ path: `${SCREENSHOTS_DIR}/06-back-at-launchpad.png` });
await openSkywardFromLaunchpad(page, context, allClassGrades);
}
async function openSkywardFromLaunchpad(page: Page, context: any, allClassGrades: any[]) {
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',
'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');
// await page.screenshot({ path: `${SCREENSHOTS_DIR}/07-search-not-found.png`, fullPage: true });
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);
// 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 (newPage) => {
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...');
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 {
logger.warn(`New tab URL doesn't contain 'skyward': ${finalUrl}`);
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;
}
}
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")',
'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);
// Check each element to find one with EXACT text "Grading"
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');
// 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');
// 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 {
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];
if (!cell) {
logger.warn(` Cell ${i + 1} not found, skipping`);
continue;
}
// Get cell details
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;
}
// 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);
// 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) {
logger.info(` No category label found in SegmentedAssignmentsInformationCell, skipping`);
} else {
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 = '';
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}"`);
}
}
// Find the details panel with grades
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...`);
// Get all rows in the table
const rows = await detailsPanel.$$('tr');
logger.info(` Found ${rows.length} total rows`);
const grades = [];
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) {
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'; // default
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`);
continue;
}
const name = (await nameLink.textContent())?.trim() || '';
logger.info(` Row ${j}: Found assignment "${name}"`);
let dueDate = '';
let score = '';
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.length} 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() || '';
}
// 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) {
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}`);
// Add to the global collection
allClassGrades.push({
className: className,
teacher: teacher,
period: period,
category: categoryText,
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"]',
'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) {
// 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);
} catch (closeErr) {
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 = `${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}`);
} catch (err) {
const errorMsg = err instanceof Error ? err.message : String(err);
logger.error(`Failed to write grades file: ${errorMsg}`);
}
}