972 lines
31 KiB
TypeScript
972 lines
31 KiB
TypeScript
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 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;
|
||
|
||
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);
|
||
|
||
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(SCREENSHOTS_DIR)) {
|
||
mkdirSync(SCREENSHOTS_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 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,
|
||
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 = `${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}`);
|
||
}
|
||
} |