diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..bd5e775 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +node_modules +output +extension/reader-view +extension/*.crx +extension/*.zip +.git +.gitignore +.DS_Store +*~ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..dd8357a --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index 47b86a5..c8b4183 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,43 @@ try { **`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 - [Reader View](https://chromewebstore.google.com/detail/reader-view/ecabifbgmdmgdllomnfinbmaellmclnh) Chrome extension diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..cf03071 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,10 @@ +services: + pdfy: + build: . + ports: + - "8080:8080" + environment: + - PORT=8080 + - LOG_LEVEL=info + restart: unless-stopped + init: true diff --git a/package.json b/package.json index df64cbe..37b1135 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ }, "scripts": { "start": "node src/index.js", + "start:server": "node src/server.js", "test": "node src/test/test.js" }, "standard": { diff --git a/src/index.js b/src/index.js index dbbec46..2b3f413 100644 --- a/src/index.js +++ b/src/index.js @@ -4,6 +4,7 @@ import path from 'node:path' import readline from 'node:readline/promises' import { parseArgs } from './cli.js' import { loadConfigFile, loadPreferences, readCustomCss } from './config.js' +import { getOutputFilename } from './pdf.js' import { convert, BotProtectionError } from './pdfy.js' import logger from './logger.js' @@ -20,7 +21,7 @@ async function promptForInput () { async function main () { const { url, opts } = parseArgs() - await loadConfigFile(opts.config) + const config = await loadConfigFile(opts.config) const prefsPath = opts.prefs || null const cssPath = opts.css || null @@ -44,9 +45,9 @@ async function main () { const sizeKb = (result.pdfBuffer.length / 1024).toFixed(0) logger.info(`PDF generated: "${result.title}" (${sizeKb} KB)`) - if (result.outputPath) { - logger.info(`Saved to: "${result.outputPath}"`) - } + const savePath = result.outputPath || getOutputFilename(result.title) + fs.writeFileSync(savePath, result.pdfBuffer) + logger.info(`Saved to: "${savePath}"`) return } catch (err) { if (err instanceof BotProtectionError && attempt === 0) { diff --git a/src/pdf.js b/src/pdf.js index b35ef2f..c9bb954 100644 --- a/src/pdf.js +++ b/src/pdf.js @@ -41,11 +41,11 @@ export function enhanceHtml (html, customCss) { return enhanced } -export async function generatePdf (page, html, outputPath) { - await page.setContent(html, { waitUntil: 'networkidle0' }) +export async function generatePdf (page, html, outputPath, { timeout = 30000 } = {}) { + await page.setContent(html, { waitUntil: 'networkidle0', timeout }) await page.waitForFunction( () => Array.from(document.images).every(img => img.complete), - { timeout: 15000, polling: 200 } + { timeout, polling: 200 } ).catch(() => logger.debug('Image wait timed out, continuing...')) const pdfOptions = { format: 'A4', diff --git a/src/pdfy.js b/src/pdfy.js index 42bd3f2..84f9831 100644 --- a/src/pdfy.js +++ b/src/pdfy.js @@ -19,6 +19,7 @@ import { VALID_THEMES } from './cli.js' import logger from './logger.js' const EXTENSION_BASE = 'chrome-extension://' +const NAV_TIMEOUT = 30000 export { launchBrowser, VALID_THEMES } @@ -45,6 +46,8 @@ export async function convert (url, options = {}) { logger.info(`Theme: ${theme}`) + const timeout = NAV_TIMEOUT + await checkExtension() let browser = externalBrowser @@ -62,7 +65,7 @@ export async function convert (url, options = {}) { if (html) { await articlePage.setContent(html, { waitUntil: 'networkidle0' }) } else { - await articlePage.goto(url, { waitUntil: 'networkidle0', timeout: 30000 }) + await articlePage.goto(url, { waitUntil: 'networkidle0', timeout }) } const article = await extractArticle(articlePage, getReadabilityPath()) await articlePage.close() @@ -95,7 +98,7 @@ export async function convert (url, options = {}) { logger.debug(`Opening Reader View: ${readerUrl}`) const readerPage = await browser.newPage() - await readerPage.goto(readerUrl, { waitUntil: 'load', timeout: 30000 }) + await readerPage.goto(readerUrl, { waitUntil: 'load', timeout }) await waitForReaderView(readerPage) await new Promise((resolve) => setTimeout(resolve, 1000)) @@ -120,7 +123,7 @@ export async function convert (url, options = {}) { 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() return { title, pdfBuffer, outputPath } diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..bfe26fc --- /dev/null +++ b/src/server.js @@ -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) +})