Spaces:
Sleeping
Sleeping
| 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; | |
| } | |
| /** | |
| * Navigate to the legacy Turnitin assignment dashboard. | |
| * | |
| * After logging into the legacy student account Turnitin may redirect the user | |
| * directly to the assignment dashboard (common for accounts enrolled in a | |
| * single class with a single assignment). We detect this and skip navigation. | |
| * | |
| * Three recovery paths (in order): | |
| * 1. Already on assignment dashboard → skip entirely. | |
| * 2. On the class / assignment-list page → click the assignment Open button. | |
| * 3. On the home page → full class → assignment navigation. | |
| */ | |
| async function navigateLegacyAssignment( | |
| page: Page, | |
| classTitle: string, | |
| assignmentTitle: string | null | undefined, | |
| onEvent?: EngineEvent, | |
| ): Promise<void> { | |
| // Allow the post-login redirect to settle. | |
| await page.waitForTimeout(2500); | |
| // ── Case 1: Already on the assignment dashboard ───────────────────────── | |
| 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; | |
| } | |
| // ── Case 2: On the class / assignment-list page ───────────────────────── | |
| 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; | |
| } | |
| } | |
| // ── Case 3: Full home-page → class → assignment navigation ─────────────── | |
| await navigateToAssignment(page, classTitle ?? '', assignmentTitle); | |
| } | |
| async function getLegacySubmissionState(page: Page): Promise<{ | |
| hasExistingSubmission: boolean; | |
| hasResubmitAction: boolean; | |
| hasInitialUpload: boolean; | |
| }> { | |
| // Wait a moment for any dynamic content to render before checking state | |
| await page.waitForTimeout(1500); | |
| // Broad check for existing submission history table or similarity link | |
| 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)) || | |
| // Also check for resubmit action or submission rows that indicate a prior submission exists | |
| (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) { | |
| // Try opening dropdown menus that might contain the resubmit option | |
| 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) { | |
| // If no resubmit found but there's an Upload Submission button, the | |
| // account may not have a prior submission — fall back to initial upload. | |
| 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') { | |
| // After clicking resubmit, a confirmation dialog may appear | |
| 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) { | |
| // Some Turnitin builds skip the confirm step — check if file input | |
| // is already present without confirmation. | |
| 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.'); | |
| } | |
| } | |
| // Accept any EULA modals that might pop up after clicking submit/resubmit | |
| await page.waitForTimeout(2000); | |
| await acceptEulaEverywhere(page); | |
| // Resolve the frame scope that contains the file input (could be page or iframe) | |
| 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.'); | |
| } | |
| // Wait for the review/confirmation panel to appear. | |
| // Some Turnitin legacy builds skip the review panel and go straight to the | |
| // dashboard when the file is already processed. We accept both paths. | |
| 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(); | |
| // Wait up to 150 s for the submit button to appear, indicating file upload is complete and ready for confirmation | |
| 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.'); | |
| } | |
| // Wait for the Close / Go to Assignment Dashboard button and click it | |
| 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...'); | |
| } | |
| // Wait for submission to appear on dashboard (up to 3 minutes) | |
| 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; // Reload every 30 seconds | |
| let lastReloadAt = startedAt; | |
| while (Date.now() - startedAt < timeoutMs) { | |
| // Check both specific similarity score cells and any visible percentage | |
| 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; | |
| // Also check if similarity column shows a number without % | |
| 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; | |
| // Reload the page periodically to fetch the updated status | |
| 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; | |
| } | |
| /** | |
| * Viewer URL patterns accepted for legacy carta reports. | |
| * Turnitin serves the viewer from multiple subdomains depending on the region | |
| * and account type — accept any of them. | |
| */ | |
| 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(() => {}); | |
| // The legacy carta viewer (ev.turnitin.com/app/carta) is a heavy JavaScript | |
| // application that may take 10+ seconds to render sidebar controls. | |
| // Wait for the URL to settle and the viewer content to appear. | |
| await viewerPage.waitForTimeout(5000); | |
| if (!isViewerUrl(viewerPage.url())) { | |
| // Poll for up to 30 s for the viewer URL to settle | |
| 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()}`); | |
| } | |
| // Large documents may finish the dashboard score before the Carta sidebar | |
| // has fully hydrated, so the caller can grant a longer readiness window. | |
| 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> { | |
| // In the legacy carta viewer (ev.turnitin.com/app/carta) the filter controls | |
| // are rendered directly in the sidebar — no button click is needed. | |
| // Wait generously for the JavaScript to finish rendering. | |
| const alreadyVisible = await legacyFilterPanelVisible(page, 15000); | |
| if (alreadyVisible) return true; | |
| // Some carta builds may require clicking a sidebar toggle; try common selectors. | |
| 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) { | |
| // Reload and give the carta viewer extra time to re-render its sidebar. | |
| 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 { | |
| // Ignore invalid selector fallbacks. | |
| } | |
| } | |
| 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 { /* ignore */ } | |
| 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, | |
| ); | |
| // Persist 'navigate' step immediately so that on failure the retry | |
| // engine knows we reached the assignment page and can resume correctly. | |
| 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, | |
| }); | |
| // The Carta download modal closes after Current View is selected. Open the | |
| // Download panel again and fetch Digital Receipt as a separate PDF. | |
| 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(() => {}); | |
| } | |
| } | |