| 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'; |
|
|
| |
| |
| |
|
|
| 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; |
| |
| |
| |
| |
| |
| |
| resumeAfterStep?: string; |
| |
| |
| |
| |
| |
| 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; |
| |
| lastCompletedStep?: string; |
| } |
|
|
| |
| |
| |
|
|
| class EulaBlockedError extends Error { |
| constructor(message = 'Assignment launch is blocked by EULA') { |
| super(message); |
| this.name = 'EulaBlockedError'; |
| } |
| } |
|
|
| |
| |
| |
|
|
| 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; |
| } |
|
|
| |
| |
| |
|
|
| const STEP_ORDER = [ |
| 'login', |
| 'navigate', |
| 'quota_check', |
| 'upload', |
| 'submitted', |
| 'similarity', |
| 'viewer', |
| 'filters', |
| 'download', |
| ] as const; |
|
|
| |
| |
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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; |
|
|
| |
| 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, |
| }); |
| } |
|
|
| |
| |
| |
| |
| await emit(onEvent, 'info', 'browser', 'Creating browser context'); |
|
|
| 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; |
| 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(); |
|
|
| |
| 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 = ''; |
|
|
| 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) { |
| |
| await emit( |
| onEvent, |
| 'info', |
| 'navigate', |
| 'Navigating to assignment', |
| { |
| classTitle: assignmentTarget.classTitle, |
| assignmentTitle: assignmentTarget.assignmentTitle, |
| }, |
| ); |
|
|
| |
| for (let attempt = 1; attempt <= 3; attempt++) { |
| try { |
| assignmentLaunchUrl = await navigateToAssignment( |
| page, |
| assignmentTarget.classTitle, |
| assignmentTarget.assignmentTitle, |
| ); |
| result.lastCompletedStep = 'navigate'; |
|
|
| |
| 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; |
| } |
|
|
| |
| |
| |
| |
| |
| 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); |
| } |
|
|
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
| |
| 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(); |
| } else { |
| |
| 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 }, |
| ); |
| } |
|
|
| |
| 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; |
| } catch (error) { |
| if ( |
| error instanceof EulaBlockedError && |
| attempt < 3 |
| ) { |
| await emit( |
| onEvent, |
| 'warning', |
| 'navigate', |
| 'Assignment launch returned EULA block; retrying', |
| { attempt }, |
| ); |
| |
| 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'); |
| } |
|
|
| |
| 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>, |
| ); |
| } |
|
|
| |
| await emit(onEvent, 'info', 'filters', 'Applying filters', { |
| filters, |
| }); |
| try { |
| await applyFilters(viewerPage, filters); |
| } catch (filterError) { |
| const filterMessage = |
| filterError instanceof Error |
| ? filterError.message |
| : String(filterError); |
|
|
| |
| |
| 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}`, |
| ); |
| } |
|
|
| |
| |
| await emit( |
| onEvent, |
| 'warning', |
| 'filters', |
| 'Filter panel was not available (no filters were active); continuing with report download', |
| { error: filterMessage }, |
| ); |
| } |
| result.lastCompletedStep = 'filters'; |
|
|
| |
| if (hasActiveFilters(filters)) { |
| await emit( |
| onEvent, |
| 'info', |
| 'filters', |
| 'Validating applied filters before download', |
| { filters }, |
| ); |
| await validateFilters(viewerPage, filters, true ); |
| await emit( |
| onEvent, |
| 'info', |
| 'filters', |
| 'Filter validation passed', |
| { filters }, |
| ); |
| } |
|
|
| |
| |
| |
| 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 }, |
| ); |
| } |
|
|
| |
| 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, |
| }; |
| } |
|
|
| |
| 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 { |
| |
| await context.close().catch(() => {}); |
| } |
|
|
| |
| return result; |
| } |
|
|