Spaces:
Sleeping
Sleeping
| 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<number | null> { | |
| // 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; | |
| } | |