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'; type Scope = Page | Frame; type EngineEvent = NonNullable; const LEGACY_ACCOUNT_QUOTA_LIMIT = 4; async function emit( onEvent: RunTurnitinJobInput['onEvent'], level: 'info' | 'warning' | 'error', step: string, message: string, metadata?: Record, ): Promise { 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 match = String(value || '').match(/(\d{1,3})\s*%?/); if (!match) return null; const parsed = Number(match[1]); return Number.isFinite(parsed) ? 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); } function normalizeLabel(label: string): keyof SubmissionDetails | 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 { return new Promise((resolve, reject) => { const timer = setTimeout( () => cleanup(new Error(`Timed out waiting ${timeoutMs}ms for download`)), timeoutMs, ); const pageListeners = new Map 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 { 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 { 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 { 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 { 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 { // 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 { 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 { 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): Promise { const startedAt = Date.now(); const timeoutMs = Math.max(config.similarityTimeoutMs || 180000, 120000); 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): Promise { 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()}`); } // Wait for the carta viewer sidebar to render filter controls (up to 20 s). await visible( viewerPage, '.apply-changes-button, .osi-score, .sidebar-paper-info-button, .exclude-quotes-checkbox', 20000, ).catch(() => {}); return viewerPage; } async function readLegacySubmissionDetails(page: Page): Promise { 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 { // 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 { 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 { 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('[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 { 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 { 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( '[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 { 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( '.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( '.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 { 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( `.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('[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 { 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(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('[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 { 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 { 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('.apply-changes-button') || Array.from(document.querySelectorAll('[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('.apply-changes-button') || Array.from(document.querySelectorAll('[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 { 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 { 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(selector))); } catch { // Ignore invalid selector fallbacks. } } if (textNeedles.length > 0) { const controls = Array.from( document.querySelectorAll( '[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 { 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 { const { account, assignmentTarget, inputFilePath, inputFileName, outputDir, mode: requestedMode, filters, storageStatePath, resumeAfterStep, resumeViewerUrl, onEvent, } = input; const result: RunTurnitinJobResult = {}; let page: Page | null = null; const browser = await getBrowser(); const contextOptions: Record = { 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 = reviewDetails; await emit(onEvent, 'info', 'submission_details', 'Legacy review details captured', reviewDetails as Record); } 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); if (tableSimilarity !== null) result.similarityPercent = tableSimilarity; result.lastCompletedStep = 'similarity'; await emit(onEvent, 'info', 'similarity', `Similarity: ${result.similarityPercent ?? 'pending'}%`, { similarityPercent: result.similarityPercent, }); await emit(onEvent, 'info', 'viewer', 'Opening legacy report viewer'); page = await openLegacyViewer(page, context); 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); } 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(() => {}); } }