Spaces:
Sleeping
Sleeping
| import { type Page, type BrowserContext, type Frame } from 'playwright'; | |
| import * as path from 'path'; | |
| import * as fs from 'fs'; | |
| import { config } from '../config'; | |
| import { logger } from '../utils/logger'; | |
| import { getBrowser } from '../worker/browser-pool'; | |
| import { loginToTurnitin } from './steps/login'; | |
| import { navigateToAssignment } from './steps/navigate'; | |
| import { dropClassByTitle } from './steps/class-management'; | |
| import { uploadFile, SubmissionQuotaLimitError } from './steps/upload'; | |
| import { resubmitFile } from './steps/resubmit'; | |
| import { | |
| detectQuotaLimit, | |
| runQuotaCheck, | |
| } from './steps/quota-detect'; | |
| import { waitForSimilarity, type SimilarityResult } from './steps/similarity'; | |
| import { openReportViewerPage } from './steps/viewer'; | |
| import { readViewerSimilarityPercent } from './steps/viewer-similarity'; | |
| import { readSubmissionDetails, type SubmissionDetails } from './steps/submission-details'; | |
| import { detectSubmissionState } from './steps/submission-state'; | |
| import { applyFilters, validateFilters, hasActiveFilters, type FilterOptions } from './steps/filters'; | |
| import { downloadPdf } from './steps/download'; | |
| import { runLegacyTurnitinJob } from './legacy'; | |
| // --------------------------------------------------------------------------- | |
| // Types | |
| // --------------------------------------------------------------------------- | |
| export type { FilterOptions } from './steps/filters'; | |
| export type TurnitinUiVariant = 'modern_lti' | 'legacy_carta'; | |
| export const MODERN_ONE_POOL_KEY = 'modern_one'; | |
| export interface RunTurnitinJobInput { | |
| account: { | |
| id: string; | |
| email: string; | |
| password: string; | |
| quotaLimit?: number | null; | |
| quotaRemaining?: number | null; | |
| }; | |
| assignmentTarget: { | |
| targetUrl: string; | |
| classTitle: string; | |
| assignmentTitle?: string | null; | |
| assignmentLaunchUrl?: string | null; | |
| uiVariant?: TurnitinUiVariant; | |
| accountPoolKey?: string | null; | |
| }; | |
| inputFilePath: string; | |
| inputFileName?: string; | |
| inputFileSize?: number; | |
| outputDir: string; | |
| mode: 'upload' | 'resubmit' | 'quota_check'; | |
| filters: FilterOptions; | |
| storageStatePath?: string; | |
| /** | |
| * When set, the engine will skip steps that have already been completed and | |
| * resume from the step after this one. For example, if `resumeAfterStep` is | |
| * `'submitted'`, the engine will skip upload/resubmit and jump straight to | |
| * waiting for the similarity score. | |
| */ | |
| resumeAfterStep?: string; | |
| /** | |
| * Existing report viewer URL from a previous attempt. When the previous | |
| * attempt already reached the viewer, retries reopen this URL directly | |
| * instead of uploading/resubmitting the same file again. | |
| */ | |
| resumeViewerUrl?: string | null; | |
| attemptCount?: number; | |
| onEvent?: (event: { | |
| level: 'info' | 'warning' | 'error'; | |
| step: string; | |
| message: string; | |
| metadata?: Record<string, unknown>; | |
| }) => Promise<void>; | |
| } | |
| export interface RunTurnitinJobResult { | |
| viewerUrl?: string; | |
| similarityPercent?: number; | |
| outputPdfPath?: string; | |
| receiptPdfPath?: string; | |
| submissionDetails?: SubmissionDetails; | |
| quotaWarning?: string; | |
| quotaLimit?: { limit: number; message: string; retryText?: string }; | |
| quotaCooldown?: { message: string; nextRetryAt: string; submissionCount?: number }; | |
| permanentLimit?: { | |
| message: string; | |
| submissionCount?: number; | |
| dropClassResult?: { | |
| attempted: boolean; | |
| dropped: boolean; | |
| reason?: string; | |
| }; | |
| }; | |
| submittedAt?: string; | |
| submissionCount?: number; | |
| /** The last step that completed successfully – persisted so retries can skip. */ | |
| lastCompletedStep?: string; | |
| } | |
| // --------------------------------------------------------------------------- | |
| // EULA-blocked error (for retry logic) | |
| // --------------------------------------------------------------------------- | |
| class EulaBlockedError extends Error { | |
| constructor(message = 'Assignment launch is blocked by EULA') { | |
| super(message); | |
| this.name = 'EulaBlockedError'; | |
| } | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Event emitter helper | |
| // --------------------------------------------------------------------------- | |
| 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 compactEngineErrorMessage(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 page element did not appear in time: ${selector}` | |
| : 'Turnitin page element did not appear in time.'; | |
| } | |
| if (/Call log:/i.test(message)) return firstLine; | |
| return firstLine.length > 260 ? `${firstLine.slice(0, 257)}...` : firstLine; | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Step ordering — used to determine whether to skip already-completed steps | |
| // --------------------------------------------------------------------------- | |
| const STEP_ORDER = [ | |
| 'login', | |
| 'navigate', | |
| 'quota_check', | |
| 'upload', // covers both upload and resubmit | |
| 'submitted', | |
| 'similarity', | |
| 'viewer', | |
| 'filters', | |
| 'download', | |
| ] as const; | |
| /** | |
| * Returns true when `completedStep` is at or after `targetStep` in the | |
| * pipeline, meaning `targetStep` can safely be skipped. | |
| */ | |
| function isStepCompleted(completedStep: string | undefined, targetStep: string): boolean { | |
| if (!completedStep) return false; | |
| const completedIdx = STEP_ORDER.indexOf(completedStep as any); | |
| const targetIdx = STEP_ORDER.indexOf(targetStep as any); | |
| if (completedIdx < 0 || targetIdx < 0) return false; | |
| return completedIdx >= targetIdx; | |
| } | |
| function decodeFileNameForCompare(fileName: string): string { | |
| try { | |
| return decodeURIComponent(fileName); | |
| } catch { | |
| return fileName; | |
| } | |
| } | |
| function normalizeFileNameForCompare(fileName: string | undefined): string { | |
| return decodeFileNameForCompare(path.basename(String(fileName || ''))) | |
| .normalize('NFKC') | |
| .replace(/[\u200B-\u200D\uFEFF]/g, '') | |
| .replace(/\s+/g, ' ') | |
| .trim() | |
| .toLowerCase(); | |
| } | |
| function compactFileNameForCompare(fileName: string | undefined): string { | |
| return normalizeFileNameForCompare(fileName).replace(/\s+/g, ''); | |
| } | |
| function submissionDetailsMatchInput( | |
| details: SubmissionDetails | null, | |
| inputFileName: string, | |
| inputFileSize?: number, | |
| ): boolean { | |
| if (!details?.fileName || typeof inputFileSize !== 'number') return false; | |
| const detailName = normalizeFileNameForCompare(details.fileName); | |
| const expectedName = normalizeFileNameForCompare(inputFileName); | |
| const detailCompactName = compactFileNameForCompare(details.fileName); | |
| const expectedCompactName = compactFileNameForCompare(inputFileName); | |
| return Boolean( | |
| details.fileSize === inputFileSize && | |
| detailName && | |
| expectedName && | |
| (detailName === expectedName || detailCompactName === expectedCompactName), | |
| ); | |
| } | |
| function getScopeOwnerPage(scope: Page | Frame): Page { | |
| return typeof (scope as any).page === 'function' | |
| ? (scope as Frame).page() | |
| : (scope as Page); | |
| } | |
| const MODERN_VIEWER_READY_SELECTOR = [ | |
| 'tii-sws-tab-button#tab-similarity', | |
| '#tab-similarity', | |
| '.tii-SimilarityReportPanel[aria-hidden="false"]', | |
| '.tii-OverviewScore__Value', | |
| 'tdl-button[with-data-px="SettingsClicked"]', | |
| '.tii-SimilarityReportPanelHeader__SettingsButton', | |
| 'tii-sws-download-btn-mfe', | |
| 'tii-sws-submission-details-btn', | |
| ].join(', '); | |
| async function waitForModernViewerReady( | |
| viewerPage: Page, | |
| viewerUrl: string, | |
| timeoutMs = 75_000, | |
| ): Promise<boolean> { | |
| const deadline = Date.now() + timeoutMs; | |
| let reloads = 0; | |
| while (Date.now() < deadline) { | |
| if ( | |
| await viewerPage | |
| .locator(MODERN_VIEWER_READY_SELECTOR) | |
| .first() | |
| .isVisible({ timeout: 2500 }) | |
| .catch(() => false) | |
| ) { | |
| return true; | |
| } | |
| const bodyText = await viewerPage | |
| .locator('body') | |
| .innerText({ timeout: 1500 }) | |
| .catch(() => ''); | |
| if (bodyText.trim().length > 20) { | |
| await viewerPage.waitForTimeout(2500).catch(() => {}); | |
| continue; | |
| } | |
| if (reloads < 2) { | |
| reloads++; | |
| logger.warn('Report viewer body was empty; reloading direct viewer URL', { | |
| reloads, | |
| viewerUrl, | |
| }); | |
| await viewerPage | |
| .goto(viewerUrl, { waitUntil: 'domcontentloaded', timeout: 60000 }) | |
| .catch(() => {}); | |
| await viewerPage | |
| .waitForLoadState('networkidle', { timeout: 20000 }) | |
| .catch(() => {}); | |
| await viewerPage.waitForTimeout(5000).catch(() => {}); | |
| continue; | |
| } | |
| await viewerPage.waitForTimeout(2500).catch(() => {}); | |
| } | |
| return false; | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Main orchestrator | |
| // --------------------------------------------------------------------------- | |
| /** | |
| * Run a complete Turnitin automation job. | |
| * | |
| * Flow: | |
| * 1. Launch browser context (headless per config) | |
| * 2. Restore storageState if available | |
| * 3. Login | |
| * 4. Navigate to assignment | |
| * 5. If mode=quota_check: run quota check and return | |
| * 6. Detect quota limit before upload | |
| * 7. If already submitted (resume or live detection): skip to step 8 | |
| * Otherwise, if mode=resubmit: call resubmit, else call upload | |
| * 8. Wait for similarity | |
| * 9. Open viewer | |
| * 10. Apply filters | |
| * 11. Download PDF | |
| * 12. Save storageState | |
| * 13. Close context | |
| * 14. Return result | |
| */ | |
| export async function runTurnitinJob( | |
| input: RunTurnitinJobInput, | |
| ): Promise<RunTurnitinJobResult> { | |
| if (input.assignmentTarget.uiVariant === 'legacy_carta') { | |
| return runLegacyTurnitinJob(input); | |
| } | |
| const { | |
| account, | |
| assignmentTarget, | |
| inputFilePath, | |
| inputFileName, | |
| inputFileSize, | |
| outputDir, | |
| mode: requestedMode, | |
| filters, | |
| storageStatePath, | |
| resumeAfterStep, | |
| resumeViewerUrl, | |
| attemptCount, | |
| onEvent, | |
| } = input; | |
| const result: RunTurnitinJobResult = {}; | |
| let similarityResult: SimilarityResult | null = null; | |
| let quotaClassDropAttempted = false; | |
| let mode = requestedMode; | |
| let viewerPage: Page | Frame | null = null; | |
| // Track whether we should skip the upload step entirely. | |
| const skipUpload = isStepCompleted(resumeAfterStep, 'submitted'); | |
| const resumeFromViewer = isStepCompleted(resumeAfterStep, 'viewer') && Boolean(resumeViewerUrl); | |
| const expectedInputFileName = inputFileName || path.basename(inputFilePath); | |
| const isModernOnePool = assignmentTarget.accountPoolKey === MODERN_ONE_POOL_KEY; | |
| if (skipUpload) { | |
| logger.info('Resuming after previous upload — skipping upload/resubmit step', { | |
| resumeAfterStep, | |
| }); | |
| } | |
| // --- 1. Launch browser context --- | |
| // NEW-BUG-1 FIX: Use the shared browser from browser-pool.ts instead of | |
| // launching a new Chromium process for every job. This reduces memory from | |
| // N × ~200MB to ~200MB + N × ~30MB (one context per job). | |
| await emit(onEvent, 'info', 'browser', 'Creating browser context'); | |
| const browser = await getBrowser(); | |
| // --- 2. Create context (restore storageState if available) --- | |
| 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; | |
| logger.info('Restoring storage state', { path: storageStatePath }); | |
| } | |
| const context = await browser.newContext(contextOptions as any); | |
| let page: Page | null = null; | |
| const dropClassAfterQuotaLimit = async ( | |
| quotaMessage: string, | |
| ): Promise<void> => { | |
| if (quotaClassDropAttempted || !page) return; | |
| quotaClassDropAttempted = true; | |
| await emit( | |
| onEvent, | |
| 'warning', | |
| 'class_cleanup', | |
| 'Quota limit detected; dropping class from account', | |
| { | |
| classTitle: assignmentTarget.classTitle, | |
| quotaMessage, | |
| }, | |
| ); | |
| const dropResult = await dropClassByTitle( | |
| page, | |
| assignmentTarget.classTitle, | |
| ); | |
| await emit( | |
| onEvent, | |
| dropResult.dropped ? 'info' : 'warning', | |
| 'class_cleanup', | |
| dropResult.dropped | |
| ? 'Quota-limited class dropped or already absent' | |
| : 'Quota-limited class could not be dropped automatically', | |
| { ...dropResult }, | |
| ); | |
| }; | |
| try { | |
| page = await context.newPage(); | |
| // --- 3. Login --- | |
| 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'; | |
| let assignmentLaunchUrl = ''; // initialized to avoid undefined crash | |
| if (resumeFromViewer && resumeViewerUrl) { | |
| await emit( | |
| onEvent, | |
| 'info', | |
| 'viewer', | |
| 'Reopening report viewer from previous attempt', | |
| { viewerUrl: resumeViewerUrl, resumeAfterStep }, | |
| ); | |
| const resumedViewerPage = await context.newPage(); | |
| await resumedViewerPage.goto(resumeViewerUrl, { | |
| waitUntil: 'domcontentloaded', | |
| timeout: 60000, | |
| }); | |
| await resumedViewerPage | |
| .waitForLoadState('networkidle', { timeout: 30000 }) | |
| .catch(() => {}); | |
| const viewerReady = await waitForModernViewerReady( | |
| resumedViewerPage, | |
| resumeViewerUrl, | |
| ); | |
| if (viewerReady) { | |
| viewerPage = resumedViewerPage; | |
| result.viewerUrl = resumedViewerPage.url(); | |
| result.lastCompletedStep = 'viewer'; | |
| await emit( | |
| onEvent, | |
| 'info', | |
| 'viewer', | |
| 'Report viewer opened', | |
| { viewerUrl: result.viewerUrl, resumed: true }, | |
| ); | |
| } else { | |
| await emit( | |
| onEvent, | |
| 'warning', | |
| 'viewer', | |
| 'Direct report viewer did not hydrate; reopening through assignment page', | |
| { viewerUrl: resumeViewerUrl, resumed: true }, | |
| ); | |
| await resumedViewerPage.close().catch(() => {}); | |
| } | |
| } | |
| if (!viewerPage) { | |
| // --- 4. Navigate to assignment --- | |
| await emit( | |
| onEvent, | |
| 'info', | |
| 'navigate', | |
| 'Navigating to assignment', | |
| { | |
| classTitle: assignmentTarget.classTitle, | |
| assignmentTitle: assignmentTarget.assignmentTitle, | |
| }, | |
| ); | |
| // Retry loop for EULA-blocked assignment access (up to 3 attempts) | |
| for (let attempt = 1; attempt <= 3; attempt++) { | |
| try { | |
| assignmentLaunchUrl = await navigateToAssignment( | |
| page, | |
| assignmentTarget.classTitle, | |
| assignmentTarget.assignmentTitle, | |
| ); | |
| result.lastCompletedStep = 'navigate'; | |
| // --- 5. Quota check mode --- | |
| if (mode === 'quota_check') { | |
| await emit( | |
| onEvent, | |
| 'info', | |
| 'quota_check', | |
| 'Running quota check', | |
| ); | |
| const quotaResult = await runQuotaCheck(page); | |
| if (quotaResult.quotaLimited) { | |
| result.quotaLimit = { | |
| limit: quotaResult.limit!, | |
| message: quotaResult.message!, | |
| retryText: quotaResult.retryText || undefined, | |
| }; | |
| await dropClassAfterQuotaLimit(result.quotaLimit.message); | |
| } | |
| if (quotaResult.warning) { | |
| result.quotaWarning = quotaResult.warning; | |
| } | |
| await emit( | |
| onEvent, | |
| 'info', | |
| 'quota_check', | |
| 'Quota check complete', | |
| { quotaResult }, | |
| ); | |
| return result; | |
| } | |
| // --- 6. Detect quota limit before upload --- | |
| // A quota-limit banner can still be visible after the third successful | |
| // submission. If this job is resuming after upload/submission, do not | |
| // abort here: no additional submission is needed, and the worker only | |
| // has to open/download the existing report. | |
| const preUploadQuota = skipUpload | |
| ? null | |
| : await detectQuotaLimit(page); | |
| if (preUploadQuota) { | |
| result.quotaLimit = { | |
| limit: preUploadQuota.limit, | |
| message: preUploadQuota.message, | |
| retryText: preUploadQuota.retryText, | |
| }; | |
| await dropClassAfterQuotaLimit(preUploadQuota.message); | |
| throw new SubmissionQuotaLimitError(preUploadQuota.message); | |
| } | |
| // --- 7. Upload / Resubmit / Skip --- | |
| // Detect live page state to decide what action is needed. | |
| const submissionState = await detectSubmissionState(page); | |
| if (!skipUpload && isModernOnePool && submissionState.hasExistingSubmission) { | |
| const message = | |
| 'Modern one-use account already has a submission for this assignment. Marking account as permanently limited and trying another account.'; | |
| result.quotaLimit = { | |
| limit: 1, | |
| message, | |
| }; | |
| await dropClassAfterQuotaLimit(message); | |
| throw new SubmissionQuotaLimitError(message); | |
| } | |
| if ( | |
| !skipUpload && | |
| !viewerPage && | |
| (attemptCount || 0) > 1 && | |
| submissionState.hasExistingSubmission && | |
| typeof inputFileSize === 'number' | |
| ) { | |
| await emit( | |
| onEvent, | |
| 'info', | |
| 'recovery_check', | |
| 'Existing submission found on retry; verifying file details before deciding whether to skip upload', | |
| { | |
| inputFileName: expectedInputFileName, | |
| inputFileSize, | |
| attemptCount, | |
| }, | |
| ); | |
| const candidateViewer = await openReportViewerPage( | |
| page, | |
| context, | |
| submissionState.scope || undefined, | |
| ).catch(async (error: unknown) => { | |
| await emit( | |
| onEvent, | |
| 'warning', | |
| 'recovery_check', | |
| 'Existing submission could not be opened for recovery verification', | |
| { | |
| error: error instanceof Error ? error.message : String(error), | |
| }, | |
| ); | |
| return null; | |
| }); | |
| if (candidateViewer) { | |
| const recoveredDetails = await readSubmissionDetails(candidateViewer); | |
| if (recoveredDetails) { | |
| result.submissionDetails = recoveredDetails; | |
| await emit( | |
| onEvent, | |
| 'info', | |
| 'submission_details', | |
| 'Submission details captured', | |
| recoveredDetails as Record<string, unknown>, | |
| ); | |
| } | |
| if ( | |
| submissionDetailsMatchInput( | |
| recoveredDetails, | |
| expectedInputFileName, | |
| inputFileSize, | |
| ) | |
| ) { | |
| viewerPage = candidateViewer; | |
| result.viewerUrl = candidateViewer.url(); | |
| result.lastCompletedStep = 'viewer'; | |
| await emit( | |
| onEvent, | |
| 'info', | |
| 'viewer', | |
| 'Existing submission matches current file; resuming from report viewer', | |
| { | |
| viewerUrl: result.viewerUrl, | |
| recovered: true, | |
| matchBasis: 'fileName+fileSize', | |
| }, | |
| ); | |
| break; | |
| } | |
| await emit( | |
| onEvent, | |
| 'warning', | |
| 'recovery_check', | |
| 'Existing submission does not match current input file; continuing with requested upload flow', | |
| { | |
| inputFileName: expectedInputFileName, | |
| inputFileSize, | |
| turnitinFileName: recoveredDetails?.fileName || null, | |
| turnitinFileSize: recoveredDetails?.fileSize || null, | |
| }, | |
| ); | |
| const candidateOwner = getScopeOwnerPage(candidateViewer); | |
| if (candidateOwner !== page) { | |
| await candidateOwner.close().catch(() => {}); | |
| } else { | |
| await page.goto(assignmentLaunchUrl, { | |
| waitUntil: 'domcontentloaded', | |
| timeout: 60000, | |
| }).catch(() => {}); | |
| await page.waitForTimeout(1500).catch(() => {}); | |
| } | |
| } | |
| } | |
| if (viewerPage) { | |
| break; | |
| } | |
| // ── A: File was already submitted (resume scenario) ── | |
| // If the caller told us to skip upload, ALWAYS skip — do not | |
| // re-check the live page because the submission card may not be | |
| // visible yet (Turnitin is still processing it). | |
| if (skipUpload) { | |
| await emit( | |
| onEvent, | |
| 'info', | |
| 'resume', | |
| 'Resuming after previous upload — skipping upload step', | |
| { resumeAfterStep, hasExistingSubmission: submissionState.hasExistingSubmission }, | |
| ); | |
| result.lastCompletedStep = 'submitted'; | |
| result.submittedAt = new Date().toISOString(); // approximate | |
| } else { | |
| // ── B: Normal upload / resubmit ── | |
| if (mode === 'upload' && submissionState.hasExistingSubmission) { | |
| mode = 'resubmit'; | |
| await emit( | |
| onEvent, | |
| 'warning', | |
| 'mode_switch', | |
| 'Existing submission detected; switching to resubmit mode', | |
| { | |
| requestedMode, | |
| effectiveMode: mode, | |
| hasResubmitAction: submissionState.hasResubmitAction, | |
| }, | |
| ); | |
| } else if ( | |
| mode === 'resubmit' && | |
| !submissionState.hasExistingSubmission && | |
| submissionState.hasUploadForm | |
| ) { | |
| mode = 'upload'; | |
| await emit( | |
| onEvent, | |
| 'warning', | |
| 'mode_switch', | |
| 'No existing submission detected; switching to first submission mode', | |
| { | |
| requestedMode, | |
| effectiveMode: mode, | |
| }, | |
| ); | |
| } | |
| if (mode === 'resubmit') { | |
| await emit( | |
| onEvent, | |
| 'info', | |
| 'resubmit', | |
| 'Resubmitting file', | |
| { filePath: inputFilePath }, | |
| ); | |
| await resubmitFile(page, inputFilePath); | |
| } else { | |
| await emit(onEvent, 'info', 'upload', 'Uploading file', { | |
| filePath: inputFilePath, | |
| }); | |
| await uploadFile(page, inputFilePath); | |
| } | |
| result.submittedAt = new Date().toISOString(); | |
| result.lastCompletedStep = 'submitted'; | |
| await emit( | |
| onEvent, | |
| 'info', | |
| 'submitted', | |
| 'File submitted successfully', | |
| { submittedAt: result.submittedAt }, | |
| ); | |
| } | |
| // --- 8. Wait for similarity --- | |
| await emit( | |
| onEvent, | |
| 'info', | |
| 'similarity', | |
| 'Waiting for similarity score', | |
| ); | |
| similarityResult = await waitForSimilarity( | |
| page, | |
| assignmentLaunchUrl!, | |
| { | |
| timeoutMs: config.similarityTimeoutMs, | |
| pollMs: config.similarityPollMs, | |
| refreshAfterMs: config.similarityRefreshAfterMs, | |
| inputFileName: path.basename(inputFilePath), | |
| }, | |
| ); | |
| if (similarityResult.similarityPercent !== null) { | |
| result.similarityPercent = similarityResult.similarityPercent; | |
| } | |
| if (similarityResult.viewerUrl) { | |
| result.viewerUrl = similarityResult.viewerUrl; | |
| } | |
| result.lastCompletedStep = 'similarity'; | |
| await emit( | |
| onEvent, | |
| 'info', | |
| 'similarity', | |
| `Similarity: ${result.similarityPercent ?? 'pending'}%`, | |
| { similarityPercent: result.similarityPercent }, | |
| ); | |
| break; // Success – exit retry loop | |
| } catch (error) { | |
| if ( | |
| error instanceof EulaBlockedError && | |
| attempt < 3 | |
| ) { | |
| await emit( | |
| onEvent, | |
| 'warning', | |
| 'navigate', | |
| 'Assignment launch returned EULA block; retrying', | |
| { attempt }, | |
| ); | |
| // Navigate back to class page and retry | |
| await page.goto( | |
| `https://www.turnitin.com/s_home.asp?lang=en_us`, | |
| { waitUntil: 'domcontentloaded', timeout: 60000 }, | |
| ); | |
| continue; | |
| } | |
| throw error; | |
| } | |
| } | |
| } | |
| if (!viewerPage && !assignmentLaunchUrl) { | |
| throw new Error('Assignment launch URL was not captured — cannot proceed to similarity wait'); | |
| } | |
| // --- 9. Open viewer --- | |
| if (!viewerPage) { | |
| await emit( | |
| onEvent, | |
| 'info', | |
| 'viewer', | |
| 'Opening report viewer', | |
| ); | |
| if (!similarityResult) { | |
| throw new Error('Similarity/submission card result was not captured before opening viewer'); | |
| } | |
| viewerPage = await openReportViewerPage( | |
| page, | |
| context, | |
| similarityResult.scope, | |
| similarityResult.locator, | |
| ); | |
| result.viewerUrl = viewerPage.url(); | |
| result.lastCompletedStep = 'viewer'; | |
| await emit( | |
| onEvent, | |
| 'info', | |
| 'viewer', | |
| 'Report viewer opened', | |
| { viewerUrl: result.viewerUrl, resumed: false }, | |
| ); | |
| } | |
| if (!viewerPage) { | |
| throw new Error('Report viewer page was not available after open/resume'); | |
| } | |
| const submissionDetails = await readSubmissionDetails(viewerPage); | |
| if (submissionDetails) { | |
| result.submissionDetails = submissionDetails; | |
| await emit( | |
| onEvent, | |
| 'info', | |
| 'submission_details', | |
| 'Submission details captured', | |
| submissionDetails as Record<string, unknown>, | |
| ); | |
| } | |
| // --- 10. Apply filters --- | |
| await emit(onEvent, 'info', 'filters', 'Applying filters', { | |
| filters, | |
| }); | |
| try { | |
| await applyFilters(viewerPage, filters); | |
| } catch (filterError) { | |
| const filterMessage = | |
| filterError instanceof Error | |
| ? filterError.message | |
| : String(filterError); | |
| // When the user has enabled at least one filter, filter application | |
| // is MANDATORY — do NOT proceed to download with an unfiltered report. | |
| if (hasActiveFilters(filters)) { | |
| await emit( | |
| onEvent, | |
| 'error', | |
| 'filters', | |
| 'Filters are enabled but could not be applied; aborting download to prevent unfiltered report', | |
| { error: filterMessage, filters }, | |
| ); | |
| throw new Error( | |
| `Filter application failed with active filters: ${filterMessage}`, | |
| ); | |
| } | |
| // No filters are active — the panel might simply be unavailable; | |
| // it is safe to continue with the download. | |
| await emit( | |
| onEvent, | |
| 'warning', | |
| 'filters', | |
| 'Filter panel was not available (no filters were active); continuing with report download', | |
| { error: filterMessage }, | |
| ); | |
| } | |
| result.lastCompletedStep = 'filters'; | |
| // --- 10b. Validate that all active filters were applied before downloading --- | |
| if (hasActiveFilters(filters)) { | |
| await emit( | |
| onEvent, | |
| 'info', | |
| 'filters', | |
| 'Validating applied filters before download', | |
| { filters }, | |
| ); | |
| await validateFilters(viewerPage, filters, true /* retryApply */); | |
| await emit( | |
| onEvent, | |
| 'info', | |
| 'filters', | |
| 'Filter validation passed', | |
| { filters }, | |
| ); | |
| } | |
| // Read the similarity score from the viewer after filters are applied. | |
| // The score may have changed due to filter exclusions; give the UI a | |
| // moment to re-render before reading. | |
| await emit( | |
| onEvent, | |
| 'info', | |
| 'similarity', | |
| 'Reading post-filter similarity score from viewer', | |
| ); | |
| const viewerSimilarityPercent = | |
| await readViewerSimilarityPercent(viewerPage); | |
| if (viewerSimilarityPercent !== null) { | |
| result.similarityPercent = viewerSimilarityPercent; | |
| await emit( | |
| onEvent, | |
| 'info', | |
| 'similarity', | |
| `Viewer similarity (post-filter): ${viewerSimilarityPercent}%`, | |
| { similarityPercent: viewerSimilarityPercent, filtered: hasActiveFilters(filters) }, | |
| ); | |
| } else { | |
| await emit( | |
| onEvent, | |
| 'warning', | |
| 'similarity', | |
| 'Could not read similarity score from viewer; using pre-filter value if available', | |
| { preFilteSimilarity: result.similarityPercent ?? null }, | |
| ); | |
| } | |
| // --- 11. Download PDF --- | |
| await emit( | |
| onEvent, | |
| 'info', | |
| 'download', | |
| 'Downloading PDF report', | |
| ); | |
| fs.mkdirSync(outputDir, { recursive: true }); | |
| const outputPdfPath = path.join( | |
| outputDir, | |
| `turnitin_report_${Date.now()}.pdf`, | |
| ); | |
| const actualPath = await downloadPdf( | |
| viewerPage, | |
| context, | |
| outputPdfPath, | |
| ); | |
| result.outputPdfPath = actualPath; | |
| result.lastCompletedStep = 'download'; | |
| await emit( | |
| onEvent, | |
| 'info', | |
| 'download', | |
| 'PDF downloaded successfully', | |
| { outputPdfPath: actualPath }, | |
| ); | |
| if (isModernOnePool) { | |
| const message = | |
| 'Modern one-use account consumed its single allowed submission. Class will be dropped and account will be permanently limited.'; | |
| let dropClassResult: NonNullable<RunTurnitinJobResult['permanentLimit']>['dropClassResult'] | undefined; | |
| if (page) { | |
| await emit(onEvent, 'warning', 'class_cleanup', 'Modern one-use submission completed; dropping class from account', { | |
| classTitle: assignmentTarget.classTitle, | |
| }); | |
| 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 | |
| ? 'Modern one-use class dropped or already absent' | |
| : 'Modern one-use class could not be dropped automatically', | |
| { ...dropClassResult }, | |
| ); | |
| } | |
| result.permanentLimit = { | |
| message, | |
| submissionCount: 1, | |
| dropClassResult, | |
| }; | |
| } | |
| // --- 12. Save storageState --- | |
| if (storageStatePath) { | |
| await context | |
| .storageState({ path: storageStatePath }) | |
| .catch(() => {}); | |
| logger.info('Storage state saved after job', { | |
| path: storageStatePath, | |
| }); | |
| } | |
| } catch (error) { | |
| const message = | |
| error instanceof Error ? error.message : String(error); | |
| const publicMessage = compactEngineErrorMessage(message); | |
| const isQuotaLimit = | |
| error instanceof Error && | |
| (error.name === 'SubmissionQuotaLimitError' || | |
| /reached your limit|submission quota limit/i.test(message)); | |
| if (isQuotaLimit && page) { | |
| await dropClassAfterQuotaLimit(message).catch((dropError: unknown) => { | |
| logger.warn('Failed to drop class after quota limit', { | |
| classTitle: assignmentTarget.classTitle, | |
| error: | |
| dropError instanceof Error | |
| ? dropError.message | |
| : String(dropError), | |
| }); | |
| }); | |
| } | |
| await emit(onEvent, 'error', 'error', publicMessage, { | |
| 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 { | |
| // --- 13. Close context (NOT the browser — it's shared across jobs) --- | |
| await context.close().catch(() => {}); | |
| } | |
| // --- 14. Return result --- | |
| return result; | |
| } | |