relv-space5 / src /engine /steps /similarity.ts
relv-dev's picture
Deploy latest verified Turnitin worker
2db5489 verified
Raw
History Blame Contribute Delete
8.51 kB
import type { Page, Frame, Locator } from 'playwright';
import { logger } from '../../utils/logger';
import {
readScopeDeepText,
resolveAssignmentScope,
SELECTORS,
} from '../selectors';
import { acceptEulaEverywhere } from './login';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
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;
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
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();
}
/**
* Poll for the similarity score to appear on the submission card.
*
* After `refreshAfterMs` with no result, refreshes the assignment launch URL
* once. Returns the similarity percent (if readable) and viewer URL.
*/
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 = '';
// BUG-11 FIX: Allow multiple refreshes (every refreshAfterMs from first refresh)
let refreshCount = 0;
const MAX_REFRESHES = 2;
let lastRefreshAt = 0;
while (Date.now() - started < timeoutMs) {
// ---- Deep text scan across all scopes ----
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,
);
// BUG-4 FIX: Validate bounds — regex now matches 0-999 so we clamp to 0-100
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),
};
}
// After refresh, if submission row is visible even without percent, proceed
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),
};
}
}
// ---- Locator-based scan ----
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),
};
}
}
}
// ---- Log body text for diagnostics ----
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,
});
}
// ---- Refresh assignment launch URL periodically ----
// BUG-11 FIX: Allow up to MAX_REFRESHES refreshes, each triggered after
// refreshAfterMs has passed since the previous refresh (or since start).
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);
// ── Handle 502/503/504 error pages after refresh ──
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`,
);
}