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, type FetchResult } from 'snapdf'Returns { buffer: Buffer, title: string }.
const { buffer, title } = await fetchPdf('https://chatgpt.com/share/abc123', {
pageSize: 'letter', // 'letter' | 'a4'
margin: 36, // points, 72pt = 1in
landscape: false,
timeout: 60000, // ms
selector: '[data-message-author-role]',
cookies: [{ name: 'session', value: '...', domain: 'chatgpt.com' }],
executablePath: '/path/to/chrome',
args: ['--no-sandbox'],
onProgress: (msg) => console.log(msg),
})
await fs.writeFile(`${title}.pdf`, buffer)const text = await fetchTxt('https://chatgpt.com/share/abc123', {
timeout: 60000,
selector: '[data-message-author-role]',
cookies: [...],
executablePath: '/path/to/chrome',
args: ['--no-sandbox'],
onProgress: (msg) => console.log(msg),
})interface FetchOptions {
pageSize?: 'letter' | 'a4' // PDF only
margin?: number // PDF only, points
landscape?: boolean // PDF only
timeout?: number // ms, applies to page load
selector?: string // CSS selector for content nodes
cookies?: CookieParam[] // Puppeteer cookie objects
executablePath?: string // Path to Chrome binary
args?: string[] // Puppeteer launch args (e.g. ['--no-sandbox'])
onProgress?: (msg: string) => void // Progress callback
hideUserInput?: boolean // Omit user messages from output
hideAssistantOutput?: boolean // Omit assistant messages from output
}
interface FetchResult {
buffer: Buffer // PDF bytes
title: string // Page title, sanitized for use as a filename
}import express from 'express'
import { fetchPdf } from 'snapdf'
const app = express()
app.get('/pdf', async (req, res) => {
const { url } = req.query
const { buffer } = await fetchPdf(String(url))
res.setHeader('Content-Type', 'application/pdf')
res.send(buffer)
})Pass session cookies to access pages behind a login:
const { buffer } = await fetchPdf('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:
import chromium from '@sparticuz/chromium'
import { fetchPdf } from 'snapdf'
const { buffer } = await fetchPdf(url, {
executablePath: await chromium.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/article
await fetchPdf(url, { selector: 'article' })
// Any custom format
await fetchPdf(url, { selector: '.message-bubble' })