relv-space6 / src /engine /steps /submission-details.ts
relv-dev's picture
Add R2 dual-storage adapter and resilient legacy large-document wait
ba37a9b verified
Raw
History Blame Contribute Delete
5 kB
import type { Page, Frame } from 'playwright';
import { logger } from '../../utils/logger';
import { SELECTORS, deepClickByText } from '../selectors';
type Scope = Page | Frame;
export interface SubmissionDetails {
studentId?: string;
className?: string;
classId?: string;
submissionId?: string;
submissionDate?: string;
submissionCount?: number;
fileName?: string;
fileExtension?: string;
fileSize?: number;
charCount?: number;
wordCount?: number;
pageCount?: number;
pageCountSource?: 'pdf' | 'docx_metadata';
expectedWaitMinutes?: number;
largeDocument?: boolean;
}
type ParsedSubmissionDetailKey = Exclude<
keyof SubmissionDetails,
'pageCountSource' | 'expectedWaitMinutes' | 'largeDocument'
>;
function normalizeKey(label: string): ParsedSubmissionDetailKey | null {
const normalized = label.toLowerCase().replace(/\s+/g, ' ').trim();
if (normalized === 'student id') return 'studentId';
if (normalized === 'class name') return 'className';
if (normalized === 'class id') return 'classId';
if (normalized === 'submission id') return 'submissionId';
if (normalized === 'submission date') return 'submissionDate';
if (normalized === 'submission count') return 'submissionCount';
if (normalized === 'file name') return 'fileName';
if (normalized === 'file extension') return 'fileExtension';
if (normalized === 'file size') return 'fileSize';
if (normalized === 'char count') return 'charCount';
if (normalized === 'character count') return 'charCount';
if (normalized === 'word count') return 'wordCount';
if (normalized === 'page count') return 'pageCount';
return null;
}
function parseNumber(value: string): number | undefined {
const numeric = Number(String(value || '').replace(/[^\d]/g, ''));
return Number.isFinite(numeric) ? numeric : undefined;
}
/**
* Open the Turnitin viewer details panel and read file metadata. The worker
* emits these details to job events so the tracking page can show file facts
* before/while the final report is downloaded.
*/
export async function readSubmissionDetails(
page: Scope,
): Promise<SubmissionDetails | null> {
const detailsButton = page
.locator(
[
'tii-sws-submission-details-btn tdl-labeled-button',
'tii-sws-submission-details-btn',
'tii-sws-header [slot="submission-details-btn"]',
'tdl-labeled-button[withdatapx="SubmissionDetailsMenuClicked"]',
'[withdatapx="SubmissionDetailsMenuClicked"]',
'button:has-text("Details")',
'tdl-labeled-button:has-text("Details")',
].join(', '),
)
.first();
if (!(await detailsButton.isVisible({ timeout: 10000 }).catch(() => false))) {
const clickedByText = await deepClickByText(page, ['details']).catch(
() => false,
);
if (!clickedByText) {
logger.warn('Submission details button was not visible');
return null;
}
} else {
await detailsButton.click({ force: true });
}
await page.waitForTimeout(1200).catch(() => {});
const fileTab = page
.locator(
[
'tii-sws-tab-button#sub-details-tab-file',
'#sub-details-tab-file',
'.submission-details-tab:has-text("File")',
'[role="tab"]:has-text("File")',
].join(', '),
)
.first();
if (await fileTab.isVisible({ timeout: 5000 }).catch(() => false)) {
await fileTab.click({ force: true }).catch(() => {});
await page.waitForTimeout(700).catch(() => {});
} else {
await deepClickByText(page, ['file']).catch(() => false);
await page.waitForTimeout(700).catch(() => {});
}
const details = await page
.locator('#tii-sws-submission-details-list')
.first()
.evaluate((list) => {
return Array.from(list.querySelectorAll('.submission-details-item')).map(
(item) => {
const term = item
.querySelector('[role="term"]')
?.textContent?.trim() || '';
const value = item
.querySelector('[role="definition"], .submission-details-value')
?.textContent?.trim() || '';
return { term, value };
},
);
})
.catch(() => []);
const result: SubmissionDetails = {};
for (const row of details) {
const key = normalizeKey(row.term);
if (!key) continue;
if (
key === 'fileName' ||
key === 'fileExtension' ||
key === 'studentId' ||
key === 'className' ||
key === 'classId' ||
key === 'submissionId' ||
key === 'submissionDate'
) {
result[key] = row.value;
} else {
const parsed = parseNumber(row.value);
if (parsed !== undefined) result[key] = parsed;
}
}
await page
.locator(SELECTORS.filters.similarityTab)
.first()
.click({ force: true, timeout: 3000 })
.catch(() => {});
await page.waitForTimeout(800).catch(() => {});
if (Object.keys(result).length === 0) return null;
logger.info('Read submission details from viewer', { ...result });
return result;
}