资讯动态

公众号文章批量抓取与PDF生成:请求解析渲染导出全链路实践

发布时间:2026/10/20 14:39:06 来源:尧图企业网站定制
1. 这个项目到底在解决什么问题微信公众号里的内容做内容归档、行业研究或者竞品分析的朋友应该都有体会单篇文章在手机上翻翻没问题但一旦要批量保存、离线阅读、做全文检索麻烦就来了。平台本身不提供批量导出网页端打开文章又会遇到各种限制——未登录只能看摘要、图片带防盗链、代码块被折叠、评论区加载不出来。我这次要聊的就是围绕“把公众号文章完整抓下来并生成一份排版可读的 PDF”这件事从思路到落地的一次完整实践。先把话说在前面这篇文章讨论的是技术层面的数据采集与文档生成方案面向的是有合法授权、用于个人学习研究或已获内容方许可的场景。任何采集行为都应该遵守目标平台的服务条款、版权规定以及相关法律法规不要拿去干侵权或者恶意爬取的事。这一点是底线后面所有技术细节都建立在这个前提上。那这个项目具体能做什么简单说输入一批文章链接程序自动完成请求页面、解析正文、下载图片、处理样式、渲染成 HTML、最后转成一份带目录、带页码、图片内嵌的 PDF。适合谁来参考做数据分析需要沉淀素材的、做知识管理想离线归档的、以及想练手“请求—解析—渲染—导出”这条完整链路的开发者。哪怕你最后不用 PDF中间那套解析和资源本地化的思路也能直接搬到别的场景。我踩过的坑主要集中在三个地方一是页面结构经常变选择器写死就等着崩二是图片防盗链直接引用原链接在本地打开全是裂图三是中文字体和分页转 PDF 时经常出现文字重叠或者一页只放两行。下面我会把这三块拆开讲透。2. 整体方案设计与技术选型思路2.1 为什么选“请求 解析 本地渲染”这条链路市面上做网页转 PDF 的路子大致有三条一是直接用浏览器自带的打印功能人工操作量大了不现实二是用无头浏览器比如 Puppeteer、Playwright打开页面后直接page.pdf()三是自己发请求拿 HTML解析出正文重新组装页面再渲染。我最终选的是第三条为主、第二条为辅的混合方案。原因很实际无头浏览器虽然省事但它会把整个页面的脚本、广告、推荐位全都跑一遍速度慢、资源占用高而且很多动态内容会干扰正文提取。自己发请求拿到的 HTML 是“干净”的正文结构清晰解析起来可控性强。只有在遇到纯前端渲染、正文靠 JS 注入的页面时才回退到无头浏览器兜底。这里有个关键判断先探测页面是不是服务端直出。做法很简单用curl或者任意 HTTP 客户端请求一次看返回的 HTML 里有没有正文文本。如果有走解析路线如果没有再上无头浏览器。这个判断能帮你省掉 80% 不必要的浏览器开销。2.2 请求层的关键请求头与访问节奏请求层是整个项目的地基。很多人一上来就requests.get(url)然后发现返回的是验证页或者空内容就开始怀疑人生。问题基本都出在请求头太“裸”了。一个像样的请求头至少要包含这几项请求头字段作用建议值User-Agent标识客户端类型用主流桌面浏览器的 UAReferer标识来源页设为文章所在域名Accept声明可接受的内容类型text/html,application/xhtmlxmlAccept-Language声明语言偏好zh-CN,zh;q0.9Cookie维持会话状态从已登录会话中获取其中 Cookie 是最容易被忽略又最关键的。很多文章页在未登录状态下只返回部分内容登录后才能拿到完整正文和评论。获取 Cookie 的合规做法是在你自己的浏览器里正常登录从开发者工具里复制当前会话的 Cookie用于你自己的采集任务。注意 Cookie 有时效性过期了要重新获取。访问节奏同样重要。我一般会设置请求间隔比如每篇之间随机 sleep 1 到 3 秒并且限制并发数。这不是为了“绕过”什么而是基本的礼貌——高频请求会给对方服务器造成压力也容易触发风控导致自己的任务中断。把节奏放慢反而整体成功率更高。import requests import time import random HEADERS { 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, Accept: text/html,application/xhtmlxml,application/xml;q0.9, Accept-Language: zh-CN,zh;q0.9, } def fetch(url, cookie): headers dict(HEADERS) if cookie: headers[Cookie] cookie resp requests.get(url, headersheaders, timeout15) resp.raise_for_status() resp.encoding resp.apparent_encoding time.sleep(random.uniform(1, 3)) return resp.text这段代码没什么花哨的但resp.apparent_encoding这一行很关键。公众号文章里中文很多如果编码判断错了抓下来就是一堆乱码。用apparent_encoding让库自己去猜比硬编码utf-8稳得多。2.3 解析层正文提取的取舍解析层的核心目标只有一个从一堆 DOM 里精准捞出正文容器。公众号文章的正文通常包在一个 id 类似js_content的 div 里但不同模板、不同时期的结构会有差异所以不能只靠一个选择器。我的做法是“多选择器 打分”策略准备一组候选选择器逐个尝试谁命中的文本长度最长、包含的段落数最多就选谁。这样即使主选择器失效也能靠备选兜底。from bs4 import BeautifulSoup CANDIDATES [#js_content, .rich_media_content, article, .article-content] def extract_content(html): soup BeautifulSoup(html, html.parser) best, best_score None, 0 for sel in CANDIDATES: node soup.select_one(sel) if not node: continue text_len len(node.get_text(stripTrue)) p_count len(node.find_all(p)) score text_len p_count * 20 if score best_score: best, best_score node, score return best打分公式里给段落数乘了个权重是因为有些容器虽然文本多但可能把导航、推荐位也算进去了段落结构反而更能反映“这是不是正文”。这个权重是我调了几次之后定下来的你可以根据自己的样本微调。2.4 渲染与导出为什么用 HTML 中转拿到正文节点后不要急着直接往 PDF 库塞。中间加一层 HTML 重组好处非常多可以统一字体、统一行距、把图片路径替换成本地路径、插入目录锚点。等 HTML 在浏览器里预览没问题了再转 PDF问题定位会容易很多。转 PDF 我推荐用无头浏览器Playwright 或 Puppeteer的pdf()方法而不是纯 Python 的 PDF 库。原因很直接浏览器对 CSS 的支持最完整分页、字体、图片这些老大难问题交给浏览器引擎处理最省心。纯 Python 库在遇到复杂排版时经常出现文字重叠、图片错位。3. 核心细节解析与实操要点3.1 图片防盗链的处理这是整个项目里最容易翻车的一环。公众号的图片通常放在 CDN 上直接引用原链接在浏览器里能显示是因为浏览器带了 Referer。但你把 HTML 存到本地再打开Referer 没了图片就全裂了。解决办法是把图片下载到本地然后替换 HTML 里的 src。下载时同样要带上 Referer 请求头否则 CDN 会拒绝。import os from urllib.parse import urlparse def download_images(soup, base_dir, referer): os.makedirs(base_dir, exist_okTrue) for i, img in enumerate(soup.find_all(img)): src img.get(data-src) or img.get(src) if not src or not src.startswith(http): continue try: r requests.get(src, headers{**HEADERS, Referer: referer}, timeout15) ext os.path.splitext(urlparse(src).path)[1] or .jpg fname fimg_{i:03d}{ext} fpath os.path.join(base_dir, fname) with open(fpath, wb) as f: f.write(r.content) img[src] os.path.relpath(fpath, base_dir) if img.has_attr(data-src): del img[data-src] except Exception as e: print(f图片下载失败 {src}: {e})注意>body { font-family: Noto Sans CJK SC, Source Han Sans SC, PingFang SC, Microsoft YaHei, sans-serif; font-size: 16px; line-height: 1.8; color: #333; max-width: 800px; margin: 0 auto; padding: 24px; } img { max-width: 100%; height: auto; display: block; margin: 16px auto; } p { margin: 12px 0; }max-width: 100%这行是防止大图撑破页面。公众号里经常有宽图不限制宽度的话转 PDF 时会横向溢出右边被裁掉。3.3 分页与目录的生成一份“完整”的 PDF光有内容还不够得有目录和页码不然几十页翻起来很痛苦。目录的生成思路是解析正文里的各级标题h1 到 h3给每个标题加一个 id 锚点然后在文档开头生成一个链接列表。def build_toc(soup): toc [] for idx, h in enumerate(soup.find_all([h1, h2, h3])): anchor fsec-{idx} h[id] anchor toc.append({level: int(h.name[1]), text: h.get_text(stripTrue), anchor: anchor}) return toc分页则交给浏览器的pdf()方法通过 CSS 的page-break控制。比如让每个一级标题另起一页h1 { page-break-before: always; } h1:first-of-type { page-break-before: avoid; }first-of-type那行是为了避免文档第一页出现空白页——第一个标题如果强制分页前面会多出一张白纸。这个细节不注意的话生成的 PDF 开头总是空的很影响观感。3.4 无头浏览器导出的参数配置用 Playwright 导出 PDF 时有几个参数值得细说参数含义建议值format纸张尺寸A4print_background是否打印背景色Truemargin页边距上下 20mm左右 15mmdisplay_header_footer是否显示页眉页脚视需求prefer_css_page_size是否优先用 CSS 尺寸Falseprint_backgroundTrue一定要开否则代码块、引用块的背景色全没了看起来光秃秃的。页边距别设太小装订或者打印时会切到内容。from playwright.sync_api import sync_playwright def html_to_pdf(html_path, pdf_path): with sync_playwright() as p: browser p.chromium.launch() page browser.new_page() page.goto(ffile://{os.path.abspath(html_path)}) page.pdf( pathpdf_path, formatA4, print_backgroundTrue, margin{top: 20mm, bottom: 20mm, left: 15mm, right: 15mm}, ) browser.close()page.goto用的是file://协议所以图片路径必须是相对路径或者绝对文件路径不能是 http 链接。这也是为什么前面要把图片下载到本地。4. 完整实操流程与关键环节实现4.1 环境准备与依赖安装先把环境搭起来。Python 3.9 以上都行依赖不多pip install requests beautifulsoup4 lxml playwright playwright install chromiumlxml是解析器比默认的html.parser快不少容错性也更好。playwright install chromium会下载一个 Chromium 内核第一次装会有点慢耐心等。目录结构我习惯这样组织project/ ├── main.py ├── output/ │ ├── article_001/ │ │ ├── index.html │ │ ├── images/ │ │ └── article.pdf每篇文章一个独立目录图片放子目录HTML 和 PDF 放同级。这样批量处理时不会互相干扰出问题也好定位。4.2 单篇文章的完整处理流程把前面的模块串起来单篇处理的流程是这样的读取文章链接和 Cookie发请求拿 HTML解析出正文节点下载图片并替换路径注入统一样式生成目录锚点写出本地 HTML用无头浏览器转 PDFdef process_article(url, cookie, out_root, index): out_dir os.path.join(out_root, farticle_{index:03d}) os.makedirs(out_dir, exist_okTrue) html fetch(url, cookie) soup BeautifulSoup(html, html.parser) content extract_content(soup) if content is None: print(f正文提取失败: {url}) return img_dir os.path.join(out_dir, images) download_images(content, img_dir, refererurl) toc build_toc(content) final_html wrap_html(content, toc) html_path os.path.join(out_dir, index.html) with open(html_path, w, encodingutf-8) as f: f.write(final_html) pdf_path os.path.join(out_dir, article.pdf) html_to_pdf(html_path, pdf_path) print(f完成: {pdf_path})wrap_html负责把正文、目录、样式拼成一个完整的 HTML 文档。这里有个小技巧目录用a href#sec-0这种锚点链接在浏览器里点击能跳转转成 PDF 后这些链接依然有效阅读体验会好很多。4.3 批量处理与断点续传单篇跑通之后批量就是加个循环。但批量场景下有两个问题必须处理一是某篇失败不能影响整体二是任务中断后要能续传。我的做法是维护一个进度文件记录哪些链接已经处理成功。每次启动先读进度跳过已完成的。import json def load_progress(path): if os.path.exists(path): with open(path, r, encodingutf-8) as f: return set(json.load(f)) return set() def save_progress(path, done): with open(path, w, encodingutf-8) as f: json.dump(sorted(done), f, ensure_asciiFalse, indent2)每成功一篇就往done里加一条并落盘。这样即使跑到一半程序崩了重启后也能从断点继续不用从头再来。这个习惯在处理几百篇的时候能救命。4.4 参数计算请求间隔与并发怎么定很多人纠结并发数设多少。我的经验是先单线程跑通再考虑并发。如果确实要提速并发数控制在 3 到 5 之间配合每请求 1 到 3 秒的随机间隔。算一笔账假设单篇处理耗时 5 秒含请求、图片下载、PDF 渲染100 篇单线程大约 8 分钟。如果并发 4理论上能压到 2 分多钟但实际因为 PDF 渲染是 CPU 密集的提升没那么明显而且并发太高容易触发风控。所以我的建议是请求和解析可以适度并发PDF 渲染串行化整体收益和稳定性最平衡。注意不要为了追求速度把间隔设成 0。高频请求不仅不礼貌还会让你的 IP 被临时限制反而拖慢整体进度。慢就是快这话在采集场景里特别成立。5. 常见问题与排查技巧实录5.1 抓到的内容是空的或者只有一半这是最高频的问题。排查顺序建议这样走现象可能原因排查方法返回空 HTML请求头缺失或 Cookie 过期打印响应状态码和长度有 HTML 但无正文正文靠 JS 渲染搜索 HTML 里有无正文关键词正文只有开头未登录被截断检查 Cookie 是否有效中文乱码编码判断错误用 apparent_encoding如果确认是 JS 渲染就切到无头浏览器方案用 Playwright 打开页面等networkidle之后再取page.content()。这一步能解决绝大多数“抓不到”的问题。5.2 图片下载失败或显示为占位图前面提过>ct r.headers.get(Content-Type, ) if image not in ct: print(f非图片响应跳过: {src}) continue5.3 PDF 里中文变成方框这是字体问题。无头浏览器所在的系统如果没有装中文字体渲染时就会用默认字体遇到中文就画方框。解决办法有两个一是系统层面装中文字体Linux 上装fonts-noto-cjk二是在 CSS 里指定一个确定存在的字体。我一般两个都做双保险。5.4 PDF 分页错乱、内容被截断分页问题通常出在图片和代码块上。一张大图刚好卡在页边界就会被切成两半。解决办法是给图片和代码块加page-break-inside: avoidimg, pre, blockquote { page-break-inside: avoid; }这样浏览器会尽量把整个元素挪到下一页而不是从中间切开。代价是可能留下一些空白但可读性比截断强太多。5.5 我的独家避坑清单先存 HTML 再转 PDF不要一步到位直接生成 PDF。中间产物 HTML 是你排查问题的唯一线索出了问题能直接在浏览器里看。给每篇文章加超时请求、图片下载、PDF 渲染都要设超时否则一篇卡住会拖死整个批量任务。日志要带链接出错时打印具体是哪篇文章、哪张图不然几百篇里找问题等于大海捞针。定期清理临时文件图片和中间 HTML 很占空间确认 PDF 没问题后可以只保留 PDF。结构变化要能感知定期抽查几篇看看解析结果是否正常。页面结构变了选择器可能悄悄失效但程序不会报错只会默默产出空内容。6. 关于合规与边界的几点个人体会技术本身是中性的但用在哪里、怎么用决定了它是工具还是麻烦。我在做这类项目时给自己定了几个规矩也建议你参考。第一只处理自己有权处理的内容。自己写的、已获授权的、明确允许转载的这些没问题。别人的原创内容抓下来自己看看可以但不要二次分发或者商用。第二控制请求频率尊重对方服务器。采集不是攻击慢一点、少一点既是对别人的尊重也是对自己任务的保护。第三数据存好、别乱传。抓下来的内容里可能包含个人信息本地保存、用完即删别随手往公开的地方扔。第四平台规则变了就跟着调整。服务条款不是摆设如果明确禁止采集那就别硬来换个思路比如手动导出或者找官方接口。说到底这套“请求—解析—本地化—渲染—导出”的链路价值不只在抓公众号。你把它抽象出来换成任何一个内容站点改改选择器和请求头就能复用。我后来拿它做过技术博客的离线归档、做过产品文档的批量导出甚至做过自己笔记的 PDF 备份核心逻辑一模一样。真正值得沉淀的是这套处理问题的思路而不是某一段具体的代码。最后分享一个我最近才想明白的小技巧与其花时间研究怎么“对抗”页面变化不如把解析层做得足够“宽容”。多选择器打分、失败降级、异常捕获这些看起来笨的办法长期来看比任何精巧的绕过手段都更耐用。页面天天变但“正文是文本最多的那块区域”这个朴素判断一直没怎么变过。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑