1. 先说清楚:为什么非要把 CSDN 文章弄成 PDF
前几天想收藏一篇讲内核调度器的深度好文,原打算直接在浏览器里按Ctrl+P打印成 PDF 存到本地,结果导出后一看,页面上全是侧边栏、相关推荐、底部广告和作者卡片,正文只占中间一小条,代码块还断成了好几页。这种 PDF 基本没法离线阅读,更别说放进自己的知识库做标注了。
CSDN 上有大量技术博主写的实操记录、踩坑总结、源码解析,内容价值很高,但站内阅读体验受限于评论区、推荐流和各种动态加载内容,长期保存并不方便。把文章完整下载到本地并转成 PDF,等于把网上的零散知识"固定"下来,方便离线读、划线、归档,甚至打印出来慢慢看。特别是准备系统性学习某个框架或参加面试复习时,把几十篇高质量文章整理成统一的 PDF 文档,效率提升非常明显。
这篇内容写给谁?主要是这几类人:平时依赖 CSDN 查资料、想建立个人离线知识库的开发者;需要把技术文章整理成培训材料或读书笔记的记录癖;以及想写个简单脚本批量保存网页文章、但对 HTML 转 PDF 流程还不太熟悉的入门朋友。我会从方案选型、核心原理、可运行的代码、踩坑记录到批量玩法全部讲透,照着操作就能把一篇文章干干净净地转成 PDF。
要说清楚一个前提:CSDN 上的文章分为博主原创、付费专栏、VIP 专享等几种类型。本文处理的场景是免费公开文章的离线保存,用于个人学习与归档。付费内容和 VIP 专享文章涉及账号权益与平台规则,不在本文讨论范围内,也请大家不要绕过付费机制去做任何形式的抓取与传播。技术上能做的事,不等于可以随便做,这一点先达成共识。
2. 三条技术路线怎么选:别一上来就写爬虫
"把网页存成 PDF"这个需求,网上能搜出一堆方法,我梳理下来其实就三大类:浏览器插件、在线转换工具、本地脚本。三类我都试过,各有各的坑,先说结论再展开。
2.1 浏览器插件:最快但"半成品"
Chrome 商店里搜"Save as PDF"或"打印美化",能找到不少一键把当前页面转为 PDF 的扩展。这类插件本质上是调用了浏览器内置的打印引擎,只是帮你去掉了页面的部分广告脚本、重新排版了一下。
我实测下来的体验是:简单文章能用,遇到代码多、公式多、页面结构复杂的文章就露馅。插件无法精确识别 CSDN 正文内容区到底从哪里开始、到哪里结束,经常会保留右下角的悬浮咨询框、顶部的登录引导条,甚至把评论区也打进去。而且部分插件是把页面整体截图再切分,PDF 里的文字无法选中复制,这对技术读者来说基本等于废了。
2.2 在线转换工具:方便但隐私与样式风险并存
把 URL 粘贴到 pdfcrowd、iLovePDF 这类在线服务,几秒钟就能拿到文件,手机上操作尤其方便。我早年也这么干过,后来放弃的原因有两个:一是排版还原度不可控,在线服务用的是通用渲染内核,对 CSDN 这类带大量自定义样式的页面支持一般,代码高亮经常丢失,窄屏适配也差;二是隐私问题,你访问过的页面地址、读过的技术内容会上传到第三方服务器,搞技术的人应该对数据流向保持敏感。偶尔转一篇应急可以,不建议作为主力方案。
2.3 本地脚本:前期投入 30 分钟,后面一劳永逸
写一个脚本让本地的无头浏览器访问页面、提取正文、渲染成 PDF,是三种方案里最"重"但最可控的。你完全掌握整个流程:正文区域用什么 CSS 选择器、页面样式如何处理、图片要不要下载到本地、PDF 的页边距和缩放比例是多少,全都可以自己定义。
我最终选择了 Playwright 驱动的无头 Chrome 方案。原因很直接:CSDN 页面大量依赖 JavaScript 动态渲染,传统 requests + BeautifulSoup 只能拿到最初的 HTML 骨架,拿不到完整渲染后的 DOM,而 Playwright 本身就是一个真实浏览器,加载完成后页面长什么样,PDF 导出后就是什么样,所见即所得。
三条路线横向对比如下:
| 方案 | 上手成本 | 排版还原度 | 自定义能力 | 隐私风险 | 适合场景 |
|---|---|---|---|---|---|
| 浏览器插件 | 低 | 中等 | 很弱 | 低 | 偶尔转一篇 |
| 在线转换 | 极低 | 不可控 | 无 | 高 | 紧急应急 |
| 本地脚本 | 中等 | 高(可调) | 强 | 无 | 批量、长期使用 |
对于想认真做知识管理的人来说,本地脚本是唯一值得投入时间的方案,后面的内容全部围绕它展开。
3. 核心原理拆解:一个网页到底是怎么变成 PDF 的
很多人以为"网页转 PDF"就是把 HTML 文件改个后缀名,这完全不是一回事。理解中间发生了什么,后面调参的时候才不会抓瞎。
3.1 页面渲染的三个关键阶段
一个网页从 URL 到最终 PDF,要经历三个环节:
- 请求与响应:通过 HTTP 请求拿到 HTML 文档,此时文档里包含的是原始标签、内联样式、外部脚本链接。
- DOM 构建与动态执行:浏览器解析 HTML 生成 DOM 树,同时执行 JavaScript,包括异步加载文章内容、渲染代码高亮、加载图片。这一步是静态爬虫最容易断掉的一环——很多数据不是 HTML 里直接有的,而是 JS 跑完后才出现在页面上。
- 排版与打印:渲染引擎根据 CSS 计算出每个元素的位置和尺寸,再通过打印接口输出为 PDF 页面。
前两步决定内容是否完整,第三步决定排版是否好看。传统的requests库做的是第一步,强一点的人会用正则或 BeautifulSoup 从第一步的结果里抠正文,但没走第二步,所以拿到的内容经常缺胳膊少腿。
3.2 为什么必须用无头浏览器而不是普通爬虫
Playwright 的全称叫 "Playwright for Python",它控制一个真实的 Chromium 浏览器实例去执行上面说的三个阶段,和你在电脑上手动打开 Chrome 完全没有区别。它支持page.pdf()方法,可以直接把当前页面导出为 PDF。
这里还有个容易被忽略的技术点:PDF 的生成路径有两种。一种是浏览器的原生打印接口,另一种是重新排版后绘制到 PDF。CSDN 的正文区宽度有限,直接打印整页会把内容压得很窄,两侧大量留白。正确的做法是先把页面内容区宽度撑开,再触发打印,这样 PDF 页面利用率高得多。我下面的代码里会有这一手的处理。
一个更底层的问题也值得理解:为什么有时图片加载不出来?因为 CSDN 的图片通常做了防盗链处理,直接访问图片 URL 会返回 403,只有带着页面自身的 Referer 头才能加载。在无头浏览器里,只要让浏览器正常访问了页面,图片的 Referer 就是正常的,所以通常没这个问题。但如果用 requests 直接下载图片 URL,十有八九会碰到 403,这也就是为什么纯爬虫方案做 PDF 总是缺图。
4. 上手实操:用 Playwright 把 CSDN 文章完整转成 PDF
接下来是核心环节。我会给出一版我在 Ubuntu 上跑通的脚本,Windows 和 macOS 同样适用,把注释写清楚,照着抄就能用。
4.1 环境准备与依赖安装
前提条件:Python 3.8 以上,建议用虚拟环境隔离依赖。
python3 -m venv csdn2pdf source csdn2pdf/bin/activate pip install playwright playwright install chromium这里解释一下第二行命令的作用:playwright install chromium是下载 Playwright 内置的 Chromium 浏览器,大约 150MB。这一步很多新手会忘了执行,结果跑代码时报Executable doesn't exist的错误。下载慢的话可以配置国内镜像源,但一般等一会儿都能下完。
4.2 核心脚本与逐行解读
脚本逻辑分五步:启动浏览器、访问页面、等待渲染完成、调整布局、导出 PDF。
import re from playwright.sync_api import sync_playwright def csdn_to_pdf(url: str, output_path: str = "csdn_article.pdf"): # 1. 启动 Chromium,headless=True 表示无界面模式 with sync_playwright() as p: browser = p.chromium.launch(headless=True) # 2. 创建上下文并设置桌面端 UA context = browser.new_context( user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" ) page = context.new_page() # 3. 访问目标页面 page.goto(url, wait_until="domcontentloaded", timeout=30000) # 4. 等待正文区元素出现,说明内容已渲染完成 try: page.wait_for_selector("#article_content", timeout=10000) except Exception: print("[warning] 未找到正文区,可能页面结构变化或需要登录") # 5. 移除干扰元素,撑开正文宽度 remove_script = """ () => { // 删除固定定位的悬浮元素 document.querySelectorAll('aside, .recommend-box, .toolbox, .csdn-side-toolbar') .forEach(el => el.remove()); // 强制将正文区宽度拉宽 const content = document.querySelector('#article_content'); if (content) { content.style.maxWidth = '1000px'; content.style.width = '1000px'; content.style.margin = '0 auto'; } // 隐藏顶部的博客标题栏之外的冗余区域 document.querySelectorAll('.blog-toolbar, .more-toolbox, .toolbox-list') .forEach(el => el.style.display = 'none'); } """ page.evaluate(remove_script) # 6. 滚动页面触发懒加载图片 page.evaluate(""" async () => { const scrollHeight = document.body.scrollHeight; for (let y = 0; y < scrollHeight; y += 800) { window.scrollTo(0, y); await new Promise(r => setTimeout(r, 150)); } window.scrollTo(0, 0); } """) # 7. 导出 PDF page.pdf( path=output_path, format="A4", margin={"top": "20mm", "bottom": "20mm", "left": "15mm", "right": "15mm"}, print_background=True, ) print(f"[ok] PDF saved to {output_path}") browser.close() if __name__ == "__main__": url = "https://blog.csdn.net/你的具体文章链接" csdn_to_pdf(url, "kernel_scheduler.pdf")4.3 关键参数与细节说明
wait_until="domcontentloaded"表示 DOM 解析完成后就立即继续执行,而不是等所有图片和脚本都加载完。太严格的条件(比如networkidle)在 CSDN 这种广告脚本很多的页面上可能等几十秒都等不到,没必要。
page.wait_for_selector("#article_content")是第二个等待保障。#article_content是 CSDN 文章正文区容器的真实 ID,它在页面里存在,就说明正文已经渲染完毕。这一步比等固定秒数可靠得多——网络快慢不影响脚本,万事具备才继续走。
步骤 6 的滚动逻辑非常关键。CSDN 的图片一般使用懒加载策略:图片元素在进入视口附近时才会真正发请求加载。如果跳过滚动这一手,PDF 导出后图片区域全是灰块。滚动到页面底部再回到顶部,就是为了把每张图都"激活"。间隔 150ms 是为了给图片加载留出时间,速度可以根据网络情况调,慢网络建议加大到 300ms。
print_background=True是排版保真的灵魂。去掉这行,所有带背景色的代码块、行号、高亮区域在 PDF 里会变成纯白底配深色文字,可读性大打折扣。用了这个参数,代码块浅灰背景、关键字的颜色高亮才会被保留。
4.4 运行效果验证
脚本跑完打开 PDF 检查三件事:第一,标题、作者、发布时间是否保留;第二,代码块是否可选中、是否保留缩进;第三,图片是否完整且清晰。我的实测结果是:正文排版与网页端一致,代码高亮完整,图片清晰无加载失败的灰块。美中不足的是目录功能(TOC)不会自动生成,这个放到进阶玩法里解决。
5. 踩坑实录:登录态、图片防盗链与代码高亮是怎么处理的
我最初写第一版脚本只花了 20 分钟,但让它稳定跑通,前后折腾了两天。下面是遇到过、也解决掉的问题,每个都说明排查思路,你遇到同款问题时不用从头踩一遍。
5.1 登录态丢失:部分文章只能看到一半
CSDN 的部分文章有"登录后查看全文"或"关注博主后查看全文"的门槛,这类功能核心是靠 Cookie 判断用户状态的。直接用无头浏览器访问,没有登录 Cookie,文章内容被截断十分正常。
我的排查结论:个人学习场景中,建议自己先手动登录一次 CSDN,固定浏览器上下文,让脚本加载已有的登录态。Playwright 支持launch_persistent_context,把用户数据目录指定到本地:
context = browser.new_context( storage_state="csdn_state.json" # 需要先手动导出登录态 )具体做法是:先用一个脚本手动打开窗口登录一次 CSDN,然后调用context.storage_state(path="csdn_state.json")保存状态;之后脚本运行时加载这个文件,就有登录权限了。注意这种方式保存的是你自己的 Cookie,有隐私风险,文件不要提交到公共仓库。
5.2 图片防盗链与懒加载的双重保险
前面提过防盗链问题,在 Playwright 方案里基本不存在,因为浏览器会自动带上正确的 Referer。但有一个例外:有些图片存储在第三方图床或阿里云 OSS 上,带有时效签名,加载一次后 URL 就失效。这种页面如果 PDF 导出的时间比较晚,图片可能已经加载失败。
处理方式是在页面加载完成后立刻滚动触发加载,并检查图片的complete属性,对失败的图片做重试:
page.evaluate(""" async () => { const imgs = document.querySelectorAll('#article_content img'); for (let img of imgs) { if (!img.complete || img.naturalWidth === 0) { img.src = img.src; // 强制重新加载 await new Promise(r => setTimeout(r, 800)); } } } """)这段代码可以作为滚动逻辑的补充,两次加载基本能覆盖 99% 的情况。个别仍加载失败的图片,PDF 里会留下一个空白占位符,不影响整篇阅读。
5.3 代码高亮丢失与折叠区域展开
CSDN 的代码块使用了 Prism.js 一类的语法高亮库,高亮是在 JS 执行完成后通过 CSS 类名控制的。普通爬虫拿 HTML 源码再渲染,肯定没有高亮效果。Playwright 天然解决这个问题。但要注意另一个细节:文章过长时部分代码块会被"展开"折叠按钮藏起来,PDF 导出后只显示前几行。
解决思路:document.querySelectorAll('.code-block').forEach(el => { el.classList.add('open'); }),或者直接模拟点击"展开全部"按钮。我的脚本里用的是直接移除隐藏样式的方案:
page.evaluate(""" () => { document.querySelectorAll('.hide-brake, .set-code-hide, .code-hide') .forEach(el => el.remove()); document.querySelectorAll('.code-toolbar') .forEach(el => el.style.display = 'block'); } """)不同时期 CSDN 使用的类名不同,这里列三个常见类,实际使用时可以在页面源码里搜索"展开"按钮对应的 DOM 结构再调整。
5.4 页面结构变化导致正文区找不到
CSDN 改版过很多次,#article_content这个 ID 从老版本就一直存在,目前仍然可靠。但如果你遇到脚本报"未找到正文区",请先手动打开页面,按F12查看正文区域的实际选择器,常见替代方案有.article_content、#mainBox、.markdown_views。把脚本里的选择器换掉即可,排查思路比代码本身更重要。
6. 进阶玩法:批量下载、信息设置与本地阅读工作流
单篇转 PDF 只是第一步。真正让这套流程有价值的是把它变成系统化的知识管理方案。
6.1 批量下载:从收藏夹批量转 PDF
如果你有一批文章链接存在浏览器书签或本地文本文件里,可以用循环批量处理。核心逻辑是给每篇文章起一个有意义的名字,避免全都是"CSDN文章.pdf"这种没法区分的命名。
建议命名为:日期_博主昵称_文章关键词.pdf,例如2025-01-15_酷酷的仔_内核调度器解析.pdf。后期检索时日期和关键词是最常用的筛选条件。
批量脚本的关键是做好异常隔离,单篇文章失败不能中断整个任务:
import time with open("article_urls.txt", "r", encoding="utf-8") as f: urls = [line.strip() for line in f if line.strip()] for i, url in enumerate(urls, 1): try: print(f"[{i}/{len(urls)}] 处理: {url}") csdn_to_pdf(url, f"output/article_{i}.pdf") time.sleep(2) # 温和限速,避免对目标站造成压力 except Exception as e: print(f"[error] {url} 转换失败: {e}") continue这里强调一个合规与礼貌问题:批量抓取任何网站之前,建议查看目标网站的 robots.txt,控制抓取频率,不要对服务器造成明显压力。个人保存几十篇文章的规模完全没有问题,但动辄上千篇的抓取既不礼貌也可能触发封禁。
6.2 给 PDF 加元数据和封面标记
直接用 Playwright 导出的 PDF 默认没有标题和作者信息。可以借助 PyPDF2 这类库给 PDF 补充元数据,方便在系统文件管理器中快速识别:
from PyPDF2 import PdfReader, PdfWriter reader = PdfReader("output/article_1.pdf") writer = PdfWriter() for page in reader.pages: writer.add_page(page) writer.add_metadata({ "/Title": "内核调度器完全解析", "/Author": "酷酷的仔", "/Subject": "Linux Kernel Scheduler", "/Keywords": "kernel, scheduler, linux" }) with open("output/article_1_meta.pdf", "wb") as f: writer.write(f)6.3 构建本地知识库:把 PDF 变成可搜索的资产
转换完成只是开始。我个人的习惯是建一个本地目录结构,按主题分区存放,再配合文件管理工具做全文搜索。
推荐一个组合:PDF 文件 + Calibre 或 Obsidian 管理。Calibre 的管理字段丰富,适合做图书库式管理;如果是纯技术人,我更推荐Obsidian——把 PDF 拖入库中,配合 Obsidian 的全文搜索和双链笔记功能,可以把散落的 PDF 变成互相连接的"个人知识图谱"。
另外一个很实用的小工具是 PDF 书签生成器。长文章转出来的 PDF 没有左侧目录导航,翻阅体验一般。可以使用pdfoutline这类库,按文章标题层级自动生成 PDF 书签:
pip install pypdf outline python -m pdfoutline executive -in article.pdf -out article_toc.pdfpdfoutline会根据 PDF 页面内容的标题样式自动推断目录层级,生成带可点击书签的版本,阅读体验会好很多。
6.4 进阶处理技巧汇总
经验沉淀下来,几个小技巧值得记在本子上:
- 代码块多、宽度大的文章,PDF 导出时把页面朝向设为
landscape横向,横向排版代码不会断行。 - 文章包含大量图片且需要高清细节时,可以把渲染视口宽度调大(比如
1920或2560),PDF 中的图片分辨率会更高。 - 想保留原页面中的链接可点击时,在
page.pdf()中加入print_background=True之外,不需要额外配置,PDF 中文本链接默认保留。 - 字体方面,建议把正文字体设置为衬线体(如 Georgia 或宋体),长时间阅读比黑体类无衬线体更舒服。可以通过注入全局 CSS 的方式覆盖页面字体。
page.add_style_tag(content=""" body { font-family: Georgia, 'Noto Serif SC', serif !important; } #article_content { font-size: 15px; line-height: 1.8; } """)这一手会显著提升整体观感,值得试一下。
7. 建好你的离线阅读习惯,这篇才没白写
整个过程走下来,你最终拥有的是一个完全本地化、可搜索、可标注的技术文章库。我自己的库里现在已经攒了两百多篇 PDF,全是踩坑笔记、源码解析和框架教程,阅读时直接在 PDF 里画线写批注,检索时用文件名关键词就能快速定位。做这套流程的初衷,就是不让好内容在网络的信息流里沉淀下来又被冲走。
我个人在实际操作中体会到的最大价值,其实是主动筛选:每次保存一篇,都会顺手整理出主题关键词,这比在收藏夹里囤一堆不知何时再看的文章链接强得多。知识管理这件事,少即是多,精读一篇胜过收藏十篇。
如果你也打算落地这套流程,开头不必追求完美。先拿最近想读的一篇文章试试手,跑通了再逐步加批量、加元数据、加书签。等脚本稳定了,你会发现这半小时的前期投入,能换来未来无数个高效检索的时刻。