Convert JavaScript-rendered web pages to PDF or plain text. Built for sites with virtual scrolling (ChatGPT, Claude, etc.) where the browser's native print fails because content isn't all in the DOM at once.
Instead of screenshotting, snapdf scrolls through the page collecting DOM nodes as virtual scroll renders them, reassembles them into a static document, and prints it with Chrome's native PDF engine — giving you a real text-based, searchable PDF.
npm install -g snapdfsnapdf <url> [output]| Flag | Description |
|---|---|
-t, --txt | Save as plain text file instead of PDF |
-p, --page-size | letter or a4 (default: letter) |
-m, --margin | Margin in points, 72pt = 1in (default: 36) |
-l, --landscape | Landscape orientation |
-T, --timeout | Page load timeout in milliseconds (default: 60000) |
-s, --selector | CSS selector for message elements (auto-detected for ChatGPT and Claude) |
-H, --hide-user-input | Omit user messages from the output |
-A, --hide-assistant-output | Omit assistant messages from the output |
# Save a ChatGPT shared conversation to PDF
snapdf https://chatgpt.com/share/abc123
# Save a Claude shared conversation to PDF
snapdf https://claude.ai/share/abc123
# Custom output path
snapdf https://chatgpt.com/share/abc123 conversation.pdf
# Plain text
snapdf https://chatgpt.com/share/abc123 -t
# A4, 0.5in margins, landscape
snapdf https://chatgpt.com/share/abc123 -p a4 -m 36 -l
# Custom selector for other sites
snapdf https://example.com/thread -s "article.message"# Longer timeout for slow pages
snapdf https://chatgpt.com/share/abc123 -T 120000If no output path is given, the filename is derived from the page title (e.g. crumby-app-discussion.pdf). If a name is given without an extension, the correct one is added automatically (conversation → conversation.pdf). Existing files are never overwritten — snapdf increments the filename (output-1.pdf, output-2.pdf, etc.).
npm install snapdfimport{fetchPdf,fetchTxt,typeFetchResult}from'snapdf'Returns { buffer: Buffer, title: string }.
const{ buffer, title }=awaitfetchPdf('https://chatgpt.com/share/abc123',{pageSize: 'letter',// 'letter' | 'a4'margin: 36,// points, 72pt = 1inlandscape: false,timeout: 60000,// mscookies: [{name: 'session',value: '...',domain: 'chatgpt.com'}],executablePath: '/path/to/chrome',args: ['--no-sandbox'],onProgress: (msg)=>console.log(msg),})awaitfs.writeFile(`${title}.pdf`,buffer)consttext=awaitfetchTxt('https://chatgpt.com/share/abc123',{timeout: 60000,cookies: [...],executablePath: '/path/to/chrome',args: ['--no-sandbox'],onProgress: (msg)=>console.log(msg),})interfaceFetchOptions{pageSize?: 'letter'|'a4'// PDF onlymargin?: number// PDF only, pointslandscape?: boolean// PDF onlytimeout?: number// ms, applies to page loadselector?: string// CSS selector for content nodescookies?: CookieParam[]// Puppeteer cookie objectsexecutablePath?: string// Path to Chrome binaryargs?: string[]// Puppeteer launch args (e.g. ['--no-sandbox'])onProgress?: (msg: string)=>void// Progress callbackhideUserInput?: boolean// Omit user messages from outputhideAssistantOutput?: boolean// Omit assistant messages from output}interfaceFetchResult{buffer: Buffer// PDF bytestitle: string// Page title, sanitized for use as a filename}importexpressfrom'express'import{fetchPdf}from'snapdf'constapp=express()app.get('/pdf',async(req,res)=>{const{ url }=req.queryconst{ buffer }=awaitfetchPdf(String(url))res.setHeader('Content-Type','application/pdf')res.send(buffer)})Pass session cookies to access pages behind a login:
const{ buffer }=awaitfetchPdf('https://chatgpt.com/c/private-thread',{cookies: [{name: '__Secure-next-auth.session-token',value: '...',domain: 'chatgpt.com'}]})Puppeteer bundles its own Chrome, which works on standard servers and locally. For serverless environments (Lambda, Vercel, etc.) use @sparticuz/chromium and pass its executable path:
importchromiumfrom'@sparticuz/chromium'import{fetchPdf}from'snapdf'const{ buffer }=awaitfetchPdf(url,{executablePath: awaitchromium.executablePath(),args: chromium.args,})ChatGPT and Claude share URLs are auto-detected — no selector needed. For other sites, pass a CSS selector that matches the repeating content nodes you want captured:
// Generic blog/articleawaitfetchPdf(url,{selector: 'article'})// Any custom formatawaitfetchPdf(url,{selector: '.message-bubble'})