relv-space5 / src /engine /turnitin.ts
relv-dev's picture
Deploy latest verified Turnitin worker
2db5489 verified
Raw
History Blame Contribute Delete
32.9 kB
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;
}