| import type { BrowserContext, Download, Page } from 'playwright'; |
| import * as fs from 'fs'; |
| import * as path from 'path'; |
| import { config } from '../config'; |
| import { logger } from '../utils/logger'; |
| import { getBrowser } from '../worker/browser-pool'; |
| import { loginToTurnitin, acceptEulaEverywhere } from './steps/login'; |
| import { navigateToAssignment } from './steps/navigate'; |
| import { dropClassByTitle } from './steps/class-management'; |
| import { hasActiveFilters } from './steps/filters'; |
| import type { FilterOptions } from './steps/filters'; |
| import type { RunTurnitinJobInput, RunTurnitinJobResult } from './turnitin'; |
| import type { SubmissionDetails } from './steps/submission-details'; |
| import type { Frame } from 'playwright'; |
| import { readLocalDocumentMetadata } from '../utils/document-metadata'; |
|
|
| type Scope = Page | Frame; |
|
|
| type EngineEvent = NonNullable<RunTurnitinJobInput['onEvent']>; |
| const LEGACY_ACCOUNT_QUOTA_LIMIT = 4; |
| const LARGE_DOCUMENT_PAGE_THRESHOLD = 100; |
|
|
| function getLegacySimilarityTimeoutMs(pageCount?: number): number { |
| const baseTimeout = Math.max(config.similarityTimeoutMs || 180000, 120000); |
| if (!pageCount || pageCount < LARGE_DOCUMENT_PAGE_THRESHOLD) return baseTimeout; |
|
|
| const extraHundreds = Math.max(0, Math.floor((pageCount - 100) / 100)); |
| const largeDocumentMinutes = Math.min(15, 8 + extraHundreds * 2); |
| return Math.max(baseTimeout, largeDocumentMinutes * 60 * 1000); |
| } |
|
|
| async function emit( |
| onEvent: RunTurnitinJobInput['onEvent'], |
| level: 'info' | 'warning' | 'error', |
| step: string, |
| message: string, |
| metadata?: Record<string, unknown>, |
| ): Promise<void> { |
| if (onEvent) { |
| await onEvent({ level, step, message, metadata }).catch(() => {}); |
| } |
| if (level === 'error') logger.error(message, { step, ...metadata }); |
| else if (level === 'warning') logger.warn(message, { step, ...metadata }); |
| else logger.info(message, { step, ...metadata }); |
| } |
|
|
| function compactErrorMessage(message: string): string { |
| const firstLine = message.split('\n').map((line) => line.trim()).find(Boolean) || message; |
| if (/locator\.waitFor: Timeout/i.test(firstLine)) { |
| const selector = firstLine.match(/locator\('([^']+)'/i)?.[1]; |
| return selector |
| ? `Turnitin legacy page element did not appear in time: ${selector}` |
| : 'Turnitin legacy page element did not appear in time.'; |
| } |
| return firstLine.length > 260 ? `${firstLine.slice(0, 257)}...` : firstLine; |
| } |
|
|
| function parseNumber(value: string | null | undefined): number | undefined { |
| const numeric = Number(String(value || '').replace(/[^\d]/g, '')); |
| return Number.isFinite(numeric) ? numeric : undefined; |
| } |
|
|
| function parsePercent(value: string | null | undefined): number | null { |
| const text = String(value || '').trim(); |
| const explicitPercent = text.match(/(?:^|\D)(\d{1,3})\s*%/); |
| const numericOnly = text.match(/^\d{1,3}$/); |
| const raw = explicitPercent?.[1] || numericOnly?.[0]; |
| if (!raw) return null; |
|
|
| const parsed = Number(raw); |
| return Number.isInteger(parsed) && parsed >= 0 && parsed <= 100 |
| ? parsed |
| : null; |
| } |
|
|
| function parseFileSizeBytes(value: string | null | undefined): number | undefined { |
| const text = String(value || '').trim(); |
| const match = text.match(/([\d.,]+)\s*(bytes?|b|kb|kib|mb|mib)?/i); |
| if (!match) return parseNumber(text); |
| const amount = Number(match[1].replace(/,/g, '')); |
| if (!Number.isFinite(amount)) return parseNumber(text); |
| const unit = (match[2] || 'b').toLowerCase(); |
| if (unit === 'mb' || unit === 'mib') return Math.round(amount * 1024 * 1024); |
| if (unit === 'kb' || unit === 'kib') return Math.round(amount * 1024); |
| return Math.round(amount); |
| } |
|
|
| type ParsedLegacySubmissionDetailKey = Exclude< |
| keyof SubmissionDetails, |
| 'pageCountSource' | 'expectedWaitMinutes' | 'largeDocument' |
| >; |
|
|
| function normalizeLabel(label: string): ParsedLegacySubmissionDetailKey | null { |
| const normalized = label.toLowerCase().replace(/\s+/g, ' ').trim(); |
| if (normalized === 'student id') return 'studentId'; |
| if (normalized === 'class name') return 'className'; |
| if (normalized === 'class id') return 'classId'; |
| if (normalized === 'submission id') return 'submissionId'; |
| if (normalized === 'submission date') return 'submissionDate'; |
| if (normalized === 'submission count') return 'submissionCount'; |
| if (normalized === 'file name') return 'fileName'; |
| if (normalized === 'file extension') return 'fileExtension'; |
| if (normalized === 'file size') return 'fileSize'; |
| if (normalized === 'character count') return 'charCount'; |
| if (normalized === 'char count') return 'charCount'; |
| if (normalized === 'word count') return 'wordCount'; |
| if (normalized === 'page count') return 'pageCount'; |
| return null; |
| } |
|
|
| async function waitForAnyDownload( |
| context: BrowserContext, |
| timeoutMs: number, |
| ): Promise<Download> { |
| return new Promise((resolve, reject) => { |
| const timer = setTimeout( |
| () => cleanup(new Error(`Timed out waiting ${timeoutMs}ms for download`)), |
| timeoutMs, |
| ); |
| const pageListeners = new Map<Page, (d: Download) => void>(); |
|
|
| const onDownload = (download: Download) => cleanup(null, download); |
| const onPage = (p: Page) => attach(p); |
|
|
| function attach(p: Page) { |
| p.on('download', onDownload); |
| pageListeners.set(p, onDownload); |
| } |
|
|
| function cleanup(error: Error | null, download?: Download) { |
| clearTimeout(timer); |
| context.off('page', onPage); |
| for (const [p, listener] of pageListeners) p.off('download', listener); |
| if (error) reject(error); |
| else resolve(download!); |
| } |
|
|
| for (const p of context.pages()) attach(p); |
| context.on('page', onPage); |
| }); |
| } |
|
|
| async function visible(page: Scope, selector: string, timeout = 2500): Promise<boolean> { |
| try { |
| await page.locator(selector).first().waitFor({ state: 'visible', timeout }); |
| return true; |
| } catch { |
| return false; |
| } |
| } |
|
|
| async function findLegacyAssignmentScope( |
| page: Page, |
| selector: string, |
| timeoutMs = 20000, |
| ): Promise<Page | Frame | null> { |
| const deadline = Date.now() + timeoutMs; |
| while (true) { |
| if (await page.locator(selector).first().isVisible().catch(() => false)) { |
| return page; |
| } |
|
|
| for (const frame of page.frames()) { |
| if (frame.url().includes('cookie-shim')) continue; |
| if (await frame.locator(selector).first().isVisible().catch(() => false)) { |
| return frame; |
| } |
| } |
|
|
| if (Date.now() >= deadline) break; |
| await page.waitForTimeout(500).catch(() => {}); |
| } |
|
|
| return null; |
| } |
|
|
| async function clickFirstVisible( |
| page: Scope, |
| selectors: string[], |
| timeout = 5000, |
| ): Promise<boolean> { |
| const deadline = Date.now() + timeout; |
| while (true) { |
| for (const selector of selectors) { |
| const locator = page.locator(selector).first(); |
| if (await locator.isVisible().catch(() => false)) { |
| await locator.click({ force: true }); |
| return true; |
| } |
| } |
| if (Date.now() >= deadline) break; |
| await page.waitForTimeout(250).catch(() => {}); |
| } |
| return false; |
| } |
|
|
| async function clickFirstEnabled( |
| page: Scope, |
| selectors: string[], |
| timeout = 30000, |
| ): Promise<boolean> { |
| const deadline = Date.now() + timeout; |
| while (Date.now() < deadline) { |
| for (const selector of selectors) { |
| const locator = page.locator(selector).first(); |
| const visible = await locator.isVisible().catch(() => false); |
| if (!visible) continue; |
| const disabled = await locator |
| .evaluate((element: Element) => { |
| const button = element as HTMLButtonElement; |
| return Boolean( |
| button.disabled || |
| element.getAttribute('disabled') !== null || |
| element.classList.contains('disabled') || |
| element.getAttribute('aria-disabled') === 'true', |
| ); |
| }) |
| .catch(() => false); |
| if (!disabled) { |
| await locator.click({ force: true }); |
| return true; |
| } |
| } |
| await page.waitForTimeout(750); |
| } |
| return false; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async function navigateLegacyAssignment( |
| page: Page, |
| classTitle: string, |
| assignmentTitle: string | null | undefined, |
| onEvent?: EngineEvent, |
| ): Promise<void> { |
| |
| await page.waitForTimeout(2500); |
|
|
| |
| const onDashboard = await visible( |
| page, |
| [ |
| '#dashboard-table', |
| '.empty-assignment.student', |
| 'button.paper-upload[data-px="uploadSubmissionClicked"]', |
| 'button.paper-upload-modal', |
| '.student-submission-button', |
| ].join(', '), |
| 5000, |
| ); |
| if (onDashboard) { |
| await emit( |
| onEvent, |
| 'info', |
| 'navigate', |
| 'Legacy account landed on assignment dashboard after login; skipping class navigation', |
| ); |
| return; |
| } |
|
|
| |
| if (assignmentTitle) { |
| const assignmentRow = page |
| .locator('tr.assignment-row') |
| .filter({ hasText: assignmentTitle }) |
| .locator('a.btn-open, a.btn-primary, button:has-text("Open"), a:has-text("Open")') |
| .first(); |
| const onAssignmentList = await assignmentRow |
| .waitFor({ state: 'visible', timeout: 3000 }) |
| .then(() => true) |
| .catch(() => false); |
| if (onAssignmentList) { |
| await emit(onEvent, 'info', 'navigate', 'On class assignment list; clicking assignment Open button', { |
| assignmentTitle, |
| }); |
| await Promise.all([ |
| page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 60000 }).catch(() => null), |
| assignmentRow.click({ force: true }), |
| ]); |
| await page.waitForTimeout(4000); |
| await acceptEulaEverywhere(page); |
| return; |
| } |
| } |
|
|
| |
| await navigateToAssignment(page, classTitle ?? '', assignmentTitle); |
| } |
|
|
| async function getLegacySubmissionState(page: Page): Promise<{ |
| hasExistingSubmission: boolean; |
| hasResubmitAction: boolean; |
| hasInitialUpload: boolean; |
| }> { |
| |
| await page.waitForTimeout(1500); |
|
|
| |
| const hasExistingSubmission = |
| (await visible(page, '#dashboard-table a.default-open, #dashboard-table td[data-title="Similarity Score"], a.default-open[data-paper-title]', 6000)) || |
| (await page |
| .locator('#dashboard-table, table') |
| .filter({ hasText: /Assignment Submissions|Similarity Score/i }) |
| .first() |
| .waitFor({ state: 'visible', timeout: 3000 }) |
| .then(() => true) |
| .catch(() => false)) || |
| |
| (await visible(page, 'button.paper-upload-modal[title*="Resubmit"], a.paper-upload-modal:has-text("Resubmit"), button:has-text("Resubmit paper")', 3000)); |
|
|
| const hasResubmitAction = await visible( |
| page, |
| 'button.paper-upload-modal[title*="Resubmit"], a.paper-upload-modal:has-text("Resubmit"), button:has-text("Resubmit paper"), .dropdown-toggle + .dropdown-menu a:has-text("Resubmit")', |
| 3000, |
| ); |
|
|
| const hasInitialUpload = await visible( |
| page, |
| 'button.paper-upload[data-px="uploadSubmissionClicked"], button.paper-upload:has-text("Upload Submission"), button:has-text("Upload Submission"), a.paper-upload:has-text("Upload Submission")', |
| 3000, |
| ); |
|
|
| return { hasExistingSubmission, hasResubmitAction, hasInitialUpload }; |
| } |
|
|
| async function openLegacyUploadModal( |
| page: Page, |
| mode: 'upload' | 'resubmit', |
| onEvent?: EngineEvent, |
| ): Promise<{ effectiveMode: 'upload' | 'resubmit'; scope: Scope }> { |
| const state = await getLegacySubmissionState(page); |
| let effectiveMode = mode; |
|
|
| if (mode === 'upload' && (state.hasExistingSubmission || state.hasResubmitAction)) { |
| effectiveMode = 'resubmit'; |
| await emit(onEvent, 'warning', 'mode_switch', 'Existing legacy submission detected; switching to resubmit mode', { |
| requestedMode: mode, |
| effectiveMode, |
| }); |
| } else if (mode === 'resubmit' && !state.hasResubmitAction && state.hasInitialUpload) { |
| effectiveMode = 'upload'; |
| await emit(onEvent, 'warning', 'mode_switch', 'No legacy submission detected; switching to first submission mode', { |
| requestedMode: mode, |
| effectiveMode, |
| }); |
| } |
|
|
| if (effectiveMode === 'resubmit') { |
| let opened = await clickFirstVisible(page, [ |
| 'button.paper-upload-modal[title*="Resubmit"]', |
| 'a.paper-upload-modal:has-text("Resubmit paper")', |
| 'button:has-text("Resubmit paper")', |
| 'a:has-text("Resubmit paper")', |
| ], 5000); |
|
|
| if (!opened) { |
| |
| await clickFirstVisible(page, [ |
| '#dashboard-table button.dropdown-toggle', |
| '#dashboard-table .dropdown-toggle', |
| 'button.dropdown-toggle', |
| '.btn-group .dropdown-toggle', |
| ], 3000); |
| await page.waitForTimeout(800); |
| const openedFromMenu = await clickFirstVisible(page, [ |
| 'a.paper-upload-modal:has-text("Resubmit paper")', |
| 'button:has-text("Resubmit paper")', |
| 'a:has-text("Resubmit paper")', |
| '.dropdown-menu a:has-text("Resubmit")', |
| ], 5000); |
| if (!openedFromMenu) { |
| |
| |
| const hasInitialUpload = await visible(page, |
| 'button.paper-upload[data-px="uploadSubmissionClicked"], button.paper-upload:has-text("Upload Submission"), button:has-text("Upload Submission")', |
| 3000, |
| ); |
| if (hasInitialUpload) { |
| effectiveMode = 'upload'; |
| await emit(onEvent, 'warning', 'mode_switch', |
| 'Legacy resubmit action not found but Upload Submission button is present; falling back to initial upload', {}); |
| } else { |
| throw new Error('Legacy resubmit action was not found and no Upload Submission fallback is available.'); |
| } |
| } else { |
| opened = true; |
| } |
| } |
|
|
| if (effectiveMode === 'resubmit') { |
| |
| await page.waitForTimeout(800); |
| const confirmed = await clickFirstVisible(page, [ |
| 'button.paper-upload[id^="upload_type-"]:has-text("Confirm")', |
| 'button.btn-primary.paper-upload:has-text("Confirm")', |
| 'button.paper-upload:has-text("Confirm")', |
| 'button:has-text("Confirm")', |
| ], 10000); |
| if (!confirmed) { |
| |
| |
| const fileInputPresent = await visible( |
| page, |
| 'input[data-test="submission-file-select"], input#file, input[type="file"]', |
| 3000, |
| ); |
| if (!fileInputPresent) { |
| throw new Error('Legacy resubmit confirmation button was not found.'); |
| } |
| } |
| } |
| } |
|
|
| if (effectiveMode === 'upload') { |
| const opened = await clickFirstVisible(page, [ |
| 'button.paper-upload[data-px="uploadSubmissionClicked"]', |
| 'button.paper-upload:has-text("Upload Submission")', |
| 'button:has-text("Upload Submission")', |
| 'a.paper-upload:has-text("Upload Submission")', |
| ], 10000); |
| if (!opened) { |
| throw new Error('Legacy Upload Submission button was not found.'); |
| } |
| } |
|
|
| |
| await page.waitForTimeout(2000); |
| await acceptEulaEverywhere(page); |
|
|
| |
| const fileInputSelector = 'input[data-test="submission-file-select"], input#file, input[type="file"]'; |
| let scope: Scope | null = null; |
| let inputAttached = false; |
| let lastErrorMessage = 'Legacy upload file input did not appear.'; |
|
|
| for (let attempt = 1; attempt <= 2; attempt++) { |
| scope = |
| (await findLegacyAssignmentScope( |
| page, |
| fileInputSelector, |
| attempt === 1 ? 25000 : 45000, |
| )) || page; |
|
|
| inputAttached = await scope |
| .locator(fileInputSelector) |
| .first() |
| .waitFor({ state: 'attached', timeout: attempt === 1 ? 25000 : 45000 }) |
| .then(() => true) |
| .catch((error) => { |
| lastErrorMessage = error instanceof Error ? error.message : String(error); |
| return false; |
| }); |
|
|
| if (inputAttached) break; |
|
|
| logger.warn('Legacy upload file input did not appear after opening modal; retrying upload dialog', { |
| attempt, |
| effectiveMode, |
| error: lastErrorMessage, |
| }); |
| await page.keyboard.press('Escape').catch(() => {}); |
| await page.waitForTimeout(1500); |
|
|
| if (effectiveMode === 'upload') { |
| await clickFirstVisible(page, [ |
| 'button.paper-upload[data-px="uploadSubmissionClicked"]', |
| 'button.paper-upload:has-text("Upload Submission")', |
| 'button:has-text("Upload Submission")', |
| 'a.paper-upload:has-text("Upload Submission")', |
| ], 10000); |
| } else { |
| await clickFirstVisible(page, [ |
| 'button.paper-upload-modal[title*="Resubmit"]', |
| 'a.paper-upload-modal:has-text("Resubmit paper")', |
| 'button:has-text("Resubmit paper")', |
| 'a:has-text("Resubmit paper")', |
| ], 10000); |
| } |
| await page.waitForTimeout(2500); |
| await acceptEulaEverywhere(page); |
| } |
|
|
| if (!inputAttached || !scope) { |
| throw new Error(lastErrorMessage); |
| } |
|
|
| return { effectiveMode, scope }; |
| } |
|
|
| async function readLegacyReviewDetails(page: Scope): Promise<SubmissionDetails | null> { |
| const rows = await page |
| .evaluate(() => { |
| const pick = (selector: string) => |
| document.querySelector(selector)?.textContent?.trim() || ''; |
| return { |
| fileName: pick('dd[data-test="submission-review-title"]'), |
| fileSize: pick('dd[data-test="submission-review-filesize"]'), |
| wordCount: pick('dd[data-test="submission-review-wordcount"]'), |
| }; |
| }) |
| .catch(() => ({ fileName: '', fileSize: '', wordCount: '' })); |
|
|
| const details: SubmissionDetails = {}; |
| if (rows.fileName) details.fileName = rows.fileName; |
| if (rows.fileSize) details.fileSize = parseFileSizeBytes(rows.fileSize); |
| if (rows.wordCount) details.wordCount = parseNumber(rows.wordCount); |
| return Object.keys(details).length > 0 ? details : null; |
| } |
|
|
| async function completeLegacyUpload( |
| scope: Scope, |
| inputFilePath: string, |
| inputFileName?: string, |
| ): Promise<SubmissionDetails | null> { |
| const page = 'page' in scope ? (scope as any).page() : (scope as Page); |
|
|
| const fileInput = scope |
| .locator('input[data-test="submission-file-select"], input#file, input[type="file"]') |
| .first(); |
| await fileInput.setInputFiles(inputFilePath); |
| await scope.waitForTimeout(1500); |
|
|
| const titleInput = scope |
| .locator('input[data-test="submission-title"], input[name="title"], input#title') |
| .first(); |
| if (inputFileName && await titleInput.waitFor({ state: 'visible', timeout: 2000 }).then(() => true).catch(() => false)) { |
| await titleInput.fill(path.parse(inputFileName).name).catch(() => {}); |
| } |
|
|
| const reviewClicked = await clickFirstEnabled(scope, [ |
| 'button[data-test="submission-file-submit"]', |
| '.upload-and-review-btn', |
| 'button:has-text("Upload and Review")', |
| 'button:has-text("Upload & Review")', |
| ], 30000); |
| if (!reviewClicked) { |
| throw new Error('Legacy Upload and Review button was not found after selecting the file.'); |
| } |
|
|
| |
| |
| |
| const submitLocator = scope |
| .locator([ |
| 'button[data-test="submission-review-button-submit"]', |
| 'button[data-test*="confirm"]', |
| 'button[data-test*="submit"]', |
| 'button:has-text("Submit to Turnitin")', |
| 'button:has-text("Confirm")', |
| ].join(', ')) |
| .first(); |
|
|
| |
| try { |
| await submitLocator.waitFor({ state: 'visible', timeout: 150000 }); |
| } catch (err) { |
| throw new Error('Legacy review panel (Submit to Turnitin / Confirm) did not appear within 150 seconds.'); |
| } |
|
|
| const reviewDetails = await readLegacyReviewDetails(scope); |
|
|
| const submitClicked = await clickFirstVisible(scope, [ |
| 'button[data-test="submission-review-button-submit"]', |
| 'button[data-test*="confirm"]', |
| 'button[data-test*="submit"]', |
| 'button:has-text("Submit to Turnitin")', |
| 'button:has-text("Confirm")', |
| ], 30000); |
| if (!submitClicked) { |
| throw new Error('Legacy Submit to Turnitin / Confirm button was not found.'); |
| } |
|
|
| |
| const closeClicked = await clickFirstVisible(scope, [ |
| 'button[data-test="submission-complete-button-close"]', |
| 'button:has-text("Close")', |
| 'button:has-text("Go to Assignment Dashboard")', |
| ], 60000); |
|
|
| if (closeClicked) { |
| logger.info('Legacy submission completed, closed modal.'); |
| } else { |
| logger.warn('Legacy Close/Go to Assignment Dashboard button not found, continuing...'); |
| } |
|
|
| |
| await page |
| .locator('#dashboard-table a.default-open, #dashboard-table td[data-title="Similarity Score"], a.default-open[data-paper-title]') |
| .first() |
| .waitFor({ state: 'visible', timeout: 180000 }); |
|
|
| return reviewDetails; |
| } |
|
|
| async function waitForLegacySimilarity( |
| page: Page, |
| timeoutMs: number, |
| ): Promise<number | null> { |
| const startedAt = Date.now(); |
| const reloadIntervalMs = 30000; |
| let lastReloadAt = startedAt; |
|
|
| while (Date.now() - startedAt < timeoutMs) { |
| |
| const text = await page |
| .locator('td[data-title="Similarity Score"] a.similarity-open, td[data-title="Similarity Score"], a.similarity-open') |
| .first() |
| .textContent({ timeout: 5000 }) |
| .catch(() => null); |
| const percent = parsePercent(text); |
| if (percent !== null) return percent; |
|
|
| |
| const rawText = await page |
| .locator('td[data-title="Similarity Score"]') |
| .first() |
| .textContent({ timeout: 2000 }) |
| .catch(() => null); |
| const rawPercent = parsePercent(rawText); |
| if (rawPercent !== null) return rawPercent; |
|
|
| |
| if (Date.now() - lastReloadAt >= reloadIntervalMs) { |
| logger.info('Similarity score not ready yet, reloading legacy assignment dashboard...'); |
| await page.reload({ waitUntil: 'domcontentloaded', timeout: 60000 }).catch(() => {}); |
| await page.waitForTimeout(5000); |
| lastReloadAt = Date.now(); |
| } else { |
| await page.waitForTimeout(config.similarityPollMs || 5000); |
| } |
| } |
|
|
| return null; |
| } |
|
|
| |
| |
| |
| |
| |
| const VIEWER_URL_PATTERNS = [ |
| /ev\.turnitin\.com\/app\/carta/i, |
| /reports\.integrity\.turnitin\.com/i, |
| /submission-viewer/i, |
| ]; |
|
|
| function isViewerUrl(url: string): boolean { |
| return VIEWER_URL_PATTERNS.some((re) => re.test(url)); |
| } |
|
|
| async function openLegacyViewer( |
| page: Page, |
| context: BrowserContext, |
| readyTimeoutMs = 20000, |
| ): Promise<Page> { |
| const opener = page |
| .locator('td[data-title="Similarity Score"] a.similarity-open, a.default-open[data-paper-title], a.default-open') |
| .first(); |
|
|
| await opener.waitFor({ state: 'visible', timeout: 30000 }); |
| const newPagePromise = context.waitForEvent('page', { timeout: 25000 }).catch(() => null); |
| await opener.click({ force: true }); |
| const newPage = await newPagePromise; |
| const viewerPage = newPage || page; |
|
|
| await viewerPage.waitForLoadState('domcontentloaded', { timeout: 60000 }).catch(() => {}); |
| |
| |
| |
| await viewerPage.waitForTimeout(5000); |
|
|
| if (!isViewerUrl(viewerPage.url())) { |
| |
| await viewerPage |
| .waitForURL((url) => isViewerUrl(url.toString()), { timeout: 30000 }) |
| .catch(() => {}); |
| } |
|
|
| if (!isViewerUrl(viewerPage.url())) { |
| throw new Error(`Legacy report viewer did not open. Current URL: ${viewerPage.url()}`); |
| } |
|
|
| |
| |
| await visible( |
| viewerPage, |
| '.apply-changes-button, .osi-score, .sidebar-paper-info-button, .exclude-quotes-checkbox', |
| readyTimeoutMs, |
| ).catch(() => {}); |
|
|
| return viewerPage; |
| } |
|
|
| async function readLegacySubmissionDetails(page: Page): Promise<SubmissionDetails | null> { |
| await clickFirstVisible(page, [ |
| '[data-px="EVSimReportSubmissionInformationClicked"]', |
| '.sidebar-paper-info-button', |
| '[title="Submission Information"]', |
| 'button:has-text("Submission Information")', |
| 'button:has-text("Details")', |
| ], 10000); |
| await page.waitForTimeout(1200); |
|
|
| const rows = await page |
| .evaluate(() => { |
| const wanted = [ |
| 'Student ID', |
| 'Class Name', |
| 'Class ID', |
| 'Submission ID', |
| 'Submission Date', |
| 'Submission Count', |
| 'File Name', |
| 'File Extension', |
| 'File Size', |
| 'Character Count', |
| 'Char Count', |
| 'Word Count', |
| 'Page Count', |
| ]; |
| const out: Array<{ term: string; value: string }> = []; |
| const all = Array.from(document.querySelectorAll('li, dd, div, span')); |
| const clean = (value: string | null | undefined) => |
| String(value || '').replace(/\s+/g, ' ').trim(); |
|
|
| for (const label of wanted) { |
| const labelNode = all.find((node) => clean(node.textContent) === label); |
| if (!labelNode) continue; |
| const container = labelNode.closest('li, dl, .submission-details-item, .paper-info-item') || labelNode.parentElement; |
| const explicitValue = |
| container?.querySelector('[role="definition"], .submission-details-value, dd, .value, .paper-info-value')?.textContent || |
| labelNode.nextElementSibling?.textContent || |
| ''; |
| const value = clean(explicitValue); |
| if (value && value !== label) out.push({ term: label, value }); |
| } |
| return out; |
| }) |
| .catch(() => []); |
|
|
| const details: SubmissionDetails = {}; |
| for (const row of rows) { |
| const key = normalizeLabel(row.term); |
| if (!key) continue; |
|
|
| if ( |
| key === 'fileName' || |
| key === 'fileExtension' || |
| key === 'studentId' || |
| key === 'className' || |
| key === 'classId' || |
| key === 'submissionId' || |
| key === 'submissionDate' |
| ) { |
| details[key] = row.value; |
| } else if (key === 'fileSize') { |
| const parsed = parseFileSizeBytes(row.value); |
| if (parsed !== undefined) details.fileSize = parsed; |
| } else { |
| const parsed = parseNumber(row.value); |
| if (parsed !== undefined) details[key] = parsed; |
| } |
| } |
|
|
| await page.keyboard.press('Escape').catch(() => {}); |
| await page.waitForTimeout(700).catch(() => {}); |
| return Object.keys(details).length > 0 ? details : null; |
| } |
|
|
| async function openLegacyFilters(page: Page): Promise<boolean> { |
| |
| |
| |
| const alreadyVisible = await legacyFilterPanelVisible(page, 15000); |
| if (alreadyVisible) return true; |
|
|
| |
| const clicked = await clickFirstVisible(page, [ |
| '[data-px="EVSimReportFiltersClicked"]', |
| '[title="Filters and Settings"]', |
| '[data-px*="Filter"]', |
| '.sidebar-filter-button', |
| '[title="Filters"]', |
| 'button:has-text("Filters")', |
| ], 5000) || await clickLegacyCartaElement(page, [ |
| '[data-px="EVSimReportFiltersClicked"]', |
| '[title="Filters and Settings"]', |
| '[role="button"][title*="Filters"]', |
| '.tii-icon-funnel', |
| '.sc-segment-view', |
| ], ['filters and settings', 'filters'], 8000); |
|
|
| if (clicked) { |
| await resetLegacyViewerZoom(page); |
| await page.waitForTimeout(2500); |
| } |
|
|
| return await legacyFilterPanelVisible(page, 10000); |
| } |
|
|
| async function closeLegacyFilters(page: Page): Promise<void> { |
| await resetLegacyViewerZoom(page); |
| await clickLegacyCartaElement(page, [ |
| '[title="Match Overview"]', |
| '[data-px*="MatchOverview"]', |
| '[data-px*="SimilarityReport"]', |
| '.tii-icon-match-overview', |
| '.sc-segment-view', |
| ], ['match overview', 'similarity report', 'similarity'], 5000).catch(() => false); |
| await page.keyboard.press('Escape').catch(() => {}); |
| await page.waitForTimeout(1000).catch(() => {}); |
| } |
|
|
| async function legacyFilterPanelVisible(page: Page, timeoutMs = 8000): Promise<boolean> { |
| const deadline = Date.now() + timeoutMs; |
| while (Date.now() < deadline) { |
| const found = await page.evaluate(() => { |
| const isVisible = (element: Element): boolean => { |
| if (!(element instanceof HTMLElement)) return false; |
| const style = window.getComputedStyle(element); |
| const rect = element.getBoundingClientRect(); |
| return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0; |
| }; |
|
|
| const directControls = Array.from( |
| document.querySelectorAll( |
| '.apply-changes-button, .exclude-quotes-checkbox, .exclude-biblio-checkbox, .small-matches-radio-group, .filter-inputs input', |
| ), |
| ); |
| if (directControls.some(isVisible)) return true; |
|
|
| const clean = (value: string | null | undefined) => |
| String(value || '').replace(/\s+/g, ' ').trim().toLowerCase(); |
| const textFor = (element: Element): string => { |
| const labelledBy = element.getAttribute('aria-labelledby'); |
| const labelText = labelledBy |
| ?.split(/\s+/) |
| .map((id) => document.getElementById(id)?.textContent || '') |
| .join(' '); |
| return clean([ |
| element.textContent || '', |
| element.getAttribute('aria-label') || '', |
| element.getAttribute('title') || '', |
| labelText || '', |
| ].join(' ')); |
| }; |
|
|
| const controls = Array.from( |
| document.querySelectorAll<HTMLElement>('[role="checkbox"], [role="radio"][index], .sc-checkbox-control, .sc-radio-button'), |
| ).filter(isVisible); |
| const hasQuotes = controls.some((element) => textFor(element).includes('exclude quotes')); |
| const hasBibliography = controls.some((element) => textFor(element).includes('exclude bibliography')); |
| const hasSmallMatches = controls.some((element) => |
| textFor(element).includes('words') || |
| textFor(element).includes('%') || |
| textFor(element).includes("don't exclude by size"), |
| ); |
|
|
| return hasQuotes || hasBibliography || hasSmallMatches; |
| }).catch(() => false); |
| if (found) return true; |
| await page.waitForTimeout(400).catch(() => {}); |
| } |
| return false; |
| } |
|
|
| async function resetLegacyViewerZoom(page: Page): Promise<void> { |
| await page.keyboard.press('Control+0').catch(() => {}); |
| await page |
| .evaluate(() => { |
| if (document.activeElement instanceof HTMLElement) { |
| document.activeElement.blur(); |
| } |
| document.documentElement.style.zoom = '1'; |
| if (document.body) document.body.style.zoom = '1'; |
| window.scrollTo(0, 0); |
| }) |
| .catch(() => {}); |
| } |
|
|
| async function setLegacyCheckboxByText( |
| page: Page, |
| label: string, |
| enabled: boolean, |
| ): Promise<boolean> { |
| return page.evaluate( |
| ({ labelText, desired }) => { |
| const clean = (value: string | null | undefined) => |
| String(value || '').replace(/\s+/g, ' ').trim().toLowerCase(); |
| const desiredLabel = clean(labelText); |
|
|
| const textFor = (element: Element): string => { |
| const labelledBy = element.getAttribute('aria-labelledby'); |
| const labelText = labelledBy |
| ?.split(/\s+/) |
| .map((id) => document.getElementById(id)?.textContent || '') |
| .join(' '); |
| return clean([ |
| element.textContent || '', |
| element.getAttribute('aria-label') || '', |
| element.getAttribute('title') || '', |
| labelText || '', |
| ].join(' ')); |
| }; |
|
|
| const findCheckbox = (): HTMLElement | HTMLInputElement | null => { |
| const direct = desiredLabel.includes('quotes') |
| ? document.querySelector('.exclude-quotes-checkbox') |
| : desiredLabel.includes('bibliography') |
| ? document.querySelector('.exclude-biblio-checkbox') |
| : null; |
| if (direct) return direct as HTMLElement; |
|
|
| const candidates = Array.from( |
| document.querySelectorAll<HTMLElement | HTMLInputElement>( |
| '[role="checkbox"], .sc-checkbox-control, input[type="checkbox"]', |
| ), |
| ); |
| const matched = candidates.find((element) => textFor(element).includes(desiredLabel)); |
| if (matched) return matched; |
|
|
| const labelCandidates = Array.from(document.querySelectorAll('label, span, div')); |
| const labelNode = labelCandidates.find((node) => clean(node.textContent).includes(desiredLabel)); |
| if (!labelNode) return null; |
| const container = labelNode.closest('fieldset, li, label, div, tr') || labelNode.parentElement; |
| return ( |
| container?.querySelector('input[type="checkbox"], [role="checkbox"], .sc-checkbox-control') || |
| labelNode.closest('[role="checkbox"], .sc-checkbox-control') |
| ) as HTMLElement | HTMLInputElement | null; |
| }; |
|
|
| const checkbox = findCheckbox(); |
| if (!checkbox) return false; |
|
|
| const current = |
| checkbox instanceof HTMLInputElement |
| ? checkbox.checked |
| : checkbox.getAttribute('aria-checked') === 'true' || |
| checkbox.classList.contains('sel') || |
| checkbox.classList.contains('selected') || |
| checkbox.classList.contains('checked'); |
|
|
| if (current !== desired) checkbox.click(); |
| return true; |
| }, |
| { labelText: label, desired: enabled }, |
| ).catch(() => false); |
| } |
|
|
| async function setLegacySmallMatches(page: Page, filters: FilterOptions): Promise<void> { |
| const mode = filters.smallMatchMode || 'words'; |
| const enabled = Boolean(filters.excludeSmallMatches) && mode !== 'off'; |
|
|
| if (!enabled) { |
| await clickLegacySmallMatchRadio(page, '2'); |
| await resetLegacyViewerZoom(page); |
| return; |
| } |
|
|
| await clickLegacySmallMatchRadio(page, mode === 'percent' ? '1' : '0'); |
| await page.waitForTimeout(500); |
|
|
| const rawThreshold = Number(filters.smallMatchThreshold) || 8; |
| const max = mode === 'percent' ? 100 : 40; |
| const threshold = String(Math.min(max, Math.max(1, Math.round(rawThreshold)))); |
|
|
| const targetSelector = await page.evaluate((desiredMode) => { |
| const inputs = Array.from( |
| document.querySelectorAll<HTMLInputElement>( |
| '.small-matches-radio-group ~ .filter-inputs input.field, .filter-inputs input.field, .filter-inputs input, input[aria-label*="source"], input[aria-label*="match"]', |
| ), |
| ) |
| .filter((input) => { |
| const style = window.getComputedStyle(input); |
| const rect = input.getBoundingClientRect(); |
| return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0; |
| }) |
| .sort((a, b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top); |
|
|
| const input = inputs[desiredMode === 'percent' ? 1 : 0] || inputs[0]; |
| if (!input) return null; |
| document |
| .querySelectorAll('[data-relv-small-match-target]') |
| .forEach((element) => element.removeAttribute('data-relv-small-match-target')); |
| input.setAttribute('data-relv-small-match-target', desiredMode); |
| return `[data-relv-small-match-target="${desiredMode}"]`; |
| }, mode).catch(() => null); |
|
|
| let keyboardSet = false; |
| let inputName: string | null = null; |
| if (targetSelector) { |
| const target = page.locator(targetSelector).first(); |
| inputName = await target |
| .evaluate((input: HTMLInputElement) => input.name || input.id || '') |
| .catch(() => null); |
| await target.scrollIntoViewIfNeeded({ timeout: 3000 }).catch(() => {}); |
| await target.click({ force: true, clickCount: 3, timeout: 3000 }).catch(() => {}); |
| await page.keyboard.press('Control+A').catch(() => {}); |
| await page.keyboard.press('Backspace').catch(() => {}); |
| await page.keyboard.type(threshold, { delay: 45 }).catch(() => {}); |
| await page.keyboard.press('Tab').catch(() => {}); |
| await page.waitForTimeout(300); |
| const valueAfterKeyboard = await target |
| .evaluate((input: HTMLInputElement) => input.value || input.getAttribute('value') || '') |
| .catch(() => ''); |
| keyboardSet = valueAfterKeyboard.replace(/[^\d]/g, '') === threshold; |
| } |
|
|
| let setInDom = false; |
| if (!keyboardSet) { |
| setInDom = await page.evaluate( |
| ({ desiredMode, desiredValue }) => { |
| const isVisible = (element: Element): boolean => { |
| if (!(element instanceof HTMLElement)) return false; |
| const style = window.getComputedStyle(element); |
| const rect = element.getBoundingClientRect(); |
| return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0; |
| }; |
|
|
| const inputs = Array.from( |
| document.querySelectorAll<HTMLInputElement>( |
| '.small-matches-radio-group ~ .filter-inputs input.field, .filter-inputs input.field, .filter-inputs input, input[type="number"], input[aria-label*="source"], input[aria-label*="match"]', |
| ), |
| ).sort((a, b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top); |
| if (inputs.length === 0) return false; |
|
|
| const index = desiredMode === 'percent' ? 1 : 0; |
| const visibleInputs = inputs.filter(isVisible); |
| const input = inputs[index] || visibleInputs[0] || inputs[0]; |
| if (!input) return false; |
|
|
| input.focus(); |
| input.select?.(); |
| const nativeSetter = Object.getOwnPropertyDescriptor( |
| window.HTMLInputElement.prototype, |
| 'value', |
| )?.set; |
| nativeSetter?.call(input, desiredValue); |
| input.value = desiredValue; |
| input.setAttribute('value', desiredValue); |
| input.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Backspace' })); |
| input.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: 'Backspace' })); |
| for (const char of desiredValue) { |
| input.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: char })); |
| input.dispatchEvent(new KeyboardEvent('keypress', { bubbles: true, key: char })); |
| input.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: char })); |
| } |
| input.dispatchEvent(new Event('input', { bubbles: true })); |
| input.dispatchEvent(new Event('change', { bubbles: true })); |
| input.dispatchEvent(new Event('blur', { bubbles: true })); |
| input.closest('.filter-inputs')?.dispatchEvent(new Event('change', { bubbles: true })); |
| return true; |
| }, |
| { desiredMode: mode, desiredValue: threshold }, |
| ).catch(() => false); |
| } |
|
|
| await resetLegacyViewerZoom(page); |
|
|
| if (!keyboardSet && !setInDom) { |
| logger.warn('Legacy small matches threshold input was not visible', { mode, threshold }); |
| } else { |
| logger.info('Legacy small matches threshold set', { |
| mode, |
| threshold, |
| input: inputName, |
| usedKeyboard: keyboardSet, |
| usedDomFallback: setInDom, |
| }); |
| } |
| } |
|
|
| async function clickLegacySmallMatchRadio(page: Page, index: '0' | '1' | '2'): Promise<boolean> { |
| return page.evaluate((radioIndex) => { |
| const clean = (value: string | null | undefined) => |
| String(value || '').replace(/\s+/g, ' ').trim().toLowerCase(); |
| const dispatchClick = (element: HTMLElement): void => { |
| element.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, cancelable: true, view: window })); |
| element.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, view: window })); |
| element.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true, view: window })); |
| element.click(); |
| element.dispatchEvent(new Event('change', { bubbles: true })); |
| }; |
|
|
| const direct = document.querySelector<HTMLElement>( |
| `.small-matches-radio-group [role="radio"][index="${radioIndex}"], [role="radio"][index="${radioIndex}"], .radio-${radioIndex}`, |
| ); |
| if (direct) { |
| dispatchClick(direct); |
| return true; |
| } |
|
|
| const label = |
| radioIndex === '0' |
| ? 'words' |
| : radioIndex === '1' |
| ? '%' |
| : "don't exclude by size"; |
| const candidate = Array.from(document.querySelectorAll<HTMLElement>('[role="radio"], .sc-radio-button')).find( |
| (element) => clean(element.textContent).includes(label), |
| ); |
| if (!candidate) return false; |
| dispatchClick(candidate); |
| return true; |
| }, index).catch(() => false); |
| } |
|
|
| type LegacyFilterState = { |
| excludeQuotes: boolean | null; |
| excludeBibliography: boolean | null; |
| smallMatchMode: 'words' | 'percent' | 'off' | null; |
| wordsThreshold: number | null; |
| percentThreshold: number | null; |
| }; |
|
|
| function getDesiredLegacySmallMatch(filters: FilterOptions): { |
| mode: 'words' | 'percent' | 'off'; |
| threshold: number | null; |
| } { |
| if (!filters.excludeSmallMatches || filters.smallMatchMode === 'off') { |
| return { mode: 'off', threshold: null }; |
| } |
|
|
| const mode = filters.smallMatchMode === 'percent' ? 'percent' : 'words'; |
| const max = mode === 'percent' ? 100 : 40; |
| const threshold = Math.min( |
| max, |
| Math.max(1, Math.round(Number(filters.smallMatchThreshold) || 8)), |
| ); |
|
|
| return { mode, threshold }; |
| } |
|
|
| async function readLegacyFilterState(page: Page): Promise<LegacyFilterState> { |
| return page.evaluate(() => { |
| const clean = (value: string | null | undefined) => |
| String(value || '').replace(/\s+/g, ' ').trim().toLowerCase(); |
|
|
| const textFor = (element: Element): string => { |
| const labelledBy = element.getAttribute('aria-labelledby'); |
| const labelText = labelledBy |
| ?.split(/\s+/) |
| .map((id) => document.getElementById(id)?.textContent || '') |
| .join(' '); |
| return clean([ |
| element.textContent || '', |
| element.getAttribute('aria-label') || '', |
| element.getAttribute('title') || '', |
| labelText || '', |
| ].join(' ')); |
| }; |
|
|
| const isVisible = (element: Element): boolean => { |
| if (!(element instanceof HTMLElement)) return false; |
| const style = window.getComputedStyle(element); |
| const rect = element.getBoundingClientRect(); |
| return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0; |
| }; |
|
|
| const stateForToggle = (element: HTMLElement | HTMLInputElement): boolean | null => { |
| if (element instanceof HTMLInputElement) return element.checked; |
| const input = element.querySelector('input[type="checkbox"], input[type="radio"]') as HTMLInputElement | null; |
| if (input) return input.checked; |
| const aria = element.getAttribute('aria-checked'); |
| if (aria === 'true') return true; |
| if (aria === 'false') return false; |
| if ( |
| element.classList.contains('sel') || |
| element.classList.contains('selected') || |
| element.classList.contains('checked') |
| ) { |
| return true; |
| } |
| return null; |
| }; |
|
|
| const findByLabel = ( |
| directSelector: string, |
| roleSelector: string, |
| label: string, |
| ): HTMLElement | HTMLInputElement | null => { |
| const direct = document.querySelector(directSelector) as HTMLElement | HTMLInputElement | null; |
| if (direct) return direct; |
|
|
| const desiredLabel = clean(label); |
| const candidates = Array.from( |
| document.querySelectorAll<HTMLElement | HTMLInputElement>(roleSelector), |
| ); |
| const matched = candidates.find((element) => textFor(element).includes(desiredLabel)); |
| if (matched) return matched; |
|
|
| const labels = Array.from(document.querySelectorAll('label, span, div')); |
| const labelNode = labels.find((node) => clean(node.textContent).includes(desiredLabel)); |
| const container = labelNode?.closest('fieldset, li, label, div, tr') || labelNode?.parentElement; |
| return ( |
| container?.querySelector(roleSelector) || |
| labelNode?.closest(roleSelector) |
| ) as HTMLElement | HTMLInputElement | null; |
| }; |
|
|
| const readChecked = (directSelector: string, label: string): boolean | null => { |
| const element = findByLabel( |
| directSelector, |
| '[role="checkbox"], .sc-checkbox-control, input[type="checkbox"]', |
| label, |
| ); |
| if (!element) return null; |
| return stateForToggle(element); |
| }; |
|
|
| const readRadio = (index: string, label: string): boolean => { |
| const element = ( |
| document.querySelector( |
| `.small-matches-radio-group [role="radio"][index="${index}"], [role="radio"][index="${index}"], .radio-${index}`, |
| ) || |
| Array.from(document.querySelectorAll<HTMLElement>('[role="radio"], .sc-radio-button')).find( |
| (candidate) => textFor(candidate).includes(clean(label)), |
| ) |
| ) as HTMLElement | HTMLInputElement | null; |
| if (!element) return false; |
| return stateForToggle(element) === true; |
| }; |
|
|
| const thresholdInputs = (Array.from( |
| document.querySelectorAll( |
| '.small-matches-radio-group ~ .filter-inputs input.field, .filter-inputs input.field, .filter-inputs input, input[type="number"], input[aria-label*="source"], input[aria-label*="match"]', |
| ), |
| ) as HTMLInputElement[]).sort( |
| (a, b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top, |
| ); |
|
|
| const parseThresholdInput = (input: HTMLInputElement | undefined): number | null => { |
| if (!input) return null; |
| const raw = String(input.value || '').replace(/[^\d]/g, ''); |
| if (!raw) return null; |
| const numeric = Number(raw); |
| return Number.isFinite(numeric) ? numeric : null; |
| }; |
|
|
| const parseInput = (index: number): number | null => parseThresholdInput(thresholdInputs[index]); |
|
|
| const visibleThreshold = parseThresholdInput( |
| thresholdInputs.find((input) => isVisible(input)), |
| ); |
|
|
| let smallMatchMode: LegacyFilterState['smallMatchMode'] = null; |
| if (readRadio('0', 'words')) smallMatchMode = 'words'; |
| else if (readRadio('1', '%')) smallMatchMode = 'percent'; |
| else if (readRadio('2', "don't exclude by size")) smallMatchMode = 'off'; |
|
|
| let wordsThreshold = parseInput(0); |
| let percentThreshold = parseInput(1); |
| if (smallMatchMode === 'words' && wordsThreshold === null) { |
| wordsThreshold = visibleThreshold; |
| } |
| if (smallMatchMode === 'percent' && percentThreshold === null) { |
| percentThreshold = visibleThreshold; |
| } |
|
|
| return { |
| excludeQuotes: readChecked('.exclude-quotes-checkbox', 'Exclude Quotes'), |
| excludeBibliography: readChecked('.exclude-biblio-checkbox', 'Exclude Bibliography'), |
| smallMatchMode, |
| wordsThreshold, |
| percentThreshold, |
| }; |
| }); |
| } |
|
|
| async function verifyLegacyFilterStates( |
| page: Page, |
| filters: FilterOptions, |
| ): Promise<{ state: LegacyFilterState; mismatches: string[] }> { |
| const desiredSmallMatch = getDesiredLegacySmallMatch(filters); |
| const state = await readLegacyFilterState(page); |
| const mismatches: string[] = []; |
|
|
| const expectedQuotes = Boolean(filters.excludeQuotes); |
| const expectedBibliography = Boolean(filters.excludeBibliography); |
|
|
| if (expectedQuotes ? state.excludeQuotes !== true : state.excludeQuotes === true) { |
| mismatches.push(`Exclude Quotes expected ${expectedQuotes}, got ${state.excludeQuotes}`); |
| } |
| if (expectedBibliography ? state.excludeBibliography !== true : state.excludeBibliography === true) { |
| mismatches.push( |
| `Exclude Bibliography expected ${expectedBibliography}, got ${state.excludeBibliography}`, |
| ); |
| } |
| if ( |
| desiredSmallMatch.mode !== 'off' && |
| state.smallMatchMode !== desiredSmallMatch.mode |
| ) { |
| mismatches.push( |
| `Small matches mode expected ${desiredSmallMatch.mode}, got ${state.smallMatchMode}`, |
| ); |
| } |
| if ( |
| desiredSmallMatch.mode === 'off' && |
| (state.smallMatchMode === 'words' || state.smallMatchMode === 'percent') |
| ) { |
| mismatches.push( |
| `Small matches mode expected off, got ${state.smallMatchMode}`, |
| ); |
| } |
| if (desiredSmallMatch.mode === 'words' && state.wordsThreshold !== desiredSmallMatch.threshold) { |
| mismatches.push( |
| `Small matches words threshold expected ${desiredSmallMatch.threshold}, got ${state.wordsThreshold}`, |
| ); |
| } |
| if (desiredSmallMatch.mode === 'percent' && state.percentThreshold !== desiredSmallMatch.threshold) { |
| mismatches.push( |
| `Small matches percent threshold expected ${desiredSmallMatch.threshold}, got ${state.percentThreshold}`, |
| ); |
| } |
|
|
| return { state, mismatches }; |
| } |
|
|
| async function applyLegacyFilters(page: Page, filters: FilterOptions): Promise<void> { |
| const ownerPage = page; |
| const activeFilters = hasActiveFilters(filters); |
|
|
| for (let attempt = 1; attempt <= 3; attempt++) { |
| const opened = await openLegacyFilters(page); |
| if (!opened) { |
| if (attempt < 3) { |
| |
| await ownerPage.reload({ waitUntil: 'domcontentloaded', timeout: 30000 }).catch(() => {}); |
| await ownerPage.waitForTimeout(10000); |
| continue; |
| } |
| if (activeFilters) { |
| throw new Error('Legacy filter panel could not be opened after 3 viewer refresh attempts.'); |
| } |
| return; |
| } |
|
|
| const initialVerification = await verifyLegacyFilterStates(page, filters).catch(() => null); |
| const alreadyApplied = |
| initialVerification !== null && initialVerification.mismatches.length === 0; |
|
|
| if (alreadyApplied) { |
| logger.info('Legacy filters already matched requested state; skipping Apply Changes', { |
| state: initialVerification.state, |
| filters, |
| }); |
| await closeLegacyFilters(page); |
| return; |
| } |
|
|
| await setLegacyCheckboxByText(page, 'Exclude Quotes', Boolean(filters.excludeQuotes)); |
| await setLegacyCheckboxByText(page, 'Exclude Bibliography', Boolean(filters.excludeBibliography)); |
| await setLegacySmallMatches(page, filters); |
| await page.waitForTimeout(700); |
|
|
| let verification = await verifyLegacyFilterStates(page, filters); |
| if (verification.mismatches.length > 0) { |
| logger.warn('Legacy filter verification failed after first set; retrying filter state update', { |
| mismatches: verification.mismatches, |
| state: verification.state, |
| filters, |
| }); |
|
|
| await setLegacyCheckboxByText(page, 'Exclude Quotes', Boolean(filters.excludeQuotes)); |
| await setLegacyCheckboxByText(page, 'Exclude Bibliography', Boolean(filters.excludeBibliography)); |
| await setLegacySmallMatches(page, filters); |
| await page.waitForTimeout(700); |
| verification = await verifyLegacyFilterStates(page, filters); |
| } |
|
|
| if (verification.mismatches.length > 0) { |
| throw new Error( |
| `Legacy filters could not be verified before applying changes: ${verification.mismatches.join('; ')}`, |
| ); |
| } |
|
|
| logger.info('Legacy filter verification passed', { |
| state: verification.state, |
| filters, |
| }); |
|
|
| await resetLegacyViewerZoom(page); |
| const appliedByDom = await clickLegacyApplyChanges(page); |
| if (appliedByDom) { |
| await page.waitForTimeout(3500); |
| return; |
| } |
|
|
| if (!activeFilters) { |
| await closeLegacyFilters(page); |
| return; |
| } |
| if (attempt < 3) { |
| await ownerPage.reload({ waitUntil: 'domcontentloaded', timeout: 30000 }).catch(() => {}); |
| await ownerPage.waitForTimeout(5000); |
| } |
| } |
|
|
| throw new Error('Legacy Apply Changes button was not visible after 3 viewer refresh attempts.'); |
| } |
|
|
| async function clickLegacyApplyChanges(page: Page): Promise<boolean> { |
| const deadline = Date.now() + 12000; |
| let foundDisabled = false; |
|
|
| while (Date.now() < deadline) { |
| const result = await page.evaluate(() => { |
| const clean = (value: string | null | undefined) => |
| String(value || '').replace(/\s+/g, ' ').trim().toLowerCase(); |
| const textFor = (element: Element): string => { |
| const labelledBy = element.getAttribute('aria-labelledby'); |
| const labelText = labelledBy |
| ?.split(/\s+/) |
| .map((id) => document.getElementById(id)?.textContent || '') |
| .join(' '); |
| return clean([ |
| element.textContent || '', |
| element.getAttribute('aria-label') || '', |
| element.getAttribute('title') || '', |
| labelText || '', |
| ].join(' ')); |
| }; |
|
|
| const button = ( |
| document.querySelector<HTMLElement>('.apply-changes-button') || |
| Array.from(document.querySelectorAll<HTMLElement>('[role="button"], button, .sc-button-view')).find((element) => |
| textFor(element).includes('apply changes'), |
| ) |
| ) as HTMLElement | null; |
|
|
| if (!button) return { found: false, disabled: false, clicked: false }; |
|
|
| const disabled = |
| button.classList.contains('disabled') || |
| button.getAttribute('aria-disabled') === 'true' || |
| button.hasAttribute('disabled') || |
| (button as HTMLButtonElement).disabled === true; |
|
|
| if (disabled) return { found: true, disabled: true, clicked: false }; |
|
|
| button.scrollIntoView({ block: 'center', inline: 'center' }); |
| button.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, cancelable: true, view: window })); |
| button.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, view: window })); |
| button.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true, view: window })); |
| button.click(); |
| return { found: true, disabled: false, clicked: true }; |
| }).catch(() => ({ found: false, disabled: false, clicked: false })); |
|
|
| if (result.clicked) return true; |
| if (result.found && result.disabled) foundDisabled = true; |
| await page.waitForTimeout(500).catch(() => {}); |
| } |
|
|
| if (foundDisabled) { |
| const forceClicked = await page.evaluate(() => { |
| const clean = (value: string | null | undefined) => |
| String(value || '').replace(/\s+/g, ' ').trim().toLowerCase(); |
| const textFor = (element: Element): string => { |
| const labelledBy = element.getAttribute('aria-labelledby'); |
| const labelText = labelledBy |
| ?.split(/\s+/) |
| .map((id) => document.getElementById(id)?.textContent || '') |
| .join(' '); |
| return clean([ |
| element.textContent || '', |
| element.getAttribute('aria-label') || '', |
| element.getAttribute('title') || '', |
| labelText || '', |
| ].join(' ')); |
| }; |
| const button = ( |
| document.querySelector<HTMLElement>('.apply-changes-button') || |
| Array.from(document.querySelectorAll<HTMLElement>('[role="button"], button, .sc-button-view')).find((element) => |
| textFor(element).includes('apply changes'), |
| ) |
| ) as HTMLElement | null; |
| if (!button) return false; |
| button.classList.remove('disabled'); |
| button.removeAttribute('disabled'); |
| button.setAttribute('aria-disabled', 'false'); |
| button.scrollIntoView({ block: 'center', inline: 'center' }); |
| button.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, cancelable: true, view: window })); |
| button.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, view: window })); |
| button.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true, view: window })); |
| button.click(); |
| return true; |
| }).catch(() => false); |
|
|
| if (forceClicked) { |
| logger.warn('Legacy Apply Changes button stayed disabled; force-clicked after verified filter values'); |
| await page.waitForTimeout(2500).catch(() => {}); |
| const stillOpen = await page |
| .locator('.apply-changes-button') |
| .first() |
| .waitFor({ state: 'visible', timeout: 1000 }) |
| .then(() => true) |
| .catch(() => false); |
| return !stillOpen; |
| } |
|
|
| logger.warn('Legacy Apply Changes button stayed disabled after filter state verification'); |
| } |
|
|
| return false; |
| } |
|
|
| async function readLegacyViewerSimilarity(page: Page): Promise<number | null> { |
| await page.waitForTimeout(1000); |
| const text = await page |
| .locator('.osi-score, label[title="Match Overview"], [title="Match Overview"]') |
| .first() |
| .textContent({ timeout: 10000 }) |
| .catch(() => null); |
| return parsePercent(text); |
| } |
|
|
| async function clickLegacyCartaElement( |
| page: Page, |
| selectors: string[], |
| textIncludes: string[], |
| timeoutMs = 10000, |
| ): Promise<boolean> { |
| const deadline = Date.now() + timeoutMs; |
| while (Date.now() < deadline) { |
| const clicked = await page.evaluate( |
| ({ selectors: rawSelectors, textIncludes: rawTextIncludes }) => { |
| const clean = (value: string | null | undefined) => |
| String(value || '').replace(/\s+/g, ' ').trim().toLowerCase(); |
|
|
| const textFor = (element: Element): string => { |
| const labelledBy = element.getAttribute('aria-labelledby'); |
| const labelText = labelledBy |
| ?.split(/\s+/) |
| .map((id) => document.getElementById(id)?.textContent || '') |
| .join(' '); |
| return clean([ |
| element.textContent || '', |
| element.getAttribute('aria-label') || '', |
| element.getAttribute('title') || '', |
| labelText || '', |
| element.getAttribute('data-px') || '', |
| ].join(' ')); |
| }; |
|
|
| const visibleScore = (element: Element): number => { |
| if (!(element instanceof HTMLElement)) return 0; |
| const style = window.getComputedStyle(element); |
| const rect = element.getBoundingClientRect(); |
| if (style.display === 'none' || style.visibility === 'hidden') return 0; |
| if (rect.width > 0 && rect.height > 0) return 2; |
| return 1; |
| }; |
|
|
| const textNeedles = rawTextIncludes.map(clean).filter(Boolean); |
| const candidates: HTMLElement[] = []; |
| for (const selector of rawSelectors) { |
| try { |
| candidates.push(...Array.from(document.querySelectorAll<HTMLElement>(selector))); |
| } catch { |
| |
| } |
| } |
|
|
| if (textNeedles.length > 0) { |
| const controls = Array.from( |
| document.querySelectorAll<HTMLElement>( |
| '[role="button"], button, a, .sc-button-view, .sc-segment-view, .sc-list-item-view, .btn-link', |
| ), |
| ); |
| candidates.push( |
| ...controls.filter((element) => { |
| const text = textFor(element); |
| return textNeedles.some((needle) => text.includes(needle)); |
| }), |
| ); |
| } |
|
|
| const unique = Array.from(new Set(candidates)); |
| const target = unique |
| .filter((element) => !element.hasAttribute('disabled') && element.getAttribute('aria-disabled') !== 'true') |
| .sort((a, b) => visibleScore(b) - visibleScore(a))[0]; |
| if (!target) return false; |
|
|
| target.scrollIntoView({ block: 'center', inline: 'center' }); |
| target.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, cancelable: true, view: window })); |
| target.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, view: window })); |
| target.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true, view: window })); |
| target.click(); |
| return true; |
| }, |
| { selectors, textIncludes }, |
| ).catch(() => false); |
|
|
| if (clicked) return true; |
| await page.waitForTimeout(250).catch(() => {}); |
| } |
| return false; |
| } |
|
|
| type LegacyDownloadOption = 'current_view' | 'digital_receipt'; |
|
|
| async function downloadLegacyPdf( |
| page: Page, |
| context: BrowserContext, |
| outputPath: string, |
| option: LegacyDownloadOption, |
| ): Promise<string> { |
| let lastError: Error | null = null; |
| const isReceipt = option === 'digital_receipt'; |
| const optionLabel = isReceipt ? 'Digital Receipt' : 'Current View'; |
| const optionSelectors = isReceipt |
| ? [ |
| '[data-px="EVSimReportDownloadDigitalReceipt"]', |
| '[aria-label="Digital Receipt"]', |
| '.print-download-items [role="button"]:has-text("Digital Receipt")', |
| '.print-download-btn:has-text("Digital Receipt")', |
| 'button:has-text("Digital Receipt")', |
| 'a:has-text("Digital Receipt")', |
| '[role="menuitem"]:has-text("Digital Receipt")', |
| ] |
| : [ |
| '[data-px="EVSimReportDownloadCurrentView"]', |
| '[aria-label="Current View"]', |
| '.print-download-items [role="button"]:has-text("Current View")', |
| '.print-download-btn:has-text("Current View")', |
| 'button:has-text("Current View")', |
| 'a:has-text("Current View")', |
| '[role="menuitem"]:has-text("Current View")', |
| ]; |
| const cartaSelectors = isReceipt |
| ? [ |
| '[data-px="EVSimReportDownloadDigitalReceipt"]', |
| '[aria-label="Digital Receipt"]', |
| ] |
| : [ |
| '[data-px="EVSimReportDownloadCurrentView"]', |
| '[aria-label="Current View"]', |
| ]; |
|
|
| for (let attempt = 1; attempt <= 3; attempt++) { |
| try { |
| await page.waitForLoadState('domcontentloaded', { timeout: 15000 }).catch(() => {}); |
| await page.waitForTimeout(attempt === 1 ? 1000 : 4000); |
|
|
| const opened = await clickFirstVisible(page, [ |
| '[data-px="EVSimReportDownloadClicked"]', |
| '.sidebar-download-button', |
| '[title="Download"]', |
| 'button:has-text("Download")', |
| ], 8000) || await clickLegacyCartaElement(page, [ |
| '[data-px="EVSimReportDownloadClicked"]', |
| '.sidebar-download-button', |
| '[title="Download"]', |
| '[role="button"][title*="Download"]', |
| '.tii-icon-download', |
| ], ['download'], 8000); |
|
|
| if (!opened) { |
| throw new Error('Legacy download menu button was not found.'); |
| } |
| await page.waitForTimeout(1200); |
|
|
| const downloadPromise = waitForAnyDownload(context, 120000); |
| const selected = await clickFirstVisible(page, optionSelectors, 8000) || |
| await clickLegacyCartaElement( |
| page, |
| cartaSelectors, |
| [optionLabel.toLowerCase()], |
| 12000, |
| ); |
|
|
| if (!selected) { |
| void downloadPromise.catch(() => {}); |
| throw new Error(`Legacy ${optionLabel} download option was not found.`); |
| } |
|
|
| const download = await downloadPromise; |
| await download.saveAs(outputPath); |
| const stat = fs.statSync(outputPath); |
| if (stat.size <= 0) { |
| try { fs.unlinkSync(outputPath); } catch { } |
| throw new Error(`Downloaded legacy PDF is empty: ${outputPath}`); |
| } |
| return outputPath; |
| } catch (error) { |
| lastError = error instanceof Error ? error : new Error(String(error)); |
| logger.warn('Legacy PDF download attempt failed', { |
| attempt, |
| option, |
| error: lastError.message, |
| }); |
| if (attempt < 3) { |
| await page.keyboard.press('Escape').catch(() => {}); |
| await page.reload({ waitUntil: 'domcontentloaded', timeout: 30000 }).catch(() => {}); |
| } |
| } |
| } |
|
|
| throw lastError || new Error(`Legacy ${optionLabel} PDF download failed.`); |
| } |
|
|
| export async function runLegacyTurnitinJob( |
| input: RunTurnitinJobInput, |
| ): Promise<RunTurnitinJobResult> { |
| const { |
| account, |
| assignmentTarget, |
| inputFilePath, |
| inputFileName, |
| outputDir, |
| mode: requestedMode, |
| filters, |
| storageStatePath, |
| resumeAfterStep, |
| resumeViewerUrl, |
| onEvent, |
| } = input; |
|
|
| const result: RunTurnitinJobResult = {}; |
| let page: Page | null = null; |
| const localDocumentMetadata = |
| requestedMode === 'quota_check' |
| ? {} |
| : await readLocalDocumentMetadata(inputFilePath); |
| const localPageCount = localDocumentMetadata.pageCount; |
| const largeDocument = Boolean( |
| localPageCount && localPageCount >= LARGE_DOCUMENT_PAGE_THRESHOLD, |
| ); |
| const similarityTimeoutMs = getLegacySimilarityTimeoutMs(localPageCount); |
| const expectedWaitMinutes = Math.ceil(similarityTimeoutMs / 60000); |
|
|
| if (localPageCount) { |
| result.submissionDetails = { |
| pageCount: localPageCount, |
| pageCountSource: localDocumentMetadata.pageCountSource, |
| expectedWaitMinutes, |
| largeDocument, |
| }; |
| await emit( |
| onEvent, |
| 'info', |
| 'submission_details', |
| `Document page count detected: ${localPageCount} pages.`, |
| { |
| pageCount: localPageCount, |
| pageCountSource: localDocumentMetadata.pageCountSource, |
| expectedWaitMinutes, |
| largeDocument, |
| }, |
| ); |
| if (largeDocument) { |
| await emit( |
| onEvent, |
| 'warning', |
| 'large_document', |
| `Large document detected: ${localPageCount} pages. Similarity processing may take up to ${expectedWaitMinutes} minutes.`, |
| { |
| pageCount: localPageCount, |
| pageCountSource: localDocumentMetadata.pageCountSource, |
| expectedWaitMinutes, |
| largeDocument: true, |
| }, |
| ); |
| } |
| } |
|
|
| const browser = await getBrowser(); |
| const contextOptions: Record<string, unknown> = { |
| userAgent: |
| 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36', |
| viewport: { width: 1366, height: 768 }, |
| acceptDownloads: true, |
| extraHTTPHeaders: { 'Accept-Language': 'en-US,en;q=0.9' }, |
| }; |
|
|
| if (storageStatePath && fs.existsSync(storageStatePath)) { |
| contextOptions.storageState = storageStatePath; |
| } |
|
|
| const context = await browser.newContext(contextOptions as any); |
|
|
| try { |
| await emit(onEvent, 'info', 'browser', 'Creating legacy browser context'); |
| page = await context.newPage(); |
|
|
| await emit(onEvent, 'info', 'login', 'Logging in to Turnitin', { email: account.email }); |
| await loginToTurnitin(page, account.email, account.password, storageStatePath, assignmentTarget.targetUrl); |
| result.lastCompletedStep = 'login'; |
|
|
| const resumeFromViewer = |
| resumeAfterStep && |
| ['viewer', 'filters', 'download', 'receipt'].includes(resumeAfterStep) && |
| resumeViewerUrl; |
|
|
| if (resumeFromViewer) { |
| await emit(onEvent, 'info', 'viewer', 'Reopening legacy viewer from previous attempt', { |
| viewerUrl: resumeViewerUrl, |
| }); |
| page = await context.newPage(); |
| await page.goto(resumeViewerUrl, { waitUntil: 'domcontentloaded', timeout: 60000 }); |
| await page.waitForTimeout(3000); |
| result.viewerUrl = page.url(); |
| result.lastCompletedStep = 'viewer'; |
| } else { |
| await emit(onEvent, 'info', 'navigate', 'Navigating to legacy assignment', { |
| classTitle: assignmentTarget.classTitle, |
| assignmentTitle: assignmentTarget.assignmentTitle, |
| }); |
| await navigateLegacyAssignment( |
| page, |
| assignmentTarget.classTitle, |
| assignmentTarget.assignmentTitle, |
| onEvent, |
| ); |
| |
| |
| result.lastCompletedStep = 'navigate'; |
| await emit(onEvent, 'info', 'navigate', 'Legacy assignment page loaded'); |
|
|
| if (requestedMode === 'quota_check') { |
| const details = await openLegacyViewer(page, context) |
| .then((viewer) => readLegacySubmissionDetails(viewer)) |
| .catch(() => null); |
| if (details) result.submissionDetails = details; |
| result.submissionCount = details?.submissionCount; |
| result.lastCompletedStep = 'quota_check'; |
| return result; |
| } |
|
|
| if (resumeAfterStep && ['submitted', 'similarity'].includes(resumeAfterStep)) { |
| await emit(onEvent, 'info', 'resume', 'Resuming legacy job after previous upload; skipping upload step', { |
| resumeAfterStep, |
| }); |
| result.lastCompletedStep = 'submitted'; |
| } else { |
| const { effectiveMode, scope } = await openLegacyUploadModal(page, requestedMode, onEvent); |
| await emit(onEvent, 'info', effectiveMode, effectiveMode === 'resubmit' ? 'Resubmitting legacy file' : 'Uploading legacy file', { |
| filePath: inputFilePath, |
| }); |
| const reviewDetails = await completeLegacyUpload(scope, inputFilePath, inputFileName || path.basename(inputFilePath)); |
| if (reviewDetails) { |
| result.submissionDetails = { |
| ...(result.submissionDetails || {}), |
| ...reviewDetails, |
| }; |
| await emit( |
| onEvent, |
| 'info', |
| 'submission_details', |
| 'Legacy review details captured', |
| result.submissionDetails as Record<string, unknown>, |
| ); |
| } |
| result.submittedAt = new Date().toISOString(); |
| result.lastCompletedStep = 'submitted'; |
| await emit(onEvent, 'info', 'submitted', 'File submitted successfully', { |
| submittedAt: result.submittedAt, |
| }); |
| } |
|
|
| await emit(onEvent, 'info', 'similarity', 'Waiting for legacy similarity score'); |
| const tableSimilarity = await waitForLegacySimilarity(page, similarityTimeoutMs); |
| if (tableSimilarity !== null) result.similarityPercent = tableSimilarity; |
| result.lastCompletedStep = 'similarity'; |
| await emit(onEvent, 'info', 'similarity', `Similarity: ${result.similarityPercent ?? 'pending'}%`, { |
| similarityPercent: result.similarityPercent, |
| pageCount: localPageCount, |
| timeoutMs: similarityTimeoutMs, |
| }); |
|
|
| if (tableSimilarity === null) { |
| throw new Error( |
| `Legacy similarity is still processing after ${expectedWaitMinutes} minutes. Retrying from the similarity checkpoint without uploading the file again.`, |
| ); |
| } |
|
|
| await emit(onEvent, 'info', 'viewer', 'Opening legacy report viewer'); |
| page = await openLegacyViewer( |
| page, |
| context, |
| largeDocument ? 60000 : 20000, |
| ); |
| result.viewerUrl = page.url(); |
| result.lastCompletedStep = 'viewer'; |
| await emit(onEvent, 'info', 'viewer', 'Legacy report viewer opened', { |
| viewerUrl: result.viewerUrl, |
| }); |
| } |
|
|
| const details = await readLegacySubmissionDetails(page); |
| if (details) { |
| result.submissionDetails = { ...(result.submissionDetails || {}), ...details }; |
| result.submissionCount = details.submissionCount; |
| await emit(onEvent, 'info', 'submission_details', 'Legacy submission details captured', result.submissionDetails as Record<string, unknown>); |
| } |
|
|
| await emit(onEvent, 'info', 'filters', 'Applying legacy filters', { filters }); |
| await applyLegacyFilters(page, filters); |
| result.lastCompletedStep = 'filters'; |
|
|
| const viewerSimilarity = await readLegacyViewerSimilarity(page); |
| if (viewerSimilarity !== null) { |
| result.similarityPercent = viewerSimilarity; |
| await emit(onEvent, 'info', 'similarity', `Viewer similarity (post-filter): ${viewerSimilarity}%`, { |
| similarityPercent: viewerSimilarity, |
| filtered: hasActiveFilters(filters), |
| }); |
| } |
|
|
| await emit(onEvent, 'info', 'download', 'Downloading legacy PDF report'); |
| fs.mkdirSync(outputDir, { recursive: true }); |
| const outputPdfPath = path.join(outputDir, `turnitin_legacy_report_${Date.now()}.pdf`); |
| result.outputPdfPath = await downloadLegacyPdf( |
| page, |
| context, |
| outputPdfPath, |
| 'current_view', |
| ); |
| result.lastCompletedStep = 'download'; |
| await emit(onEvent, 'info', 'download', 'PDF downloaded successfully', { |
| outputPdfPath: result.outputPdfPath, |
| }); |
|
|
| |
| |
| await page.keyboard.press('Escape').catch(() => {}); |
| await page.waitForTimeout(500); |
| await emit(onEvent, 'info', 'receipt', 'Downloading legacy Digital Receipt'); |
| const receiptPdfPath = path.join(outputDir, `turnitin_legacy_receipt_${Date.now()}.pdf`); |
| result.receiptPdfPath = await downloadLegacyPdf( |
| page, |
| context, |
| receiptPdfPath, |
| 'digital_receipt', |
| ); |
| result.lastCompletedStep = 'receipt'; |
| await emit(onEvent, 'info', 'receipt', 'Digital Receipt downloaded successfully', { |
| receiptPdfPath: result.receiptPdfPath, |
| }); |
|
|
| const accountQuotaRemaining = |
| typeof input.account.quotaRemaining === 'number' |
| ? input.account.quotaRemaining |
| : null; |
| const shouldPermanentlyLimitLegacy = |
| (typeof result.submissionCount === 'number' && result.submissionCount >= LEGACY_ACCOUNT_QUOTA_LIMIT) || |
| accountQuotaRemaining === 1; |
|
|
| if (shouldPermanentlyLimitLegacy) { |
| const message = |
| 'Legacy Turnitin account reached its 4-submission limit. Class will be dropped and account will be permanently limited.'; |
| await emit(onEvent, 'warning', 'class_cleanup', 'Legacy submission limit reached; dropping class from account', { |
| classTitle: assignmentTarget.classTitle, |
| submissionCount: result.submissionCount ?? null, |
| accountQuotaRemaining, |
| }); |
| const dropClassResult = await dropClassByTitle(page, assignmentTarget.classTitle).catch((dropError: unknown) => ({ |
| attempted: true, |
| dropped: false, |
| reason: dropError instanceof Error ? dropError.message : String(dropError), |
| })); |
| await emit( |
| onEvent, |
| dropClassResult.dropped ? 'info' : 'warning', |
| 'class_cleanup', |
| dropClassResult.dropped |
| ? 'Legacy class dropped or already absent after submission limit' |
| : 'Legacy class could not be dropped automatically after submission limit', |
| { ...dropClassResult }, |
| ); |
| result.permanentLimit = { |
| message, |
| submissionCount: result.submissionCount, |
| dropClassResult, |
| }; |
| } |
|
|
| if (storageStatePath) { |
| await context.storageState({ path: storageStatePath }).catch(() => {}); |
| } |
|
|
| return result; |
| } catch (error) { |
| const message = error instanceof Error ? error.message : String(error); |
| if (page) { |
| const screenshotPath = path.join(outputDir || '/tmp', `legacy_error_${Date.now()}.png`); |
| await page.screenshot({ path: screenshotPath }).catch(() => {}); |
| logger.info(`Saved error screenshot to ${screenshotPath}`); |
| } |
| await emit(onEvent, 'error', 'error', compactErrorMessage(message), { |
| errorName: error instanceof Error ? error.name : 'UnknownError', |
| }); |
| if (error && typeof error === 'object') { |
| (error as any).lastCompletedStep = result.lastCompletedStep; |
| (error as any).viewerUrl = result.viewerUrl; |
| (error as any).similarityPercent = result.similarityPercent; |
| } |
| throw error; |
| } finally { |
| await context.close().catch(() => {}); |
| } |
| } |
|
|