import type { Page, Frame } from 'playwright'; import { logger } from '../../utils/logger'; import { readScopeDeepText } from '../selectors'; type Scope = Page | Frame; // --------------------------------------------------------------------------- // Parsing helpers // --------------------------------------------------------------------------- /** * Extract a similarity percentage from text. * * Handles multiple formats found across Turnitin UI versions: * - "45% Overall Similarity" * - "Similarity: 45%" * - "Overall Similarity 45 %" * - Plain "45" or "45%" (when extracted directly from a score element) */ function parseSimilarityPercent(text: string): number | null { const normalized = String(text || '').replace(/\s+/g, ' ').trim(); // Pattern 1: "45% Overall Similarity" const labelled = normalized.match( /(\d{1,3})\s*%\s*Overall Similarity/i, ); // Pattern 2: "Similarity: 45%" or "Overall Similarity: 45%" const generic = normalized.match( /(?:Overall\s+)?Similarity[:\s]+(\d{1,3})\s*%/i, ); // Pattern 3: "Overall Similarity 45 %" (label before value) const reversed = normalized.match( /Overall Similarity\s+(\d{1,3})\s*%/i, ); // Pattern 4: Plain number from a score element — "45" or "45%" const plain = normalized.match(/^(\d{1,3})\s*%?$/); const value = Number( labelled?.[1] ?? generic?.[1] ?? reversed?.[1] ?? plain?.[1] ?? NaN, ); if (!Number.isFinite(value)) return null; return Math.max(0, Math.min(100, value)); } // --------------------------------------------------------------------------- // CSS selectors for the similarity score in the report viewer // --------------------------------------------------------------------------- const SCORE_SELECTORS = [ // Primary — Turnitin viewer overview score '.tii-SimilarityHeader__heading-content .tii-OverviewScore__Value', '.tii-OverviewScore__Value', 'span.tii-OverviewScore__Value', // Heading area with live region 'h1[aria-live="polite"] span', 'h1[aria-live="polite"]', // Alternative UI variants '.tii-SimilarityReportPanelHeader__OverviewScore span', '.similarity-score', '.overall-similarity-score', '[data-testid="similarity-score"]', // Badge-style display 'span[part="tii-grn-badge-label"]', ]; // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- /** * Read the current overall similarity score from the Turnitin report viewer. * * Handles: * - Multiple CSS selector strategies for different Turnitin UI versions * - Shadow DOM deep text fallback * - Automatic retry with short wait to handle post-filter score updates * * This is used both as a fallback for assignment pages that still show * similarity as pending, and as the authoritative read after filter * application (where the displayed score may differ from the pre-filter value). */ export async function readViewerSimilarityPercent( page: Scope, ): Promise { // Allow up to 3 attempts with a short stabilization wait between each. // After filters are applied the score element may take a moment to update. const MAX_READ_ATTEMPTS = 3; for (let attempt = 1; attempt <= MAX_READ_ATTEMPTS; attempt++) { // Small wait for DOM to stabilize (especially after filter application) if (attempt > 1) { await page.waitForTimeout(2000); } // --- Strategy 1: CSS selector scan --- for (const selector of SCORE_SELECTORS) { const element = page.locator(selector).first(); const rawText = await element .textContent({ timeout: 2000 }) .catch(() => null); if (rawText !== null && rawText.trim() !== '') { // Try parsing as-is first (may already contain "Overall Similarity") let parsed = parseSimilarityPercent(rawText.trim()); // Fallback: append context so the labelled regex can match plain numbers if (parsed === null) { parsed = parseSimilarityPercent(`${rawText.trim()} Overall Similarity`); } if (parsed !== null) { logger.info('Read similarity score from viewer selector', { selector, rawText: rawText.trim(), similarityPercent: parsed, attempt, }); return parsed; } } } // --- Strategy 2: Deep text scan (traverses shadow DOM) --- const deepText = await readScopeDeepText(page); if (deepText) { const parsed = parseSimilarityPercent(deepText); if (parsed !== null) { logger.info('Read similarity score from viewer deep text', { similarityPercent: parsed, attempt, }); return parsed; } } if (attempt < MAX_READ_ATTEMPTS) { logger.debug(`Similarity score not found on attempt ${attempt}/${MAX_READ_ATTEMPTS}; retrying`); } } logger.warn('Could not read similarity score from viewer after all attempts'); return null; }