relv-space3 / src /engine /steps /viewer-similarity.ts
relv-dev's picture
Deploy latest verified Turnitin worker
9e212e3 verified
Raw
History Blame Contribute Delete
5.04 kB
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;
}