/** * PDF extraction — discovers and extracts text from PDF URLs found during crawl. * * When the HTTP engine or browser engine discovers a link to a .pdf file, * this module fetches the PDF, extracts its text content, and returns it * as a screen-compatible markdown string for test generation. * * Uses pdf-parse (already installed in API package, accessible via dynamic import). */ const PDF_EXT = /\.pdf(\?.*)?$/i; const PDF_MIME = 'application/pdf'; export interface PdfScreen { url: string; title: string; markdown: string; pageCount: number; info: Record; sizeBytes: number; } export function isPdfUrl(url: string, contentType?: string): boolean { return PDF_EXT.test(url) || (contentType?.includes(PDF_MIME) ?? false); } export async function extractPdfFromUrl(url: string, opts: { userAgent?: string; timeoutMs?: number; maxSizeBytes?: number; } = {}): Promise { const { userAgent = 'ZeTa-Crawler/1.0', timeoutMs = 30_000, maxSizeBytes = 50 * 1024 * 1024 } = opts; let buffer: Buffer; let sizeBytes: number; try { const response = await fetch(url, { headers: { 'User-Agent': userAgent, 'Accept': 'application/pdf,*/*' }, signal: AbortSignal.timeout(timeoutMs), }); if (!response.ok) return null; const contentType = response.headers.get('content-type') ?? ''; if (!contentType.includes('pdf') && !PDF_EXT.test(url)) return null; const arrayBuffer = await response.arrayBuffer(); sizeBytes = arrayBuffer.byteLength; if (sizeBytes > maxSizeBytes) { console.warn(`[pdf-extractor] PDF too large (${sizeBytes} bytes), skipping: ${url}`); return null; } buffer = Buffer.from(arrayBuffer); } catch (e: any) { console.error(`[pdf-extractor] Failed to fetch PDF ${url}: ${e.message}`); return null; } try { // pdf-parse is installed in the API package — use dynamic import // so this file compiles without adding pdf-parse to crawler's package.json // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore – no types for pdf-parse/lib/pdf-parse.js const pdfParse = await import('pdf-parse/lib/pdf-parse.js').catch( () => import('pdf-parse' as any) ) as any; const parse = pdfParse.default ?? pdfParse; const data = await parse(buffer, { max: 0 }); // max: 0 = all pages const title = (data.info?.Title as string) || extractTitleFromUrl(url); const markdown = pdfTextToMarkdown(data.text ?? '', title); return { url, title, markdown, pageCount: data.numpages ?? 0, info: { author: data.info?.Author ?? '', subject: data.info?.Subject ?? '', keywords: data.info?.Keywords ?? '', creator: data.info?.Creator ?? '', }, sizeBytes, }; } catch (e: any) { console.error(`[pdf-extractor] Failed to parse PDF ${url}: ${e.message}`); return null; } } function extractTitleFromUrl(url: string): string { try { const { pathname } = new URL(url); const filename = pathname.split('/').pop() ?? 'document'; return filename.replace(/\.pdf$/i, '').replace(/[-_]/g, ' ').trim(); } catch { return 'PDF Document'; } } function pdfTextToMarkdown(text: string, title: string): string { if (!text.trim()) return `# ${title}\n\n*No extractable text content.*`; // Clean up common PDF extraction artifacts const cleaned = text .replace(/\f/g, '\n\n---\n\n') // form feed = page break .replace(/\r\n/g, '\n') .replace(/\r/g, '\n') .replace(/\n{3,}/g, '\n\n') .replace(/[ \t]{2,}/g, ' ') .trim(); return `# ${title}\n\n${cleaned}`; } /** * Filter a list of discovered URLs to find PDF links. * Used by http-engine and browser crawler to decide which links to pass to extractPdfFromUrl. */ export function filterPdfUrls(urls: string[]): string[] { return urls.filter(u => PDF_EXT.test(u)); }