1. 项目缘起与整体思路拆解
1.1 这个项目到底在做什么
先把话说清楚:这个项目的核心目标,是把某个内容平台上的公开文章数据抓下来,经过清洗整理之后,生成一份排版规整、可以直接归档或打印的 PDF 文档。标题里提到的“Muse”,是我给这套工具链起的一个内部代号,你可以把它理解成一套“采集 + 解析 + 渲染”的组合拳,而不是某一个具体的库或者框架。
为什么会有这个需求?做过内容归档的人都知道,很多平台的文章在网页上看着挺舒服,但你想把它保存成一份离线可读、图文完整、目录清晰的 PDF,其实非常折腾。直接浏览器打印吧,排版乱、广告多、图片加载不全;手动复制粘贴吧,几十篇下来手就废了。所以这套东西解决的痛点很明确:批量、稳定、结构化地把公开文章内容落地成本地 PDF。
适合谁来参考?三类人。第一类是做个人知识管理的,想把关注的内容沉淀下来;第二类是做数据分析的,需要先把文本拿到手才能做后续处理;第三类是想学采集与文档生成这套技术组合的开发者,拿这个当练手项目非常合适。基础要求不高,会一点 Python、能看懂 HTML 结构就行。
1.2 为什么选这套技术路线
在动手之前,我对比过几条路。第一条是纯浏览器自动化,用无头浏览器模拟真人操作,把页面渲染完再截图或者打印。第二条是直接请求接口拿数据,自己拼装。第三条是混合方案,请求拿结构化数据,再用模板渲染成 PDF。
纯浏览器自动化的问题是重。启动一个浏览器实例,内存占用大,跑几十篇还行,跑几百篇就容易崩,而且速度慢。直接请求接口的问题是,你得先搞清楚数据从哪来、参数怎么带,有些平台的数据是分页加载的,你得自己处理翻页逻辑。混合方案是我最后选的,理由很简单:采集阶段用轻量请求,渲染阶段用成熟模板,各司其职,稳定性和速度都能兼顾。
这里有个关键判断:标题里说的“突破反爬”,我的理解不是去对抗什么,而是让自己的请求行为看起来像一个正常的、有礼貌的访问者。具体来说就是控制频率、带上合理的请求头、处理好会话状态。这套思路在合规采集公开数据时是通用的,也是我一直推荐的做法。
1.3 整体架构长什么样
整个流程我拆成四层,从上到下依次是:
- 调度层:负责管理待采集的链接列表,控制并发数和请求间隔。
- 采集层:发请求、拿响应、处理重试和异常。
- 解析层:从返回的 HTML 或 JSON 里提取标题、正文、图片、作者、时间等字段。
- 渲染层:把结构化数据套进 HTML 模板,再用渲染引擎转成 PDF。
这四层之间用简单的数据结构传递,比如字典或者 dataclass,耦合度低,哪一层出问题都好排查。我试过把解析和渲染揉在一起,结果改一个字段要动好几处,后来拆开之后就清爽多了。
提示:分层不是为了显得专业,而是为了出问题时你能快速定位是哪一层的锅。采集失败和渲染失败,排查方向完全不一样。
2. 核心细节解析与实操要点
2.1 请求头与会话管理的关键细节
很多人采集失败,第一步就错在请求头太“假”。默认的 requests 请求头里 User-Agent 往往带着 python-requests 的字样,平台一看就知道不是正常浏览器。我的做法是准备一组常见的浏览器 UA,随机轮换,同时补齐 Accept、Accept-Language、Referer 这几个字段。
会话管理这块,用 requests.Session() 是基本操作。它的好处是自动保持 Cookie,减少重复握手。但要注意,Session 不是万能的,如果平台对同一会话的请求频率有限制,你反而应该定期换会话。我的经验是:单会话请求间隔控制在 1 到 3 秒,连续请求 20 次左右换一次会话,这个节奏实测下来比较稳。
import requests import random import time UA_POOL = [ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ...", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 ...", # 这里放多个常见浏览器 UA ] def build_session(): s = requests.Session() s.headers.update({ "User-Agent": random.choice(UA_POOL), "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", }) return s这段代码看着简单,但有几个坑。第一,UA 不要用太老的版本,容易被识别;第二,Accept-Language 要和 UA 匹配,别整个英文 UA 配中文语言;第三,Referer 在翻页请求时最好带上上一页的地址,显得更自然。
2.2 正文提取的三种策略与取舍
拿到 HTML 之后,怎么把正文抠出来,这是最考验耐心的一步。我总结了三套策略,按优先级依次尝试。
第一套是结构化数据优先。很多平台会在页面里嵌入 JSON 格式的数据,比如window.__INITIAL_STATE__或者某个 script 标签里的 JSON。这种数据最干净,字段齐全,直接解析就行。判断方法很简单,打开开发者工具,搜一下文章标题,看它出现在哪个 script 里。
第二套是语义标签提取。如果找不到结构化数据,就找 article、main 这类语义标签,或者带有特定 class 名的容器。这一步的关键是别硬编码太具体的 class,因为平台改版很频繁。我的做法是准备一组候选选择器,逐个尝试,哪个能提取到足够长的文本就用哪个。
第三套是通用正文算法。前两套都失败时,用基于文本密度的算法兜底,比如把页面切成块,计算每块的文本长度和链接密度,选文本多、链接少的那块。这套算法不完美,但胜在通用。
| 策略 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 结构化数据 | 页面内嵌 JSON | 字段全、最稳定 | 依赖平台实现 |
| 语义标签 | 有规范 article 标签 | 实现简单 | 改版易失效 |
| 通用算法 | 前两者都失败 | 通用性强 | 准确率一般 |
注意:不要一上来就写通用算法,那是最后的手段。优先找结构化数据,能省掉大量调试时间。
2.3 图片处理的几个实操心得
文章里的图片如果不处理,生成的 PDF 要么缺图,要么全是裂图。我的处理流程是:解析阶段先把图片 URL 收集起来,渲染前统一下载到本地临时目录,然后把 HTML 里的图片地址替换成本地路径。
这里有几个细节值得说。第一,图片要设置超时和重试,有些图床响应慢,不设超时会把整个流程卡死。第二,图片格式要统一处理,webp 在某些渲染引擎里支持不好,最好转成 png 或 jpg。第三,图片宽度要限制,太宽的图会把 PDF 排版撑爆,我一般限制在 800px 以内。
from PIL import Image import os def normalize_image(src_path, dst_path, max_width=800): img = Image.open(src_path) if img.width > max_width: ratio = max_width / img.width img = img.resize((max_width, int(img.height * ratio))) if img.mode in ("RGBA", "P"): img = img.convert("RGB") img.save(dst_path, "JPEG", quality=85)这段代码里,RGBA 转 RGB 那步很关键。带透明通道的 png 直接存成 jpg 会报错,必须先转模式。我一开始没注意,跑了一半报错,排查了半天才发现是图片模式的问题。
3. 实操过程与核心环节实现
3.1 环境准备与依赖安装
先把环境搭起来。我用的是 Python 3.10,太老的版本有些库不兼容。依赖清单如下:
pip install requests beautifulsoup4 lxml pillow jinja2 weasyprint逐个说下用途。requests 负责发请求,beautifulsoup4 配 lxml 做 HTML 解析,pillow 处理图片,jinja2 做 HTML 模板渲染,weasyprint 负责把 HTML 转成 PDF。
weasyprint 这个库我要多提一句。它基于 CSS 排版,对中文支持不错,但安装时依赖一些系统库,在部分系统上需要额外装字体和图形库。如果装不上,备选方案是 pdfkit,它依赖 wkhtmltopdf 这个外部程序,配置稍微麻烦点,但兼容性好。我两个都用过,weasyprint 的 CSS 支持更现代,推荐优先试它。
3.2 采集环节的完整实现
采集这块我写了一个函数,输入是文章 URL 列表,输出是结构化数据列表。核心逻辑是遍历 URL,每个请求之间 sleep 一个随机间隔,失败就重试,重试超过三次就记录到失败列表。
import time import random from dataclasses import dataclass, field @dataclass class Article: url: str title: str = "" author: str = "" publish_time: str = "" content_html: str = "" images: list = field(default_factory=list) def fetch_articles(urls, session, interval=(1.5, 3.0), max_retry=3): articles = [] failed = [] for idx, url in enumerate(urls): for attempt in range(max_retry): try: resp = session.get(url, timeout=10) resp.raise_for_status() article = parse_article(resp.text, url) articles.append(article) break except Exception as e: if attempt == max_retry - 1: failed.append((url, str(e))) else: time.sleep(2 ** attempt) time.sleep(random.uniform(*interval)) if (idx + 1) % 10 == 0: print(f"已处理 {idx + 1}/{len(urls)}") return articles, failed这段代码有几个设计点。重试用了指数退避,第一次等 1 秒,第二次 2 秒,第三次 4 秒,避免短时间内反复冲击。间隔用随机数而不是固定值,是为了让请求节奏不那么机械。每处理 10 篇打印一次进度,长任务时心里有底。
3.3 解析环节的字段映射
解析函数要做的事情,是把 HTML 里的信息映射到 Article 对象。我一般先尝试从结构化数据里取,取不到再走 DOM 解析。
import json import re from bs4 import BeautifulSoup def parse_article(html, url): article = Article(url=url) soup = BeautifulSoup(html, "lxml") # 策略一:找内嵌 JSON for script in soup.find_all("script"): if script.string and "window.__INITIAL_STATE__" in script.string: match = re.search(r"window\.__INITIAL_STATE__\s*=\s*(\{.*?\});", script.string, re.S) if match: data = json.loads(match.group(1)) # 根据实际结构取字段 article.title = data.get("title", "") article.author = data.get("author", "") article.content_html = data.get("content", "") return article # 策略二:DOM 解析 title_node = soup.find("h1") or soup.find("title") article.title = title_node.get_text(strip=True) if title_node else "" content_node = soup.find("article") or soup.find("div", class_=re.compile("content|article")) if content_node: article.content_html = str(content_node) return article这里要提醒的是,正则匹配 JSON 那段,.*?配re.S在数据量大时可能匹配不全,更稳妥的做法是找到 script 标签后,用括号计数的方式截取完整的 JSON 对象。我一开始图省事用正则,结果遇到嵌套深的 JSON 就截断了,后来改成了计数法。
3.4 PDF 渲染的模板设计
渲染这块,我用 jinja2 写了一个 HTML 模板,把文章数据填进去,再用 weasyprint 转 PDF。模板里要处理标题、作者、时间、正文、图片,还要加页眉页脚和页码。
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <style> @page { size: A4; margin: 2cm; @bottom-center { content: counter(page); } } body { font-family: "Noto Sans CJK SC", sans-serif; line-height: 1.8; } h1 { font-size: 22px; border-bottom: 2px solid #333; padding-bottom: 8px; } .meta { color: #666; font-size: 13px; margin-bottom: 20px; } img { max-width: 100%; height: auto; } </style> </head> <body> <h1>{{ article.title }}</h1> <div class="meta">{{ article.author }} · {{ article.publish_time }}</div> <div class="content">{{ article.content_html | safe }}</div> </body> </html>模板里| safe这个过滤器很重要,不加的话 HTML 会被转义成纯文本,正文就变成一堆标签了。但加了 safe 也要注意,如果内容来源不可控,存在注入风险,所以采集来的 HTML 最好先做一次清洗,去掉 script、iframe 这类标签。
from weasyprint import HTML def render_pdf(article, template, output_path): html_str = template.render(article=article) HTML(string=html_str, base_url=".").write_pdf(output_path)base_url 这个参数别漏,它决定了 HTML 里相对路径的解析基准。图片如果用的是本地相对路径,不设 base_url 就会找不到。
4. 常见问题与排查技巧实录
4.1 采集阶段的高频问题
采集阶段最容易遇到的就是请求被拒。表现是返回 403、429,或者返回一个验证页面。遇到这种情况,先别急着加代理或者换 IP,先检查三件事:请求头是否完整、请求频率是否过高、Cookie 是否有效。
我整理了一个排查表,按现象对原因:
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 403 Forbidden | 请求头缺失或被识别 | 补全 UA、Referer |
| 429 Too Many Requests | 频率过高 | 加大间隔、降低并发 |
| 返回验证页 | 触发风控 | 降频、换会话 |
| 返回空内容 | 数据异步加载 | 找接口或渲染页面 |
| 连接超时 | 网络或图床问题 | 设超时、加重试 |
这里重点说下“返回空内容”。很多人以为是被封了,其实是页面数据通过 JS 异步加载的,你拿到的初始 HTML 里根本没有正文。判断方法很简单:把返回的 HTML 存下来,搜一下文章标题,搜不到就说明是异步加载。解决办法是找它请求的那个接口,直接请求接口拿数据。
4.2 解析阶段的踩坑记录
解析阶段最烦的是选择器失效。平台一改版,你写的div.article-content可能就找不到了。我的应对策略是多选择器兜底 + 日志记录。每次解析时,把命中的选择器记下来,如果某个选择器连续多次没命中,就说明该更新了。
还有一个坑是编码问题。有些页面声明是 UTF-8,实际是 GBK,直接解析会乱码。稳妥的做法是用resp.apparent_encoding让 requests 自己猜,或者手动检测。我遇到过一次,标题全是问号,查了半天才发现是编码没处理对。
resp = session.get(url) resp.encoding = resp.apparent_encoding html = resp.text这行代码加上之后,乱码问题基本就没了。apparent_encoding 底层用的是 chardet,准确率挺高。
4.3 渲染阶段的疑难杂症
渲染阶段最常见的问题是中文显示成方块。原因是系统里没有对应的中文字体。解决办法是装一个中文字体,比如 Noto Sans CJK,然后在 CSS 里指定 font-family。Linux 上可以用fc-list :lang=zh看看装了哪些中文字体。
第二个问题是图片不显示。排查顺序是:图片路径对不对、base_url 设没设、图片文件存不存在。我遇到过一次,图片下载时文件名带了特殊字符,保存失败,但代码没报错,结果 PDF 里全是裂图。后来在保存图片时加了文件名清洗,把特殊字符替换掉就好了。
第三个问题是分页难看。长表格或者大图经常被拦腰截断。CSS 里可以用page-break-inside: avoid让某个元素尽量不跨页,但要注意这个属性不是万能的,太长的内容还是会断。我的做法是把大图单独放一页,用page-break-before: always强制分页。
提示:渲染出来的 PDF 一定要人工抽查几份,尤其是图文混排的页面。自动化测试很难覆盖排版问题,肉眼过一遍最靠谱。
4.4 性能优化的几个实用技巧
跑大批量任务时,性能是个绕不开的话题。我总结了几个立竿见影的优化点。
第一,图片下载并发化。图片下载是 IO 密集型任务,用线程池并发下载,速度能提升好几倍。但并发数别开太大,8 到 16 个线程比较合适,太多了反而会因为带宽争抢变慢。
第二,解析结果缓存。同一篇文章如果重复处理,没必要重新解析。我用 URL 的哈希值做 key,把解析结果存到本地,下次直接读缓存。
第三,PDF 渲染批量化。weasyprint 每次渲染都有启动开销,如果文章多,可以考虑合并成一个 HTML 再渲染,或者用多进程并行渲染。我用的是多进程,每个进程负责一批文章,充分利用多核。
from concurrent.futures import ThreadPoolExecutor def download_images(image_urls, save_dir, max_workers=8): with ThreadPoolExecutor(max_workers=max_workers) as executor: futures = {executor.submit(download_one, url, save_dir): url for url in image_urls} results = {} for future in futures: url = futures[future] try: results[url] = future.result() except Exception as e: results[url] = None return results这段并发下载的代码,关键是异常要捕获住,单张图失败不能影响整体。返回的 results 里,失败的图片对应 None,后续渲染时跳过或者用占位图代替。
5. 合规采集与长期维护建议
5.1 采集行为的边界在哪里
做这类项目,有一条线必须心里有数:只采集公开的、允许访问的内容,控制频率不影响对方服务。具体来说,我给自己定了几个规矩。第一,只抓公开文章,不碰需要登录才能看的内容。第二,请求间隔不低于 1 秒,高峰期主动降频。第三,遵守平台的 robots 协议,人家明确不让抓的路径就不碰。第四,采集的数据只用于个人学习归档,不二次分发。
这几条不是束缚,而是让项目能长期跑下去的前提。你频率控制得好,对方服务器压力小,你的请求就不容易被拦,这是个双赢的事。我见过太多人一上来就开几十个并发猛冲,结果 IP 被封,项目直接报废。
5.2 代码结构的长期维护
这类项目最大的敌人是平台改版。为了让维护成本低,我把所有和平台相关的配置都抽到一个单独的配置文件里,包括选择器、接口地址、字段映射。平台一改版,我只改配置文件,不动主逻辑。
# config.py PLATFORM_CONFIG = { "article_selector": ["article", "div.content", "div.article-body"], "title_selector": ["h1", "h1.title"], "author_selector": ["span.author", "a.author-name"], "image_attr": "data-src", # 有些平台图片懒加载,真实地址在>