| import type { Page, Frame, BrowserContext, Download } from 'playwright'; |
| import * as fs from 'fs'; |
| import { logger } from '../../utils/logger'; |
| import { SELECTORS, deepClickByText } from '../selectors'; |
|
|
| type Scope = Page | Frame; |
|
|
| async function clickFirstVisible( |
| page: Scope, |
| selector: string, |
| limit = 20, |
| ): Promise<boolean> { |
| const locator = page.locator(selector); |
| const count = Math.min(await locator.count().catch(() => 0), limit); |
|
|
| for (let index = 0; index < count; index++) { |
| const candidate = locator.nth(index); |
| if (!(await candidate.isVisible({ timeout: 800 }).catch(() => false))) { |
| continue; |
| } |
| await candidate.click({ force: true }); |
| return true; |
| } |
|
|
| return false; |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| function waitForAnyDownload( |
| context: BrowserContext, |
| timeoutMs: number, |
| ): Promise<Download> { |
| return new Promise((resolve, reject) => { |
| const timer = setTimeout( |
| () => cleanup(new Error(`Timed out waiting ${timeoutMs}ms for download`)), |
| timeoutMs, |
| ); |
| const pageListeners = new Map<Page, (d: Download) => void>(); |
|
|
| const onDownload = (download: Download) => cleanup(null, download); |
| const onPage = (p: Page) => attach(p); |
|
|
| function attach(p: Page) { |
| p.on('download', onDownload); |
| pageListeners.set(p, onDownload); |
| } |
|
|
| function cleanup(error: Error | null, download?: Download) { |
| clearTimeout(timer); |
| context.off('page', onPage); |
| for (const [p, listener] of pageListeners) { |
| p.off('download', listener); |
| } |
| if (error) reject(error); |
| else resolve(download!); |
| } |
|
|
| for (const p of context.pages()) attach(p); |
| context.on('page', onPage); |
| }); |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export async function downloadPdf( |
| page: Scope, |
| context: BrowserContext, |
| outputPath: string, |
| ): Promise<string> { |
| const ownerPage = |
| typeof (page as any).page === 'function' |
| ? (page as Frame).page() |
| : (page as Page); |
|
|
| |
| |
| |
| const scopeUrl = page.url(); |
| const ownerUrl = ownerPage.url(); |
| const isValidViewerUrl = (url: string) => |
| url.includes('reports.integrity.turnitin.com') && |
| /submission-viewer|\/submission\//i.test(url); |
|
|
| if (!isValidViewerUrl(scopeUrl) && !isValidViewerUrl(ownerUrl)) { |
| throw new Error( |
| `Cannot download report because viewer page is not active. ` + |
| `scope URL: ${scopeUrl}, owner URL: ${ownerUrl}`, |
| ); |
| } |
|
|
| |
| |
| |
| |
| const downloadPromise = waitForAnyDownload(context, 120000); |
|
|
| |
| let clickedDownloadMenu = false; |
| const downloadMenuDeadline = Date.now() + 30_000; |
| while (Date.now() < downloadMenuDeadline && !clickedDownloadMenu) { |
| clickedDownloadMenu = await clickFirstVisible( |
| page, |
| SELECTORS.download.downloadMenu, |
| 20, |
| ); |
| if (!clickedDownloadMenu) { |
| await ownerPage.waitForTimeout(1000); |
| } |
| } |
| if (!clickedDownloadMenu) { |
| const clicked = await deepClickByText(page, ['download']); |
| if (!clicked) { |
| throw new Error('Download menu button was not found in report viewer'); |
| } |
| } |
| await ownerPage.waitForTimeout(1000); |
|
|
| const optionSelectors = [ |
| SELECTORS.download.currentViewOption, |
| '[with-data-px="DownloadOptionCurrentViewClicked"]', |
| '[data-px="DownloadOptionCurrentViewClicked"]', |
| 'button:has-text("Current View")', |
| 'tdl-button:has-text("Current View")', |
| '[role="menuitem"]:has-text("Current View")', |
| 'button:has-text("Similarity Report")', |
| 'tdl-button:has-text("Similarity Report")', |
| '[role="menuitem"]:has-text("Similarity Report")', |
| ]; |
|
|
| let clickedOption = false; |
| for (const selector of optionSelectors) { |
| if (await clickFirstVisible(page, selector, 10)) { |
| clickedOption = true; |
| break; |
| } |
| } |
|
|
| if (!clickedOption) { |
| clickedOption = await deepClickByText(page, [ |
| 'current view', |
| 'download current view', |
| 'similarity report', |
| 'download similarity report', |
| ]); |
| } |
|
|
| if (!clickedOption) { |
| throw new Error('Download option was not found after opening download menu'); |
| } |
|
|
| const download = await downloadPromise; |
| await download.saveAs(outputPath); |
|
|
| const stat = fs.statSync(outputPath); |
| if (stat.size <= 0) { |
| |
| try { fs.unlinkSync(outputPath); } catch { } |
| throw new Error(`Downloaded PDF is empty: ${outputPath}`); |
| } |
|
|
| logger.info('PDF downloaded', { |
| outputPath, |
| suggestedFilename: download.suggestedFilename(), |
| size: stat.size, |
| }); |
|
|
| return outputPath; |
| } |
|
|