#!/usr/bin/env python3 from http.server import HTTPServer, BaseHTTPRequestHandler import subprocess, tempfile, os, json, urllib.parse, time, threading, shutil JOBS_DIR = "/tmp/url2pdf_jobs" os.makedirs(JOBS_DIR, exist_ok=True) CWD = "/opt/url2pdf" PUPPETEER_JS = """ const puppeteer = require("puppeteer"); (async () => { const browser = await puppeteer.launch({headless: true, args: ["--no-sandbox", "--disable-gpu"]}); const page = await browser.newPage(); await page.goto(process.argv[2], {waitUntil: "networkidle2", timeout: 30000}); await page.pdf({path: process.argv[3], format: "A4", printBackground: true}); await browser.close(); })(); """ CRAWLER_JS = """ // Documentation crawler - cwd must have puppeteer installed const puppeteer = require("puppeteer"); const fs = require("fs"); const path = require("path"); const { execSync } = require("child_process"); const BASE = process.argv[2]; const JOB_DIR = process.argv[3]; const baseUrl = new URL(BASE); const articleDir = baseUrl.pathname.substring(0, baseUrl.pathname.lastIndexOf("/") + 1); function log(msg) { fs.appendFileSync(path.join(JOB_DIR, "log.txt"), msg + "\\n"); process.stdout.write(msg + "\\n"); } (async () => { const statusPath = path.join(JOB_DIR, "status.json"); function setStatus(s) { fs.writeFileSync(statusPath, JSON.stringify({status: s, time: new Date().toISOString()})); } try { setStatus("extracting_urls"); const browser = await puppeteer.launch({headless: true, args: ["--no-sandbox"]}); const mainPage = await browser.newPage(); await mainPage.goto(BASE, {waitUntil: "networkidle2", timeout: 30000}); await new Promise(r => setTimeout(r, 3000)); const allLinks = await mainPage.evaluate(() => { const links = []; document.querySelectorAll("a[href]").forEach(a => { const text = a.textContent.trim(); if (text.length > 2) links.push({href: a.href, text: text.slice(0, 80)}); }); return links; }); await mainPage.close(); await browser.close(); // Filter unique doc pages (en-us only, dedupe by filename, skip language switchers) const seenFiles = new Set(); const docPages = []; for (const link of allLinks) { try { const u = new URL(link.href); if (u.hostname !== baseUrl.hostname) continue; if (!u.pathname.startsWith(articleDir)) continue; if (!u.pathname.startsWith("/en-us/")) continue; if (u.hash) continue; const fn = u.pathname.split("/").pop(); if (!fn || fn.length < 20) continue; const lower = link.text.toLowerCase(); if (/english|dansk|deutsch|español|français|italiano|nederlands|norsk|polski|português|svenska|ภาษาไทย|tiếng việt|türkçe|čeština|ελληνικά|български|русский|עברית|العربية|日本語|简体中文|繁體中文|한국어|bahasa indonesia/i.test(lower)) continue; if (/online help center|security for endpoints|apex one$/i.test(lower)) continue; if (!seenFiles.has(fn)) { seenFiles.add(fn); docPages.push({href: link.href, text: link.text}); } } catch(e) {} } log("Found " + docPages.length + " pages"); setStatus("crawling:" + docPages.length); // Process in batches of 20 to avoid OOM const BATCH = 20; const mdPath = path.join(JOB_DIR, "output.md"); const pdfDir = path.join(JOB_DIR, "pdfs"); fs.mkdirSync(pdfDir, {recursive: true}); // Write MD header fs.writeFileSync(mdPath, "# " + baseUrl.hostname.split(".")[0] + " Documentation\\n\\n> " + docPages.length + " pages from " + BASE + "\\n\\n"); const allPdfs = []; for (let batchStart = 0; batchStart < docPages.length; batchStart += BATCH) { const batchEnd = Math.min(batchStart + BATCH, docPages.length); setStatus("processing:" + batchStart + "/" + docPages.length); const b = await puppeteer.launch({headless: true, args: ["--no-sandbox"]}); for (let i = batchStart; i < batchEnd; i++) { const {href, text} = docPages[i]; try { const p = await b.newPage(); await p.goto(href, {waitUntil: "networkidle2", timeout: 20000}); await new Promise(r => setTimeout(r, 300)); // Extract text for MD const content = await p.evaluate(() => { const mainSel = ["article", ".content", ".main-content", ".doc-content", "main", "#content"]; let main = document.body; for (const sel of mainSel) { const el = document.querySelector(sel); if (el) { main = el; break; } } const h1 = main.querySelector("h1, h2"); const title = h1 ? h1.textContent.trim() : document.title.replace(/ \\| .*$/, "").trim(); const blocks = []; main.querySelectorAll("h1, h2, h3, h4, p, li, pre").forEach(el => { const txt = el.textContent.trim(); if (!txt || txt.length < 3) return; const tag = el.tagName; if (tag.match(/^H[1-4]$/)) blocks.push("\\n" + "#".repeat(parseInt(tag[1])+1) + " " + txt + "\\n"); else if (tag === "LI") blocks.push("- " + txt); else if (tag === "PRE") blocks.push("\\n```\\n" + txt + "\\n```\\n"); else blocks.push(txt); }); return {title, body: blocks.join("\\n\\n")}; }); fs.appendFileSync(mdPath, "## " + (i+1) + ". " + content.title + "\\n\\n" + (content.body || "_") + "\\n\\n"); // PDF for this page const pdfFile = path.join(pdfDir, String(i).padStart(4, "0") + ".pdf"); await p.pdf({path: pdfFile, format: "A4", printBackground: true}); allPdfs.push(pdfFile); await p.close(); } catch(e) { fs.appendFileSync(mdPath, "## " + (i+1) + ". " + text + "\\n\\n_Error_\\n\\n"); } } await b.close(); log("Batch " + (batchStart/BATCH + 1) + ": " + (batchStart+1) + "-" + batchEnd + " / " + docPages.length); } // Merge PDFs setStatus("merging"); const finalPdf = path.join(JOB_DIR, "output.pdf"); if (allPdfs.length > 0) { execSync("pdfunite " + allPdfs.join(" ") + " " + finalPdf, {stdio: "pipe"}); } const pdfSize = fs.existsSync(finalPdf) ? fs.statSync(finalPdf).size : 0; setStatus("done:" + pdfSize + ":" + docPages.length); log("DONE. PDF: " + (pdfSize/1024/1024).toFixed(1) + "MB, MD: " + (fs.statSync(mdPath).size/1024).toFixed(0) + "KB"); } catch(e) { setStatus("error:" + e.message.slice(0, 200)); log("ERROR: " + e.message); } })(); """ HTML = """
Convert URLs & documentation sites
Powered by Chrome (Puppeteer)
Crawl an entire documentation site into a single PDF + Markdown file
Each doc page becomes one PDF page. Large sites may take a few minutes.