Skip to main content
Glama
gradescope-api.ts23.7 kB
/** * Gradescope API integration * Full implementation replicating the Python Gradescope API functionality * Handles real authentication, HTML parsing, and data extraction */ import fetch from 'node-fetch'; import * as cheerio from 'cheerio'; import { CookieJar } from 'tough-cookie'; import { cache } from './cache.js'; import { Logger } from './config.js'; interface GradescopeConfig { email: string; password: string; logger: Logger; } interface GradescopeCourse { id: string; name: string; full_name: string; semester: string; year: string; num_grades_published: string | null; num_assignments: string; } interface GradescopeAssignment { assignment_id: string; name: string; release_date: Date | null; due_date: Date | null; late_due_date: Date | null; submissions_status: string | null; grade: number | null; max_grade: number | null; } interface GradescopeMember { full_name: string; first_name: string; last_name: string; sid: string; email: string; role: string; user_id: string | null; num_submissions: number; sections: string; course_id: string; } interface GradescopeQueryAnalysis { type: 'get_courses' | 'get_assignments' | 'get_submission' | null; course_id?: string; course_name?: string; assignment_id?: string; assignment_name?: string; student_email?: string; confidence: number; } const DEFAULT_GRADESCOPE_BASE_URL = 'https://www.gradescope.com'; export class GradescopeApi { private config: GradescopeConfig; private cookieJar: CookieJar; private isAuthenticated: boolean = false; private csrfToken: string = ''; constructor(config: GradescopeConfig) { this.config = config; this.cookieJar = new CookieJar(); } /** * Authenticate with Gradescope using real login flow * Replicates the Python implementation's authentication process */ private async authenticate(): Promise<boolean> { if (this.isAuthenticated) { return true; } try { this.config.logger.debug('Starting Gradescope authentication...'); // Step 1: Get homepage to extract authenticity token and set initial session cookie const authToken = await this.getAuthTokenAndInitSession(); if (!authToken) { this.config.logger.error('Failed to get authentication token'); return false; } // Step 2: Login with credentials and auth token const loginSuccess = await this.loginWithCredentials(authToken); if (!loginSuccess) { this.config.logger.error('Login failed'); return false; } this.isAuthenticated = true; this.config.logger.debug('Gradescope authentication successful'); return true; } catch (error) { this.config.logger.error('Gradescope authentication failed:', error); return false; } } /** * Get authenticity token from homepage and initialize session */ private async getAuthTokenAndInitSession(): Promise<string | null> { try { const response = await fetch(DEFAULT_GRADESCOPE_BASE_URL, { method: 'GET', headers: { 'User-Agent': 'Canvas-MCP-JS/1.0' } }); if (!response.ok) { this.config.logger.error(`Failed to load homepage: ${response.status}`); return null; } // Store cookies from homepage const setCookieHeaders = response.headers.raw()['set-cookie']; if (setCookieHeaders) { for (const cookie of setCookieHeaders) { await this.cookieJar.setCookie(cookie, DEFAULT_GRADESCOPE_BASE_URL); } } const html = await response.text(); const $ = cheerio.load(html); // Find the authenticity token const authTokenElement = $('form[action="/login"] input[name="authenticity_token"]'); const authToken = authTokenElement.attr('value'); if (!authToken) { this.config.logger.error('Could not find authenticity token on homepage'); return null; } return authToken; } catch (error) { this.config.logger.error('Error getting auth token:', error); return null; } } /** * Login with credentials and auth token */ private async loginWithCredentials(authToken: string): Promise<boolean> { try { const loginEndpoint = `${DEFAULT_GRADESCOPE_BASE_URL}/login`; // Get cookies for the login request const cookieHeader = await this.cookieJar.getCookieString(DEFAULT_GRADESCOPE_BASE_URL); const loginData = new URLSearchParams({ 'utf8': '✓', 'session[email]': this.config.email, 'session[password]': this.config.password, 'session[remember_me]': '0', 'commit': 'Log In', 'session[remember_me_sso]': '0', 'authenticity_token': authToken }); const response = await fetch(loginEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'User-Agent': 'Canvas-MCP-JS/1.0', 'Cookie': cookieHeader, 'Referer': DEFAULT_GRADESCOPE_BASE_URL }, body: loginData, redirect: 'manual' // Handle redirects manually to detect success }); // Store new cookies from login response const setCookieHeaders = response.headers.raw()['set-cookie']; if (setCookieHeaders) { for (const cookie of setCookieHeaders) { await this.cookieJar.setCookie(cookie, DEFAULT_GRADESCOPE_BASE_URL); } } // Success is marked by a 302 redirect if (response.status === 302) { this.config.logger.debug('Login redirect detected - success'); // Follow the redirect to get the CSRF token from the account page const redirectLocation = response.headers.get('location'); if (redirectLocation) { await this.extractCSRFToken(redirectLocation); } return true; } else { this.config.logger.error(`Login failed with status: ${response.status}`); return false; } } catch (error) { this.config.logger.error('Error during login:', error); return false; } } /** * Extract CSRF token from account page after successful login */ private async extractCSRFToken(redirectUrl: string): Promise<void> { try { const fullUrl = redirectUrl.startsWith('http') ? redirectUrl : `${DEFAULT_GRADESCOPE_BASE_URL}${redirectUrl}`; const cookieHeader = await this.cookieJar.getCookieString(DEFAULT_GRADESCOPE_BASE_URL); const response = await fetch(fullUrl, { method: 'GET', headers: { 'User-Agent': 'Canvas-MCP-JS/1.0', 'Cookie': cookieHeader } }); if (response.ok) { const html = await response.text(); const $ = cheerio.load(html); const csrfTokenElement = $('meta[name="csrf-token"]'); const csrfToken = csrfTokenElement.attr('content'); if (csrfToken) { this.csrfToken = csrfToken; this.config.logger.debug('CSRF token extracted successfully'); } } } catch (error) { this.config.logger.error('Error extracting CSRF token:', error); } } /** * Make authenticated request to Gradescope */ private async makeAuthenticatedRequest(url: string, options: any = {}): Promise<any> { if (!await this.authenticate()) { return null; } try { const cookieHeader = await this.cookieJar.getCookieString(DEFAULT_GRADESCOPE_BASE_URL); const headers = { 'User-Agent': 'Canvas-MCP-JS/1.0', 'Cookie': cookieHeader, ...options.headers }; if (this.csrfToken) { headers['X-CSRF-Token'] = this.csrfToken; } const response = await fetch(url, { ...options, headers }); if (response.status === 401) { this.config.logger.error('Unauthorized - authentication may have expired'); this.isAuthenticated = false; return null; } if (!response.ok) { this.config.logger.error(`Request failed: ${response.status} ${response.statusText}`); return null; } return response; } catch (error) { this.config.logger.error('Error making authenticated request:', error); return null; } } /** * Get all courses from Gradescope (replicates Python get_courses_info) */ async getGradescopeCourses(): Promise<Record<string, Record<string, GradescopeCourse>> | null> { // Check cache first const cached = cache.get<Record<string, Record<string, GradescopeCourse>>>('gradescope_courses'); if (cached) { this.config.logger.debug('Using cached Gradescope courses data'); return cached; } try { const accountUrl = `${DEFAULT_GRADESCOPE_BASE_URL}/account`; const response = await this.makeAuthenticatedRequest(accountUrl); if (!response) { return null; } const html = await response.text(); const $ = cheerio.load(html); const courses = { instructor: {}, student: {} }; // Parse instructor courses if present const instructorCourses = await this.parseCoursesFromHTML($, 'Instructor Courses'); if (instructorCourses) { courses.instructor = instructorCourses.courses; } // Parse student courses if present const studentCourses = await this.parseCoursesFromHTML($, 'Student Courses'); if (studentCourses) { courses.student = studentCourses.courses; } // If no specific instructor/student sections, check for generic "Your Courses" if (Object.keys(courses.instructor).length === 0 && Object.keys(courses.student).length === 0) { const yourCourses = await this.parseCoursesFromHTML($, 'Your Courses'); if (yourCourses) { if (yourCourses.isInstructor) { courses.instructor = yourCourses.courses; } else { courses.student = yourCourses.courses; } } } // Format for serialization (matching Python format) const serializedCourses: Record<string, Record<string, GradescopeCourse>> = { student: Object.fromEntries( Object.entries(courses.student).map(([id, course]) => [ `Course ID: ${id}`, course ]) ) as Record<string, GradescopeCourse>, instructor: Object.fromEntries( Object.entries(courses.instructor).map(([id, course]) => [ `Course ID: ${id}`, course ]) ) as Record<string, GradescopeCourse> }; // Store in cache cache.set('gradescope_courses', serializedCourses); this.config.logger.debug(`Retrieved ${Object.keys(courses.student).length} student courses and ${Object.keys(courses.instructor).length} instructor courses`); return serializedCourses; } catch (error) { this.config.logger.error('Error in getGradescopeCourses:', error); return null; } } /** * Parse courses from HTML (replicates Python get_courses_info) */ private async parseCoursesFromHTML($: cheerio.CheerioAPI, userType: string): Promise<{ courses: Record<string, GradescopeCourse>, isInstructor: boolean } | null> { const courses: Record<string, GradescopeCourse> = {}; // Find heading for user type courses const coursesHeading = $(`h1.pageHeading:contains("${userType}")`).first(); if (coursesHeading.length === 0) { return null; } // Check if user is instructor by looking for "Create a new course" button const button = coursesHeading.next('button'); const isInstructor = button.text().trim().includes('Create a new course'); // Find the course list const courseList = coursesHeading.nextAll('.courseList').first(); if (courseList.length === 0) { return { courses, isInstructor }; } // Parse each term section courseList.find('.courseList--term').each((_, termElement) => { const termEl = $(termElement); // Extract semester and year from term text const termText = termEl.contents().first().text().trim(); const [semester, year] = termText.split(' '); // Parse each course in this term termEl.find('a').each((_, courseElement) => { const courseEl = $(courseElement); const href = courseEl.attr('href'); if (!href) return; const courseId = href.split('/').pop(); if (!courseId) return; // Extract course details const shortName = courseEl.find('h3.courseBox--shortname').text().trim(); const fullName = courseEl.find('.courseBox--name').text().trim(); let numGradesPublished: string | null = null; let numAssignments = ''; if (userType === 'Instructor Courses' || isInstructor) { const gradesEl = courseEl.find('.courseBox--noGradesPublised'); if (gradesEl.length > 0) { numGradesPublished = gradesEl.text().trim(); } const assignmentsEl = courseEl.find('.courseBox--assignments.courseBox--assignments-unpublished'); if (assignmentsEl.length > 0) { numAssignments = assignmentsEl.text().trim(); } } else { const assignmentsEl = courseEl.find('.courseBox--assignments'); if (assignmentsEl.length > 0) { numAssignments = assignmentsEl.text().trim(); } } courses[courseId] = { id: courseId, name: shortName, full_name: fullName, semester: semester || '', year: year || '', num_grades_published: numGradesPublished, num_assignments: numAssignments }; }); }); return { courses, isInstructor }; } /** * Get a course from Gradescope by name */ async getGradescopeCourseByName(courseName: string): Promise<GradescopeCourse | null> { const courses = await this.getGradescopeCourses(); if (!courses) { return null; } for (const course of Object.values(courses.student)) { if (course.name.toLowerCase().includes(courseName.toLowerCase()) || course.full_name.toLowerCase().includes(courseName.toLowerCase())) { return course; } } for (const course of Object.values(courses.instructor)) { if (course.name.toLowerCase().includes(courseName.toLowerCase()) || course.full_name.toLowerCase().includes(courseName.toLowerCase())) { return course; } } return null; } /** * Get all assignments for a course (replicates Python get_assignments) */ async getGradescopeAssignments(courseId: string): Promise<GradescopeAssignment[] | null> { // Check cache first const cached = cache.get<GradescopeAssignment[]>('gradescope_assignments', courseId); if (cached) { this.config.logger.debug(`Using cached Gradescope assignments for course ${courseId}`); return cached; } try { const courseUrl = `${DEFAULT_GRADESCOPE_BASE_URL}/courses/${courseId}`; const response = await this.makeAuthenticatedRequest(courseUrl); if (!response) { return null; } const html = await response.text(); const $ = cheerio.load(html); // Try instructor view first (has React props with assignment data) let assignments = this.parseAssignmentsInstructorView($); // If no assignments found, try student view if (!assignments || assignments.length === 0) { assignments = this.parseAssignmentsStudentView($); } if (assignments) { // Store in cache cache.set('gradescope_assignments', assignments, courseId); this.config.logger.debug(`Retrieved ${assignments.length} assignments for course ${courseId}`); } return assignments; } catch (error) { this.config.logger.error('Error in getGradescopeAssignments:', error); return null; } } /** * Parse assignments from instructor view (replicates get_assignments_instructor_view) */ private parseAssignmentsInstructorView($: cheerio.CheerioAPI): GradescopeAssignment[] | null { const assignmentsList: GradescopeAssignment[] = []; const elementWithProps = $('div[data-react-class="AssignmentsTable"]'); if (elementWithProps.length === 0) { return null; } const propsStr = elementWithProps.attr('data-react-props'); if (!propsStr) { return null; } try { const assignmentJson = JSON.parse(propsStr); for (const assignment of assignmentJson.table_data || []) { // Skip non-assignment data like sections if (assignment.type !== 'assignment') { continue; } const assignmentObj: GradescopeAssignment = { assignment_id: assignment.url?.split('/').pop() || '', name: assignment.title || '', release_date: assignment.submission_window?.release_date ? new Date(assignment.submission_window.release_date) : null, due_date: assignment.submission_window?.due_date ? new Date(assignment.submission_window.due_date) : null, late_due_date: assignment.submission_window?.hard_due_date ? new Date(assignment.submission_window.hard_due_date) : null, submissions_status: null, grade: null, max_grade: assignment.total_points ? parseFloat(assignment.total_points) : null }; assignmentsList.push(assignmentObj); } return assignmentsList; } catch (error) { this.config.logger.error('Error parsing instructor view assignments:', error); return null; } } /** * Parse assignments from student view (replicates get_assignments_student_view) */ private parseAssignmentsStudentView($: cheerio.CheerioAPI): GradescopeAssignment[] | null { const assignmentsList: GradescopeAssignment[] = []; // Find assignment rows (skip header and tail) const assignmentRows = $('tr[role="row"]').slice(1, -1); assignmentRows.each((_, row) => { const rowEl = $(row); const cells = rowEl.find('th, td'); if (cells.length < 3) return; // Extract assignment name and ID const nameCell = $(cells[0]); const name = nameCell.text().trim(); let assignmentId: string | null = null; const assignmentLink = nameCell.find('a[href]'); const assignmentButton = nameCell.find('button.js-submitAssignment'); if (assignmentLink.length > 0) { const href = assignmentLink.attr('href'); if (href) { assignmentId = href.split('/')[4]; // Extract from URL structure } } else if (assignmentButton.length > 0) { assignmentId = assignmentButton.attr('data-assignment-id') || null; } // Extract points/grade information let grade: number | null = null; let maxGrade: number | null = null; let submissionStatus = 'Not Submitted'; const pointsText = $(cells[1]).text().trim(); if (pointsText.includes(' / ')) { const points = pointsText.split(' / '); try { grade = parseFloat(points[0]); maxGrade = parseFloat(points[1]); submissionStatus = 'Submitted'; } catch (e) { // Keep defaults } } else { submissionStatus = pointsText; } // Extract dates from submission time chart let releaseDate: Date | null = null; let dueDate: Date | null = null; let lateDueDate: Date | null = null; const dateCell = $(cells[2]); const releaseDateEl = dateCell.find('.submissionTimeChart--releaseDate'); const dueDateEls = dateCell.find('.submissionTimeChart--dueDate'); if (releaseDateEl.length > 0) { const datetime = releaseDateEl.attr('datetime'); if (datetime) { releaseDate = new Date(datetime); } } if (dueDateEls.length > 0) { const firstDueDatetime = $(dueDateEls[0]).attr('datetime'); if (firstDueDatetime) { dueDate = new Date(firstDueDatetime); } if (dueDateEls.length > 1) { const lateDueDatetime = $(dueDateEls[1]).attr('datetime'); if (lateDueDatetime) { lateDueDate = new Date(lateDueDatetime); } } } const assignmentObj: GradescopeAssignment = { assignment_id: assignmentId || '', name, release_date: releaseDate, due_date: dueDate, late_due_date: lateDueDate, submissions_status: submissionStatus, grade, max_grade: maxGrade }; assignmentsList.push(assignmentObj); }); return assignmentsList; } /** * Get an assignment by name within a course */ async getGradescopeAssignmentByName(courseId: string, assignmentName: string): Promise<GradescopeAssignment | null> { const assignments = await this.getGradescopeAssignments(courseId); if (!assignments) { return null; } for (const assignment of assignments) { if (assignment.name.toLowerCase().includes(assignmentName.toLowerCase())) { return assignment; } } return null; } /** * Analyze a natural language query to determine what Gradescope information is being requested */ analyzeGradescopeQuery(query: string): GradescopeQueryAnalysis { const result: GradescopeQueryAnalysis = { type: null, confidence: 0.0 }; const queryLower = query.toLowerCase(); // Check for courses request if (['my courses', 'list courses', 'show courses', 'what courses'].some(keyword => queryLower.includes(keyword))) { result.type = 'get_courses'; result.confidence = 0.9; return result; } // Check for assignments request if (['assignments', 'homework', 'due dates'].some(keyword => queryLower.includes(keyword))) { result.type = 'get_assignments'; result.confidence = 0.8; return result; } // Check for submission request if (['submission', 'submitted', 'grade', 'feedback', 'score'].some(keyword => queryLower.includes(keyword))) { result.type = 'get_submission'; result.confidence = 0.7; return result; } return result; } /** * Search for information across Gradescope using natural language queries */ async searchGradescope(query: string): Promise<any> { const analysis = this.analyzeGradescopeQuery(query); switch (analysis.type) { case 'get_courses': const courses = await this.getGradescopeCourses(); if (!courses) { return { error: 'Could not retrieve Gradescope courses' }; } return courses; case 'get_assignments': case 'get_submission': // Both assignments and submission queries return assignment data (which includes submission info for students) const allCourses = await this.getGradescopeCourses(); if (allCourses) { return { message: 'Please specify which course you\'re interested in. Here are your courses:', courses: allCourses }; } else { return { error: 'Could not determine which course to get assignments for' }; } default: return { error: 'I\'m not sure what you\'re asking about Gradescope. Try asking about your courses or assignments.' }; } } } export type { GradescopeConfig, GradescopeCourse, GradescopeAssignment, GradescopeMember, GradescopeQueryAnalysis };

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/noahjohannessen/canvas-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server