Spaces:
Sleeping
Sleeping
File size: 5,042 Bytes
521a9b6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 | 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;
}
|