relv-space5 / src /engine /steps /download.ts
relv-dev's picture
Deploy latest verified Turnitin worker
2db5489 verified
Raw
History Blame Contribute Delete
5.88 kB
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;
}
// ---------------------------------------------------------------------------
// Context-level download listener
// ---------------------------------------------------------------------------
/**
* Wait for a download event across all pages in the context.
* Turnitin may open/close temporary pages during export, so we listen at the
* context level rather than on a single page.
*/
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);
});
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Download the Turnitin report as a PDF from the viewer page.
*
* Steps:
* 1. Set up context-level download listener (not page-level)
* 2. Click download menu
* 3. Select Current View / Similarity Report
* 4. Wait for download to complete
* 5. Save to outputPath
*
* @returns The actual file path where the PDF was saved.
*/
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);
// Validate that we are on the correct viewer URL.
// Check both the scope URL (Frame or Page) and the owner page URL —
// the report domain may only appear on one of them.
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}`,
);
}
// ── BUG-5 FIX: Attach download listener BEFORE clicking any menu ──
// Previously the listener was set up after a 1-second wait post-click.
// If the browser starts the download faster than 1s the event was missed
// and the job timed out after 120 seconds.
const downloadPromise = waitForAnyDownload(context, 120000);
// Click download menu
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) {
// BUG-12 FIX: Remove the empty file so it doesn't accumulate in tmpDir
try { fs.unlinkSync(outputPath); } catch { /* ignore */ }
throw new Error(`Downloaded PDF is empty: ${outputPath}`);
}
logger.info('PDF downloaded', {
outputPath,
suggestedFilename: download.suggestedFilename(),
size: stat.size,
});
return outputPath;
}