relvcorp2 / src /engine /legacy.ts
RelVDev2's picture
Add R2 dual-storage adapter and resilient legacy large-document wait
ade3ddb
Raw
History Blame Contribute Delete
76.5 kB
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(() => {});
}
}