Spaces:
Sleeping
Sleeping
| 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; | |
| } | |