mirror of
https://github.com/NohamR/pdfy.git
synced 2026-10-10 18:29:55 +00:00
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules
|
||||||
|
output
|
||||||
|
extension/reader-view
|
||||||
|
extension/*.crx
|
||||||
|
extension/*.zip
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.DS_Store
|
||||||
|
*~
|
||||||
33
Dockerfile
Normal file
33
Dockerfile
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
FROM node:22-bookworm-slim
|
||||||
|
|
||||||
|
# Install Chromium
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
chromium \
|
||||||
|
unzip \
|
||||||
|
ca-certificates \
|
||||||
|
--no-install-recommends \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Tell Puppeteer to use the system Chromium instead of downloading its own
|
||||||
|
ENV PUPPETEER_SKIP_BROWSER_DOWNLOAD=true
|
||||||
|
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Pre-download the Reader View extension so it works offline at runtime
|
||||||
|
RUN node src/download-extension.js
|
||||||
|
|
||||||
|
RUN useradd -m appuser && chown -R appuser:appuser /app
|
||||||
|
USER appuser
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
ENV PORT=8080
|
||||||
|
ENV LOG_LEVEL=info
|
||||||
|
|
||||||
|
CMD ["node", "src/server.js"]
|
||||||
37
README.md
37
README.md
@@ -144,6 +144,43 @@ try {
|
|||||||
**`BotProtectionError`** — thrown when the target page appears to be behind bot protection.
|
**`BotProtectionError`** — thrown when the target page appears to be behind bot protection.
|
||||||
|
|
||||||
|
|
||||||
|
## HTTP Server
|
||||||
|
|
||||||
|
Run as a headless service via Docker or directly with Node:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start server
|
||||||
|
npm run start:server
|
||||||
|
|
||||||
|
# Or via Docker
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Endpoints
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /convert Generate a PDF from a URL
|
||||||
|
GET /health Health check (browser status, uptime)
|
||||||
|
GET / Help page
|
||||||
|
```
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8080/convert \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"url":"https://example.com/article","theme":"dark"}' \
|
||||||
|
-o article.pdf
|
||||||
|
```
|
||||||
|
|
||||||
|
| Body field | Type | Description |
|
||||||
|
|------------|------|-------------|
|
||||||
|
| `url` | string | Article URL (required unless `html` is provided) |
|
||||||
|
| `theme` | string | Reader View theme |
|
||||||
|
| `css` | string | Custom CSS string |
|
||||||
|
| `html` | string | Pre-fetched HTML (bypasses URL fetch) |
|
||||||
|
| `prefs` | object | Extension preferences |
|
||||||
|
|
||||||
## Credits
|
## Credits
|
||||||
|
|
||||||
- [Reader View](https://chromewebstore.google.com/detail/reader-view/ecabifbgmdmgdllomnfinbmaellmclnh) Chrome extension
|
- [Reader View](https://chromewebstore.google.com/detail/reader-view/ecabifbgmdmgdllomnfinbmaellmclnh) Chrome extension
|
||||||
|
|||||||
10
docker-compose.yml
Normal file
10
docker-compose.yml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
services:
|
||||||
|
pdfy:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
environment:
|
||||||
|
- PORT=8080
|
||||||
|
- LOG_LEVEL=info
|
||||||
|
restart: unless-stopped
|
||||||
|
init: true
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node src/index.js",
|
"start": "node src/index.js",
|
||||||
|
"start:server": "node src/server.js",
|
||||||
"test": "node src/test/test.js"
|
"test": "node src/test/test.js"
|
||||||
},
|
},
|
||||||
"standard": {
|
"standard": {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import path from 'node:path'
|
|||||||
import readline from 'node:readline/promises'
|
import readline from 'node:readline/promises'
|
||||||
import { parseArgs } from './cli.js'
|
import { parseArgs } from './cli.js'
|
||||||
import { loadConfigFile, loadPreferences, readCustomCss } from './config.js'
|
import { loadConfigFile, loadPreferences, readCustomCss } from './config.js'
|
||||||
|
import { getOutputFilename } from './pdf.js'
|
||||||
import { convert, BotProtectionError } from './pdfy.js'
|
import { convert, BotProtectionError } from './pdfy.js'
|
||||||
import logger from './logger.js'
|
import logger from './logger.js'
|
||||||
|
|
||||||
@@ -20,7 +21,7 @@ async function promptForInput () {
|
|||||||
async function main () {
|
async function main () {
|
||||||
const { url, opts } = parseArgs()
|
const { url, opts } = parseArgs()
|
||||||
|
|
||||||
await loadConfigFile(opts.config)
|
const config = await loadConfigFile(opts.config)
|
||||||
|
|
||||||
const prefsPath = opts.prefs || null
|
const prefsPath = opts.prefs || null
|
||||||
const cssPath = opts.css || null
|
const cssPath = opts.css || null
|
||||||
@@ -44,9 +45,9 @@ async function main () {
|
|||||||
|
|
||||||
const sizeKb = (result.pdfBuffer.length / 1024).toFixed(0)
|
const sizeKb = (result.pdfBuffer.length / 1024).toFixed(0)
|
||||||
logger.info(`PDF generated: "${result.title}" (${sizeKb} KB)`)
|
logger.info(`PDF generated: "${result.title}" (${sizeKb} KB)`)
|
||||||
if (result.outputPath) {
|
const savePath = result.outputPath || getOutputFilename(result.title)
|
||||||
logger.info(`Saved to: "${result.outputPath}"`)
|
fs.writeFileSync(savePath, result.pdfBuffer)
|
||||||
}
|
logger.info(`Saved to: "${savePath}"`)
|
||||||
return
|
return
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof BotProtectionError && attempt === 0) {
|
if (err instanceof BotProtectionError && attempt === 0) {
|
||||||
|
|||||||
@@ -41,11 +41,11 @@ export function enhanceHtml (html, customCss) {
|
|||||||
return enhanced
|
return enhanced
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generatePdf (page, html, outputPath) {
|
export async function generatePdf (page, html, outputPath, { timeout = 30000 } = {}) {
|
||||||
await page.setContent(html, { waitUntil: 'networkidle0' })
|
await page.setContent(html, { waitUntil: 'networkidle0', timeout })
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
() => Array.from(document.images).every(img => img.complete),
|
() => Array.from(document.images).every(img => img.complete),
|
||||||
{ timeout: 15000, polling: 200 }
|
{ timeout, polling: 200 }
|
||||||
).catch(() => logger.debug('Image wait timed out, continuing...'))
|
).catch(() => logger.debug('Image wait timed out, continuing...'))
|
||||||
const pdfOptions = {
|
const pdfOptions = {
|
||||||
format: 'A4',
|
format: 'A4',
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { VALID_THEMES } from './cli.js'
|
|||||||
import logger from './logger.js'
|
import logger from './logger.js'
|
||||||
|
|
||||||
const EXTENSION_BASE = 'chrome-extension://'
|
const EXTENSION_BASE = 'chrome-extension://'
|
||||||
|
const NAV_TIMEOUT = 30000
|
||||||
|
|
||||||
export { launchBrowser, VALID_THEMES }
|
export { launchBrowser, VALID_THEMES }
|
||||||
|
|
||||||
@@ -45,6 +46,8 @@ export async function convert (url, options = {}) {
|
|||||||
|
|
||||||
logger.info(`Theme: ${theme}`)
|
logger.info(`Theme: ${theme}`)
|
||||||
|
|
||||||
|
const timeout = NAV_TIMEOUT
|
||||||
|
|
||||||
await checkExtension()
|
await checkExtension()
|
||||||
|
|
||||||
let browser = externalBrowser
|
let browser = externalBrowser
|
||||||
@@ -62,7 +65,7 @@ export async function convert (url, options = {}) {
|
|||||||
if (html) {
|
if (html) {
|
||||||
await articlePage.setContent(html, { waitUntil: 'networkidle0' })
|
await articlePage.setContent(html, { waitUntil: 'networkidle0' })
|
||||||
} else {
|
} else {
|
||||||
await articlePage.goto(url, { waitUntil: 'networkidle0', timeout: 30000 })
|
await articlePage.goto(url, { waitUntil: 'networkidle0', timeout })
|
||||||
}
|
}
|
||||||
const article = await extractArticle(articlePage, getReadabilityPath())
|
const article = await extractArticle(articlePage, getReadabilityPath())
|
||||||
await articlePage.close()
|
await articlePage.close()
|
||||||
@@ -95,7 +98,7 @@ export async function convert (url, options = {}) {
|
|||||||
logger.debug(`Opening Reader View: ${readerUrl}`)
|
logger.debug(`Opening Reader View: ${readerUrl}`)
|
||||||
|
|
||||||
const readerPage = await browser.newPage()
|
const readerPage = await browser.newPage()
|
||||||
await readerPage.goto(readerUrl, { waitUntil: 'load', timeout: 30000 })
|
await readerPage.goto(readerUrl, { waitUntil: 'load', timeout })
|
||||||
await waitForReaderView(readerPage)
|
await waitForReaderView(readerPage)
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||||
|
|
||||||
@@ -120,7 +123,7 @@ export async function convert (url, options = {}) {
|
|||||||
fs.mkdirSync(path.dirname(outputPath), { recursive: true })
|
fs.mkdirSync(path.dirname(outputPath), { recursive: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
const pdfBuffer = await generatePdf(pdfPage, enhancedHtml, outputPath)
|
const pdfBuffer = await generatePdf(pdfPage, enhancedHtml, outputPath, { timeout })
|
||||||
await pdfPage.close()
|
await pdfPage.close()
|
||||||
|
|
||||||
return { title, pdfBuffer, outputPath }
|
return { title, pdfBuffer, outputPath }
|
||||||
|
|||||||
128
src/server.js
Normal file
128
src/server.js
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { createServer } from 'node:http'
|
||||||
|
import process from 'node:process'
|
||||||
|
import { convert, launchBrowser, BotProtectionError, VALID_THEMES } from './pdfy.js'
|
||||||
|
import logger from './logger.js'
|
||||||
|
|
||||||
|
const PORT = parseInt(process.env.PORT || '8080', 10)
|
||||||
|
const HOST = process.env.HOST || '0.0.0.0'
|
||||||
|
|
||||||
|
let browser
|
||||||
|
|
||||||
|
async function parseBody (req) {
|
||||||
|
const chunks = []
|
||||||
|
for await (const chunk of req) {
|
||||||
|
chunks.push(chunk)
|
||||||
|
}
|
||||||
|
return JSON.parse(Buffer.concat(chunks).toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendJson (res, status, data) {
|
||||||
|
res.writeHead(status, { 'Content-Type': 'application/json' })
|
||||||
|
res.end(JSON.stringify(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleConvert (req, res) {
|
||||||
|
let body
|
||||||
|
try {
|
||||||
|
body = await parseBody(req)
|
||||||
|
} catch {
|
||||||
|
return sendJson(res, 400, { error: 'Invalid JSON body' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { url, theme, css, prefs, html } = body
|
||||||
|
|
||||||
|
if (!url && !html) {
|
||||||
|
return sendJson(res, 400, { error: 'Missing required field: url (or html for direct content)' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (theme && !VALID_THEMES.includes(theme)) {
|
||||||
|
return sendJson(res, 400, {
|
||||||
|
error: `Invalid theme. Valid themes: ${VALID_THEMES.join(', ')}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await convert(url || 'about:blank', {
|
||||||
|
browser,
|
||||||
|
html: html || null,
|
||||||
|
theme: theme || 'light',
|
||||||
|
css: css || null,
|
||||||
|
prefs: prefs || {}
|
||||||
|
})
|
||||||
|
|
||||||
|
const sizeKb = (result.pdfBuffer.length / 1024).toFixed(0)
|
||||||
|
logger.info(`Converted: "${result.title}" (${sizeKb} KB)`)
|
||||||
|
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'application/pdf',
|
||||||
|
'Content-Disposition': `attachment; filename="${encodeURIComponent(result.title)}.pdf"`,
|
||||||
|
'Content-Length': result.pdfBuffer.length
|
||||||
|
})
|
||||||
|
res.end(result.pdfBuffer)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof BotProtectionError) {
|
||||||
|
return sendJson(res, 400, {
|
||||||
|
error: 'Bot protection detected.',
|
||||||
|
hint: 'Pass the page HTML directly via the "html" field.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
logger.error(`Conversion failed: ${err.message}`)
|
||||||
|
sendJson(res, 500, { error: err.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = createServer((req, res) => {
|
||||||
|
const { method, url } = req
|
||||||
|
|
||||||
|
if (method === 'POST' && url === '/convert') {
|
||||||
|
return handleConvert(req, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'GET' && url === '/health') {
|
||||||
|
return sendJson(res, 200, {
|
||||||
|
status: 'ok',
|
||||||
|
browser: browser ? 'connected' : 'disconnected',
|
||||||
|
uptime: process.uptime()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'GET' && url === '/') {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/plain' })
|
||||||
|
res.end(`
|
||||||
|
pdfy server
|
||||||
|
|
||||||
|
POST /convert Convert a URL to PDF
|
||||||
|
Body: { "url": "...", "theme": "...", "css": "...", "html": "..." }
|
||||||
|
|
||||||
|
GET /health Health check
|
||||||
|
GET / This help
|
||||||
|
`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sendJson(res, 404, { error: 'Not found' })
|
||||||
|
})
|
||||||
|
|
||||||
|
async function start () {
|
||||||
|
logger.info('Launching browser...')
|
||||||
|
browser = await launchBrowser()
|
||||||
|
logger.info('Browser ready')
|
||||||
|
|
||||||
|
server.listen(PORT, HOST, () => {
|
||||||
|
logger.info(`Server listening on http://${HOST}:${PORT}`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function shutdown () {
|
||||||
|
logger.info('Shutting down...')
|
||||||
|
if (browser) await browser.close()
|
||||||
|
server.close(() => process.exit(0))
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on('SIGINT', shutdown)
|
||||||
|
process.on('SIGTERM', shutdown)
|
||||||
|
|
||||||
|
start().catch((err) => {
|
||||||
|
logger.error(`Failed to start: ${err.message}`)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user