Spaces:
Sleeping
Sleeping
| import type { Page, Frame, Locator } from 'playwright'; | |
| import { logger } from '../../utils/logger'; | |
| import { | |
| readScopeDeepText, | |
| resolveAssignmentScope, | |
| SELECTORS, | |
| } from '../selectors'; | |
| import { acceptEulaEverywhere } from './login'; | |
| // --------------------------------------------------------------------------- | |
| // Types | |
| // --------------------------------------------------------------------------- | |
| export interface SimilarityResult { | |
| similarityPercent: number | null; | |
| viewerUrl: string | null; | |
| scope?: Page | Frame; | |
| locator?: Locator; | |
| } | |
| export interface WaitForSimilarityOptions { | |
| timeoutMs: number; | |
| pollMs: number; | |
| refreshAfterMs: number; | |
| inputFileName?: string; | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Public API | |
| // --------------------------------------------------------------------------- | |
| function getSubmissionButtonLocator(scope: Page | Frame, inputTitle: string): Locator { | |
| const viewButtons = scope.locator(SELECTORS.similarity.viewSubmissionButton); | |
| if (inputTitle) { | |
| return viewButtons.filter({ hasText: inputTitle }).first(); | |
| } | |
| return viewButtons.first(); | |
| } | |
| /** | |
| * Poll for the similarity score to appear on the submission card. | |
| * | |
| * After `refreshAfterMs` with no result, refreshes the assignment launch URL | |
| * once. Returns the similarity percent (if readable) and viewer URL. | |
| */ | |
| export async function waitForSimilarity( | |
| page: Page, | |
| assignmentLaunchUrl: string, | |
| options: WaitForSimilarityOptions, | |
| ): Promise<SimilarityResult> { | |
| const { timeoutMs, pollMs, refreshAfterMs } = options; | |
| const inputTitle = options.inputFileName | |
| ? options.inputFileName.replace(/\.[^.]+$/, '').trim() | |
| : ''; | |
| const started = Date.now(); | |
| let lastText = ''; | |
| // BUG-11 FIX: Allow multiple refreshes (every refreshAfterMs from first refresh) | |
| let refreshCount = 0; | |
| const MAX_REFRESHES = 2; | |
| let lastRefreshAt = 0; | |
| while (Date.now() - started < timeoutMs) { | |
| // ---- Deep text scan across all scopes ---- | |
| const scopes: (Page | Frame)[] = [ | |
| page, | |
| ...page.frames().filter((f) => !f.url().includes('cookie-shim')), | |
| ]; | |
| for (const candidateScope of scopes) { | |
| const deepText = await readScopeDeepText(candidateScope); | |
| const percentMatch = deepText.match( | |
| /\b(?:Similarity:\s*)?(\d{1,3})%\b/i, | |
| ); | |
| // BUG-4 FIX: Validate bounds — regex now matches 0-999 so we clamp to 0-100 | |
| const percentRaw = percentMatch ? parseInt(percentMatch[1], 10) : NaN; | |
| const percent = Number.isFinite(percentRaw) && percentRaw >= 0 && percentRaw <= 100 | |
| ? percentRaw : NaN; | |
| const percentOk = !isNaN(percent); | |
| const titleVisible = | |
| Boolean(inputTitle) && | |
| deepText.toLowerCase().includes(inputTitle.toLowerCase()); | |
| if ( | |
| percentOk && | |
| (titleVisible || /Submitted|Similarity|Your work/i.test(deepText)) | |
| ) { | |
| const text = percentMatch![0].includes('Similarity') | |
| ? percentMatch![0] | |
| : `Similarity: ${percent}%`; | |
| logger.info('Similarity detected from deep text', { | |
| text, | |
| percent, | |
| }); | |
| return { | |
| similarityPercent: percent, | |
| viewerUrl: null, | |
| scope: candidateScope, | |
| locator: getSubmissionButtonLocator(candidateScope, inputTitle), | |
| }; | |
| } | |
| // After refresh, if submission row is visible even without percent, proceed | |
| if ( | |
| refreshCount > 0 && | |
| titleVisible && | |
| /Your work/i.test(deepText) | |
| ) { | |
| logger.info( | |
| 'Submission row visible after refresh; proceeding without similarity value', | |
| ); | |
| return { | |
| similarityPercent: null, | |
| viewerUrl: null, | |
| scope: candidateScope, | |
| locator: getSubmissionButtonLocator(candidateScope, inputTitle), | |
| }; | |
| } | |
| } | |
| // ---- Locator-based scan ---- | |
| const scope = await resolveAssignmentScope(page); | |
| const candidates = [ | |
| { | |
| kind: 'similarity', | |
| locator: scope | |
| .locator( | |
| 'span[part="tii-grn-badge-label"]:has-text("Similarity:")', | |
| ) | |
| .first(), | |
| }, | |
| { | |
| kind: 'similarity', | |
| locator: scope.locator(SELECTORS.similarity.similaritySpan).first(), | |
| }, | |
| { | |
| kind: 'similarity', | |
| locator: scope.locator(SELECTORS.similarity.similarityDisplay).first(), | |
| }, | |
| { | |
| kind: 'title', | |
| locator: scope.locator(SELECTORS.similarity.viewSubmissionButton).first(), | |
| }, | |
| ]; | |
| for (const candidateEntry of candidates) { | |
| const candidate = candidateEntry.locator; | |
| if ( | |
| await candidate.isVisible({ timeout: 1500 }).catch(() => false) | |
| ) { | |
| const text = ( | |
| await candidate.innerText().catch(() => '') | |
| ).trim(); | |
| const aria = ( | |
| (await candidate | |
| .getAttribute('aria-label') | |
| .catch(() => '')) || '' | |
| ).trim(); | |
| const visibleText = text || aria; | |
| if (visibleText && visibleText !== lastText) { | |
| lastText = visibleText; | |
| logger.info('Submission state', { visibleText }); | |
| } | |
| if ( | |
| candidateEntry.kind === 'title' && | |
| visibleText && | |
| refreshCount > 0 | |
| ) { | |
| return { | |
| similarityPercent: null, | |
| viewerUrl: null, | |
| scope, | |
| locator: candidate, | |
| }; | |
| } | |
| const simMatch = visibleText.match( | |
| /Similarity:\s*(\d+)%/i, | |
| ); | |
| if (simMatch) { | |
| const percent = parseInt(simMatch[1], 10); | |
| logger.info('Similarity detected from locator', { | |
| visibleText, | |
| percent, | |
| }); | |
| return { | |
| similarityPercent: percent, | |
| viewerUrl: null, | |
| scope, | |
| locator: getSubmissionButtonLocator(scope, inputTitle), | |
| }; | |
| } | |
| } | |
| } | |
| // ---- Log body text for diagnostics ---- | |
| const bodyText = await scope | |
| .locator('body') | |
| .innerText({ timeout: 3000 }) | |
| .catch(() => ''); | |
| const shortText = bodyText.replace(/\s+/g, ' ').trim().slice(0, 240); | |
| if (shortText && shortText !== lastText) { | |
| lastText = shortText; | |
| logger.debug('Waiting for submission/similarity', { | |
| bodySnippet: shortText, | |
| }); | |
| } | |
| // ---- Refresh assignment launch URL periodically ---- | |
| // BUG-11 FIX: Allow up to MAX_REFRESHES refreshes, each triggered after | |
| // refreshAfterMs has passed since the previous refresh (or since start). | |
| const elapsedSinceLastRefresh = lastRefreshAt === 0 | |
| ? Date.now() - started | |
| : Date.now() - lastRefreshAt; | |
| const currentUrl = page.url(); | |
| const urlSeemsFine = | |
| currentUrl.includes('turnitin.com') && | |
| (currentUrl.includes('/assignment/') || currentUrl.includes('/class/')); | |
| const shouldRefresh = | |
| refreshCount < MAX_REFRESHES && | |
| elapsedSinceLastRefresh >= refreshAfterMs && | |
| (!urlSeemsFine || currentUrl.includes('/assignment/type/tool/launch')); | |
| if (shouldRefresh) { | |
| refreshCount++; | |
| lastRefreshAt = Date.now(); | |
| logger.info( | |
| `Similarity not visible; refreshing assignment launch URL (refresh ${refreshCount}/${MAX_REFRESHES})`, | |
| { currentUrl }, | |
| ); | |
| await page | |
| .reload({ waitUntil: 'domcontentloaded', timeout: 60000 }) | |
| .catch(() => {}); | |
| await page.waitForTimeout(5000); | |
| // ── Handle 502/503/504 error pages after refresh ── | |
| for (let refreshRetry = 0; refreshRetry < 2; refreshRetry++) { | |
| const bodyAfterRefresh = await page | |
| .locator('body') | |
| .innerText({ timeout: 3000 }) | |
| .catch(() => ''); | |
| if (/502 Bad Gateway|503 Service|504 Gateway/i.test(bodyAfterRefresh)) { | |
| logger.warn( | |
| `Server error detected after refresh ${refreshCount} (attempt ${refreshRetry + 1}/2); retrying reload`, | |
| { bodySnippet: bodyAfterRefresh.slice(0, 200) }, | |
| ); | |
| await page.waitForTimeout(5000); | |
| await page | |
| .reload({ waitUntil: 'domcontentloaded', timeout: 60000 }) | |
| .catch(() => {}); | |
| await page.waitForTimeout(5000); | |
| } else { | |
| break; | |
| } | |
| } | |
| continue; | |
| } | |
| await page.waitForTimeout(pollMs); | |
| } | |
| throw new Error( | |
| `Submission card/similarity did not become ready within ${timeoutMs}ms`, | |
| ); | |
| } | |