
做这个文库下载器起因很简单我实在受够了“在线预览精致得不行下载下来却像车祸现场”的体验。市面上的文库下载器我也没少试绝大多数本质上是文本搬运工给你一份丢失排版、图片裂开、表格错位的纯文字文档碰上公式更是直接变成一堆LaTeX源码糊在屏幕里。我想要的只有八个字所见即所得。网页预览里长什么样下载到本地就得是什么样。这个工具最后做出来技术路线其实一点也不玄让浏览器自己“打印”自己。我不去猜接口、不去扒数据只开着无头浏览器把阅读页渲染出来再把渲染结果导出成PDF。前后折腾了两个晚上中间踩了字体、懒加载、弹窗遮罩的大坑总算把“预览即所得”变成了现实。如果你也想做同类工具或者只是想把在线文库备份成离线PDF这篇文章应该能帮你省下不少时间。1. 为什么“所见即所得”成了下载文库的头号难题1.1 预览与下载之间的“信息断层”先理解一下你在网页预览里看到的到底是什么。现在的文库阅读器早就不再是服务端拼好一整张HTML吐给你了它本质上是一个单页应用SPA打开页面时只拿到一个外壳真正的正文内容往往由后续的接口分页返回再由前端脚本拼进阅读器容器里。这意味着什么你直接用requests去抓页面的HTML源码大概率只能抓到一堆script标签和空壳div正文一个字都找不到。就算你耐着性子打开开发者工具把Network面板里的接口翻个底朝天找到那个返回正文JSON的地址你还要面对另一层麻烦正文里的段落结构、图片位置、表格样式、公式渲染全部由前端的几十个CSS文件和字体文件决定。你拿到了数据但没有拿到它们被组织、被呈现的方式。我打个比方网页预览像是餐厅已经摆好盘的菜接口返回的原始数据只是冰柜里的食材。你要的是那盘菜的样子而不是一袋没人处理的生肉。所以“所见即所得”这个需求的难点从来不是“能不能取到数据”而是“怎么把数据重新摆成网页里那个样子”。1.2 三种常见下法的天然缺陷市面上各种下载器思路基本逃不出下面三类各有各的死穴解析HTML成文本。实现最快一个BeautifulSoup循环就能跑。但它只保留文字内容样式、图片、分页、表格结构全部丢掉碰上公式更是直接原形毕露。这种输出只能算“可读”谈不上“可用”。模拟接口拼数据。比上一种强至少能把图片和段落按顺序怼回去。但你需要逆向对方的接口结构、分页参数、签名规则还要处理图片防盗链前端一改版你就得跟着返工维护成本极高。滚动截屏。视觉上最接近但导出的是位图文字不能搜索放大就糊长文档拼接还会出现上下页重影。这三条路我都走过一轮最后发现一个朴素的事实你所有的努力都是在试图“复刻”浏览器已经做好的渲染工作。那为什么不直接请浏览器本人出马呢让它把渲染结果原封不动交出来这就是“所见即所得”最直接的解法。2. 选定技术路线让浏览器自己“打印”自己2.1 为什么放弃纯接口解析在真正动手前我还抱着“写个脚本精准取数”的幻想试过一轮接口解析。当时踩了一连串坑浪费了大半天。首先是接口鉴权。文库接口通常带签名参数和Cookie校验你自己请求时少一个字段服务端直接不搭理你。即便你运气好把数据拉回来了还要面对结构漂移的问题很多接口返回的不是干净的JSON而是一段带样式的HTML片段字段命名还很随意甚至同一个字段在不同文档类型下含义完全不同。最头疼的是图片防盗链。正文里的图片直接请求十有八九返回403必须带Referer和Cookie才给放行。如果你想把图片和文字一起保存下来就得自己维护一套下载逻辑逐个替换图片URL还要等它们全部下载完毕再拼进文档里。这一套折腾下来我意识到自己根本不是在做一个下载器而是在重写一个阅读器。这样下去没有尽头于是果断转向无头浏览器方案。2.2 用无头浏览器做“打印”的整体逻辑无头浏览器方案的核心思路可以浓缩成一句话让页面自己加载、自己请求、自己渲染我们只负责等它渲染完再把结果导出来。具体链路是打开浏览器 → 加载阅读页 → 等待异步资源就绪 → 移除干扰元素 → 调用打印PDF → 保存到本地这里的“打印”不是物理打印机那个打印而是Chromium内核自带的Page.printToPDF能力。它在无头模式下可以被自动化调用把你当前看到的DOM渲染成一份PDF文件。Playwright把这一能力封装成了page.pdf()用起来非常顺手。大多数人可能没意识到浏览器把页面打印成PDF这个过程天然就是“所见即所得”的。因为浏览器打印用的排版引擎和屏幕渲染用的排版引擎是同一个CSS支持度也一致网页里什么样的分页、什么样的字体、什么样的背景色PDF里都是同一套规则。所以我们只需要保证页面在打印前已经加载完整并且把那些不想进PDF的弹窗、工具栏、页脚清理掉就能得到一份和预览几乎一致的PDF。2.3 环境准备与项目骨架我用的技术栈是Node.js Playwright。选Playwright而不是Puppeteer主要是两个原因一是它的API设计更现代等待元素、网络事件的能力更完善二是它可以通过npx playwright install chromium一条命令装好浏览器环境搭建省心很多。初始化项目很简单mkdir xx-wysiwyg-downloader cd xx-wysiwyg-downloader npm init -y npm install playwright npx playwright install chromium有一点容易被忽略如果跑脚本的机器是Linux服务器一定要装中文字体。无头Chromium不会自动使用宿主机的字体配置缺字体时打印出来的PDF会变成一片方块字或错乱的回退字体。我在Ubuntu上用的是sudo apt-get install -y fonts-noto-cjk这个坑我刚开始没注意导致第一版脚本打印出来的PDF全是缺字一度以为是代码问题排查半天才发现是字体问题。3. 核心实现逐页打印PDF的完整流程3.1 打开阅读页并等待异步资源就绪先看核心函数打开一个URL等页面渲染完成然后导出PDF。下面这段代码是整个下载器的地基const { chromium } require(playwright); async function capturePage(url, outPath) { const browser await chromium.launch({ headless: true, args: [--no-sandbox, --disable-dev-shm-usage] }); const context await browser.newContext({ viewport: { width: 1200, height: 900 }, deviceScaleFactor: 1, userAgent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0 Safari/537.36 }); const page await context.newPage(); // 不用 networkidle很多文库站点会持续轮询请求永远等不到空闲 await page.goto(url, { waitUntil: domcontentloaded, timeout: 60000 }); // 等阅读器容器出现这里的选择器要按目标站点调整 await page.waitForSelector(.reader-container, { timeout: 30000 }); // 给字体渲染、图片解码留出缓冲时间 await page.waitForTimeout(1500); // 强制等待页面内所有 img 元素加载完成 await page.evaluate(async () { const imgs Array.from(document.querySelectorAll(img)); await Promise.all(imgs.map(img { if (img.complete) return Promise.resolve(); return new Promise(resolve { img.addEventListener(load, () resolve(), { once: true }); img.addEventListener(error, () resolve(), { once: true }); }); })); }); // 打印PDF await page.pdf({ path: outPath, format: A4, printBackground: true, margin: { top: 0, bottom: 0, left: 0, right: 0 }, displayHeaderFooter: false }); await browser.close(); }这里有几个细节值得展开说。第一waitUntil我用了domcontentloaded而不是networkidle。很多文库站点加载完正文后还会持续发送统计上报、心跳轮询请求你等networkidle可能等到超时也等不到。更可靠的做法是先等DOM框架出来再用显式的选择器等待确定阅读器容器真的渲染出来了。第二等待图片加载的这段代码非常实用。浏览器虽然会自动加载图片但如果图片在懒加载机制里还没进入视口img.complete会是false打印出来就是一块空白。这里等所有图片都触发load事件后才继续从根源上避免缺图。第三printBackground: true必须开。很多文库页面的正文有浅色底纹、分隔线、标注背景这些在网页上看着正常如果不开这个参数打印出来会全部消失排版效果大打折扣。3.2 清理干扰元素还原干净排版页面上杂七杂八的东西太多顶栏、底部版权、右侧悬浮按钮、弹窗遮罩、二维码引导。这些东西如果不清理会出现在PDF里尤其一些半透明遮罩层打印出来会变成一大块色块难看至极。我的做法是只保留正文容器其他全部删掉。配合一段暴力清理代码await page.evaluate(() { const keep document.querySelector(.reader-container); if (!keep) return; Array.from(document.body.children).forEach(child { if (child ! keep !keep.contains(child)) { child.remove(); } }); });这段代码的逻辑是拿到阅读器容器节点遍历body的直接子元素凡是跟阅读器容器没有包含关系的一律移除。这样能最大程度保留正文区域同时把弹窗、页头、页脚一锅端。有一点要提醒清理时机很关键。有些弹窗是页面加载后延迟一秒才出现的你太早清理它后面还会冒出来。所以我通常会在调用page.pdf()之前再做一次清理确保PDF渲染的那一刻页面是干净的。如果目标站点的阅读器容器选择器不好猜一个取巧的办法是打印前先看看页面上有没有类似“.reader-box”“.doc-content”“.page-core”这样的常见类名按实际情况调整选择器即可。3.3 printToPDF 的关键参数与效果对比page.pdf()的参数直接决定了最终PDF的质量和形态。我把几个关键时刻的参数捋了一下参数作用推荐值printBackground是否打印背景色和背景图true否则浅色底纹丢失format纸面尺寸A4或按预览宽度自定义margin页边距全0贴近网页布局displayHeaderFooter是否显示页脚页码false避免多余白边preferCSSPageSize是否采用页面CSS里定义的尺寸视站点而定有的站点自带打印样式scale缩放比例默认1无需调整我实测下来A4 margin 0 printBackground true这个组合最接近网页预览的观感。有些文库页面在打印时会有自己的“打印样式表”会把页面宽度压缩成A4宽度。这种情况下你甚至可以尝试把preferCSSPageSize设为true让浏览器尊重页面自身的分页尺寸效果会更意外地好。如果你需要的内容不是PDF也可以改一下page.screenshot()可以对整个长页面截一张长图虽然前面说过它有几个缺点但偶尔需要快速分享时还挺方便。PDF和截图都从同一个渲染结果产出说明这套方案的上限其实很高。4. 批量场景下的翻页与合并处理4.1 三种翻页策略的选择大部分文库文档不止一页阅读器会通过点击按钮、滚动区域或者URL参数来切换页面。处理多页时要按目标站点的交互方式选择翻页策略。我归纳了三种常见情况策略优点缺点适用场景每页独立URL稳定可并发可能需要登录态URL带页码参数点击“下一页”按钮通用不依赖外部参数慢依赖按钮选择器SPA阅读器滚动触发懒加载简单直接无法精确控制页码边界流式加载的文档最省心的是每页独立URL的情况。如果阅读页的URL形如/read?idxxxpage2那只要循环换页码每次打开一个新页面渲染完打印后再关闭全程不需要处理翻页动画和状态同步。如果站点是SPA翻页靠点击按钮那就要注意等待逻辑。点击“下一页”之后正文内容不是立刻替换的需要等新一页渲染完成再打印否则你会连续打印两份相同的内容。4.2 循环采集与异常重试写一个比较通用的循环采集函数大致长这样async function collectDocument(pageUrl, totalPages, outDir) { const outputPaths []; for (let i 1; i totalPages; i) { const currentUrl pageUrl.includes(page) ? pageUrl.replace(/page\d/, page${i}) : ${pageUrl}page${i}; const outPath ${outDir}/page_${i}.pdf; let success false; for (let retry 0; retry 2; retry) { try { await capturePage(currentUrl, outPath); success true; break; } catch (err) { console.error(第${i}页失败第${retry 1}次重试:, err.message); await new Promise(r setTimeout(r, 3000)); } } if (!success) { console.error(第${i}页多次失败跳过); continue; } outputPaths.push(outPath); } return outputPaths; }注意我这里用了两层循环外层是页码遍历内层是失败重试。网络抖动、图片加载超时、页面偶发报错在批量任务里都是常态没有重试机制的脚本跑长文档基本活不过一半。每次重试之间间隔3秒避免对目标站点造成压力也给自己留出带宽。4.3 PDF合并与文件命名逐页打印得到的是几十个单页PDF最后还要把它们合并成一个整本。我用的是pdf-merger-js这个库API很简单npm install pdf-merger-jsconst PDFMerger require(pdf-merger-js); async function mergePDFs(inputPaths, outputPath) { const merger new PDFMerger(); for (const p of inputPaths) { await merger.add(p); } await merger.save(outputPath); }合并完成后顺手把临时生成的单页PDF清理掉只留最终的整本文件。文件名处理也要小心文档标题里经常带/\:*?|这些在Windows下非法的字符最好统一清洗一遍const safeTitle title.replace(/[\\/:*?|]/g, _);生成的文件名我习惯用日期_标题_页数.pdf的格式比如20250611_某某技术手册_88页.pdf。这样归档后一眼就能看出文档内容和规模。5. 实测中踩过的坑和对应的补丁5.1 弹窗和遮罩层让PDF第一页变成“黑名单”第一次跑完整流程时我打印出来的文档第一页顶部有一大块黑色色块完全遮盖了正文内容。排查了一会儿发现是页面右上角悬浮的“扫码下载APP”提示框配合一个半透明遮罩层。这个遮罩层本身颜色很浅网页上看不出来但打印到PDF里却渲染成了深色块。我的补丁分为两段第一段是打印前统一清理也就是前面说的只保留正文容器第二段是延迟二次清理在page.pdf()之前再跑一次同样的清理专门对付那些延迟出现的浮层。这个补丁打上后黑块问题再没出现过。5.2 中文字体丢失与行距错乱另一件让我印象深刻的坑是字体。开发机上跑得好好的部署到一台全新的Linux服务器后打印出来的PDF里中文字体全部变成了“豆腐块”或者错乱的回退字体行距也跟网页预览不一样。原因前面提过无头Chromium用的是自己环境里的字体库宿主机没有装中文字体时它找不到可用的CJK字体只能走回退逻辑。这个问题靠代码无法完全解决必须在系统层面装好字体。除了安装fonts-noto-cjk我还给页面注入了一段兜底CSS强制正文使用中文字体await page.addStyleTag({ content: body, .reader-container { font-family: Noto Sans CJK SC, WenQuanYi Micro Hei, sans-serif !important; } });虽然这会让字体跟网页原始字体有细微差异但至少保证可读性和行距正常。要追求完全一致最好在本地测试机上确认网页实际使用的字体名再在服务器上安装同一个字体。5.3 图片懒加载导致缺图、错位第三类常见问题是“打印出来图片全没了”。很多文库阅读器对正文图片做了懒加载只有图片滚动到可视区域内才请求真实地址之前显示的是占位图。无头浏览器打开页面后页面视口就那么高正文里下面的图片根本没被请求直接打印自然是一片空白。补丁方案也比较经典先滚动页面把所有内容都“溜”一遍触发懒加载再回到顶部最后打印。await page.evaluate(async () { const scrollHeight document.body.scrollHeight; let y 0; while (y scrollHeight) { window.scrollTo(0, y); await new Promise(r setTimeout(r, 200)); y window.innerHeight * 0.9; } window.scrollTo(0, 0); await new Promise(r setTimeout(r, 1000)); });这段代码会从顶部一路滚到底部每滚一段停200毫秒给浏览器留出发送图片请求、解码渲染的时间。滚完再回到顶部确保打印时页面处于起始位置。实测下来只要是标准懒加载这招基本能解决95%的缺图问题。5.4 登录态与验证码的边界文库站点有些文档需要登录才能看到完整内容甚至需要验证码。一开始我很头大因为无头浏览器是全新会话没有登录Cookie打开的页面只有残缺预览。后来我用Playwright的storageState解决了登录态持久化// 先在本地保存登录态 await context.storageState({ path: loginState.json }); // 后续每次启动都加载 const context await browser.newContext({ storageState: loginState.json });先在有头模式下打开浏览器手动登录一次把Cookie和localStorage保存下来再跑无头脚本时直接恢复登录态。这个方法对绝大多数站点都有效。但有一点我必须说清楚验证码不该去绕付费内容也不该去试。这个工具应该只处理你本来就有权限阅读的文档比如你自己上传的资料、公司内部的知识库、公开免费的教程。把它用在绕过付费和权限验证上既不合规也会给自己惹麻烦完全没有必要。6. 工具能做什么、不能做什么的最后交代6.1 使用边界这个下载器解决的核心问题只有一个把你在网页预览里能正常阅读的文档变成一份本地PDF备份。它不会去破解付费文档不会绕过VIP权限也不会突破验证码防线。它的定位是个人效率工具不是什么“文库万能钥匙”。说点实际的判断标准如果你能在网页预览里完整读到这一页的内容并且你有正当的阅读权限那把它保存下来就是合理的个人备份如果你打开页面只能看到一半或者页面明确提示需要付费、需要登录——那就到此为止换一个你有权限的文档处理。这个边界守住工具用起来才踏实。6.2 我进一步扩展的几个方向首版脚本跑通后我又做了几个小扩展都很便宜但很实用生成PDF书签因为逐页打印每一页对应PDF的一节合并后可以顺便把所有页码写进书签方便跳转。用pdf-lib可以自己往PDF里加目录。多任务排队写一个批量任务队列先解析文档列表再逐本下载跑一晚上能归档一批资料。输出HTML存档除了PDF也可以把清理后的页面保存成完整HTML图片用Base64内嵌作为网页快照存档体积比PDF小打开也快。这些扩展都不复杂核心都是围绕“所见即所得”这个原则展开先让浏览器渲染再把渲染结果用不同格式存下来。我在实际用这个脚本时最深的体会是与其追求“下载到原文件”不如追求“保存成能看的样子”。原文件未必存在即便存在格式也未必能完整还原。只要预览页面渲染得好打印出来的PDF就已经足够满足阅读、检索、批注的需求。最后再说一句这脚本写完之后我反而很少下载整本文库了。真正需要收藏的内容我会手动打开链接用浏览器自带的“另存为PDF”随手存一份“所见即所得”的自动化主要留给那些有几十上百页、一页页截图根本不现实的场景。工具嘛够用就行。