| import type { Page, Frame, Locator } from 'playwright'; |
| import { logger } from '../../utils/logger'; |
| import { |
| readScopeDeepText, |
| resolveAssignmentScope, |
| SELECTORS, |
| } from '../selectors'; |
| import { acceptEulaEverywhere } from './login'; |
|
|
| |
| |
| |
|
|
| 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; |
| } |
|
|
| |
| |
| |
|
|
| 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(); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| 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 = ''; |
| |
| let refreshCount = 0; |
| const MAX_REFRESHES = 2; |
| let lastRefreshAt = 0; |
|
|
| while (Date.now() - started < timeoutMs) { |
| |
| 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, |
| ); |
| |
| 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), |
| }; |
| } |
|
|
| |
| 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), |
| }; |
| } |
| } |
|
|
| |
| 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), |
| }; |
| } |
| } |
| } |
|
|
| |
| 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, |
| }); |
| } |
|
|
| |
| |
| |
| 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); |
|
|
| |
| 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`, |
| ); |
| } |
|
|