Python采集与PDF生成实战:从公开文章到结构化归档
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/xhtmlxml,application/xml;q0.9,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.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_width800): 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, quality85)这段代码里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_factorylist) def fetch_articles(urls, session, interval(1.5, 3.0), max_retry3): articles [] failed [] for idx, url in enumerate(urls): for attempt in range(max_retry): try: resp session.get(url, timeout10) 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(urlurl) 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(rwindow\.__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(stripTrue) 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 charsetutf-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 classmeta{{ article.author }} · {{ article.publish_time }}/div div classcontent{{ 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(articlearticle) HTML(stringhtml_str, base_url.).write_pdf(output_path)base_url 这个参数别漏它决定了 HTML 里相对路径的解析基准。图片如果用的是本地相对路径不设 base_url 就会找不到。4. 常见问题与排查技巧实录4.1 采集阶段的高频问题采集阶段最容易遇到的就是请求被拒。表现是返回 403、429或者返回一个验证页面。遇到这种情况先别急着加代理或者换 IP先检查三件事请求头是否完整、请求频率是否过高、Cookie 是否有效。我整理了一个排查表按现象对原因现象可能原因排查方向403 Forbidden请求头缺失或被识别补全 UA、Referer429 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 :langzh看看装了哪些中文字体。第二个问题是图片不显示。排查顺序是图片路径对不对、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_workers8): with ThreadPoolExecutor(max_workersmax_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, # 有些平台图片懒加载真实地址在>