Spaces:
Sleeping
Sleeping
File size: 5,881 Bytes
2db5489 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 | 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;
}
|