
简介这是一套面向Python爬虫进阶学习者的微信公众号文章采集系统源码针对传统方案文章抓取不全、账号易被封等痛点通过抓包获取微信历史文章分页接口并借助ADB与模拟器自动化操作微信客户端模拟搜索公众号、阅读文章等真实用户行为。项目支持多微信账号切换与多公众号并行爬取采集结果可持久化存入MongoDB同时引入动态代理IP降低封禁风险整体采用任务生成、代理拦截、爬虫执行等模块化设计便于二次扩展与维护。资源包共52个文件以31个py源码为主体辅以6个js拦截脚本、若干png/jpg流程与架构示意图以及txt、json、conf等配置说明文件压缩包约568KB结构清晰。目前已有226人学习下载适合希望掌握微信生态数据采集、Scrapy与自动化控制结合的开发者参考可从中获取接口抓取、代理中间件、OCR识别与ADB控制等完整实现思路。1. 微信公众号爬虫系统从标题到能跑起来的那条最短路径很多人第一次搜「微信公众号爬虫系统」脑子里想的是一套能自动翻遍所有公众号、把文章正文和阅读量全抓下来的工具。真动手才发现公众号没有公开的网页列表页文章链接散落在各种分享、历史消息和搜一搜结果里直接 requests 一个 URL 拿到的往往是「请在微信客户端打开」的提示页。这个标题背后真正要解决的问题其实是三件事怎么拿到文章链接、怎么把正文从微信的 HTML 里干净地抠出来、怎么把结果落成结构化数据方便后续检索。适合谁做内容聚合、做行业情报、做个人知识库的 Python 开发者尤其是已经会写 requests 和 XPath、但被微信这套反爬和动态渲染卡住的人。这篇不吹能抓全量只讲一条能复现、能长期维护的路径以及那些让我翻过车的参数和边界。2. 先搞清楚公众号文章到底能从哪几个口子拿数据2.1 三个真实可用的入口和各自的代价公众号文章不是没有入口是没有「一个入口拿全部」。常见做法有三条路选哪条取决于你要的是广度还是深度。第一条是搜狗微信搜索。它提供公众号和文章的搜索页能按关键词翻页。优点是无需登录、结构相对稳定缺点是结果有截断翻到十几页后基本重复且正文需要跳转到微信域名再解析。适合做关键词监控不适合做全量归档。第二条是微信公众平台后台的「图文素材」接口。如果你自己有公众号登录后台后可以调用素材管理相关接口拿到自己账号的历史文章列表和正文。这条路最稳但只能拿自己的号做不了别人的号。很多人忽略它其实做竞品分析时用几个小号互相关注再配合后台导出比硬爬省事得多。第三条是客户端抓包。手机或 PC 微信打开文章时会请求mp.weixin.qq.com下的文章页链接里带__biz、mid、idx、sn四个关键参数。抓到一次请求就能拿到一篇完整正文。这条路能拿到最全的正文和部分元数据代价是需要处理登录态和频率控制。我一般会组合用搜狗负责发现新文章链接抓包或直接请求负责拿正文后台接口负责校验自己账号的数据。三条路互补不指望单点。2.2 文章 URL 的四个参数决定了你能不能复现一篇公众号文章的典型链接长这样https://mp.weixin.qq.com/s?__bizMzA5NzQxNTg0Ngmid2650000001idx1snabc123def456chksm...__biz是公众号的唯一标识同一个号所有文章都一样mid是消息 ID一次群发对应一个 mididx是这次群发里的第几篇从 1 开始sn是这篇的校验串。这四个参数里__biz和mid组合基本能定位一次群发idx区分多图文。做去重时用__biz mid idx做唯一键最可靠别用标题标题会改。提示sn有时会变尤其是文章被重新编辑后。去重逻辑不要把它算进去。2.3 正文不在初始 HTML 里别对着空 div 发呆直接 requests 拿到的页面正文部分经常是一个空的div idjs_content内容由后面的 JS 填充。这是新手最容易翻车的地方——用 XPath 取//div[idjs_content]//text()拿到空列表还以为选择器写错了。实际有两种处理方式。一是找页面里内嵌的window.__QMTPL_SSR_DATA__或类似变量里面往往有渲染前的数据二是用带 JS 渲染的浏览器内核去取。前者轻量但依赖页面结构后者重但通用。我一般先试前者拿不到再上后者。3. 用 Python 把一篇文章从链接抓到结构化 JSON3.1 最小可运行脚本requests lxml 抓正文先装依赖Python 3.8 及以上都行pip install requests lxml下面这段是抓单篇文章的最小实现重点看它怎么处理「正文为空」和编码问题import requests from lxml import etree HEADERS { User-Agent: ( Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36 ), Accept-Language: zh-CN,zh;q0.9, } def fetch_article(url: str) - dict: resp requests.get(url, headersHEADERS, timeout15) resp.encoding utf-8 # 微信页面基本是 utf-8别让它猜 html resp.text tree etree.HTML(html) # 标题优先 og:title其次页面 title title tree.xpath(//meta[propertyog:title]/content) if not title: title tree.xpath(//h1[idactivity-name]/text()) title title[0].strip() if title else # 正文js_content 里的所有文本节点 content_nodes tree.xpath(//div[idjs_content]//text()) content \n.join(t.strip() for t in content_nodes if t.strip()) # 作者和发布时间 author tree.xpath(//a[idjs_name]/text()) author author[0].strip() if author else return { url: url, title: title, author: author, content: content, content_len: len(content), } if __name__ __main__: data fetch_article(https://mp.weixin.qq.com/s?__biz...mid...idx1sn...) print(data[title], data[content_len])逻辑说明resp.encoding utf-8这行很关键requests 有时会把微信页面猜成 ISO-8859-1导致中文全变乱码。XPath 用//text()会把嵌套标签里的文本都取出来比逐个标签取省事。content_len用来快速判断是否抓空——如果长度小于 50基本就是没渲染出来。参数说明timeout15别省微信偶尔会挂住连接HEADERS里User-Agent必须像浏览器否则可能直接返回验证页。Accept-Language加上中文部分页面会因此返回完整版。3.2 正文抓空时的兜底从内嵌数据里取如果上面content_len接近 0试试从页面脚本里找内嵌 JSONimport re, json def extract_from_ssr(html: str) - str: # 匹配 window.__QMTPL_SSR_DATA__ {...}; m re.search(rwindow\.__QMTPL_SSR_DATA__\s*\s*(\{.*?\});, html, re.S) if not m: return try: data json.loads(m.group(1)) except json.JSONDecodeError: return # 不同版本字段名可能不同常见是 content 或 content_noencode for key in (content, content_noencode): if key in data: return data[key] return 这段是兜底不是主路径。字段名会随微信前端版本变所以用for key in (...)试探而不是写死。拿到的是带 HTML 标签的字符串后续可以用 lxml 再清洗一遍。3.3 把结果落成 JSONL方便后续检索单篇抓完就存别攒在内存里。JSONL 每行一个 JSON追加写断了也不丢import json, os def save_jsonl(record: dict, path: str articles.jsonl): with open(path, a, encodingutf-8) as f: f.write(json.dumps(record, ensure_asciiFalse) \n)ensure_asciiFalse让中文原样存别存成\uXXXX否则后面做全文检索时还得转回来。文件按天切分比如articles_20240101.jsonl单文件超过 50MB 就换方便用 grep 和 pandas 直接读。4. 批量抓取时的调度、去重和频率控制4.1 用队列 协程把吞吐拉起来但别把号打死单线程抓一百篇要几分钟用asyncioaiohttp能压到几十秒。但微信对同一 IP 的请求频率敏感压太狠会触发验证。我的经验值是并发 35、每请求间隔 0.51 秒配合失败重试。import asyncio, aiohttp, random async def fetch_one(session, url, sem): async with sem: try: async with session.get(url, timeout15) as resp: html await resp.text() await asyncio.sleep(random.uniform(0.5, 1.0)) return url, html except Exception as e: return url, fERROR: {e} async def fetch_batch(urls): sem asyncio.Semaphore(4) # 并发上限 async with aiohttp.ClientSession(headersHEADERS) as session: tasks [fetch_one(session, u, sem) for u in urls] return await asyncio.gather(*tasks)Semaphore(4)是硬上限别改成 20那是给自己找验证码。random.uniform让间隔不固定比固定 sleep 更不容易被识别成脚本。失败返回错误字符串而不是抛异常方便批量处理时统计哪些 URL 需要重试。4.2 去重表怎么设计别用标题当主键去重我用 SQLite轻量、单文件、支持索引。建表时把__biz mid idx做成唯一索引CREATE TABLE IF NOT EXISTS articles ( id INTEGER PRIMARY KEY AUTOINCREMENT, biz TEXT NOT NULL, mid TEXT NOT NULL, idx INTEGER NOT NULL, title TEXT, author TEXT, url TEXT, content TEXT, fetched_at TEXT DEFAULT (datetime(now)), UNIQUE(biz, mid, idx) );插入时用INSERT OR IGNORE重复的直接跳过不用先查再插。fetched_at留着后面想按时间增量更新时有用。正文存 TEXT 没问题SQLite 单库到几个 GB 都扛得住真到瓶颈再换 PostgreSQL。4.3 增量更新只抓没见过的 mid公众号每天更新全量重抓浪费。维护一个「已抓 mid 集合」新链接进来先查这个集合不在才抓。集合可以放内存启动时从 SQLite 读一次也可以直接查库。我一般启动时SELECT DISTINCT biz, mid FROM articles读进 set运行中新增的同步加进去。这样一天跑一次只处理新文章几分钟完事。5. 避坑与排查那些让我重抓过三次的坑5.1 现象正文抓下来是「环境异常请完成验证」原因请求频率过高或 IP 被标记。微信对mp.weixin.qq.com的访问有风控短时间大量请求会返回验证页而不是文章页。解决把并发降到 23间隔拉到 12 秒如果已经触发停一段时间再跑别硬刚。长期方案是分散请求时间比如把一批 URL 分到几个小时里跑完而不是集中一分钟。5.2 现象标题和正文对不上抓 A 文章存成 B 的内容原因异步任务里共用了同一个 session 或变量返回顺序和请求顺序不一致写文件时错位。解决每个任务返回(url, html)元组写库时以返回的 url 为准不要依赖任务列表的索引。或者干脆每个任务内部完成解析和入库不把 html 传出来。5.3 现象中文乱码正文里全是「æ–‡ç« 」原因requests 自动推断编码错误把 utf-8 当成了 latin-1。解决拿到 response 后立刻resp.encoding utf-8不要用resp.apparent_encoding那个在微信页面上经常猜错。aiohttp 默认按 header 里的 charset 解一般没问题但保险起见可以await resp.text(encodingutf-8)。5.4 现象XPath 取正文返回空列表但页面明明有内容原因正文由 JS 渲染初始 HTML 里js_content是空的或者页面结构改版id 变了。解决先打印len(html)和html[:500]看拿到的是什么。如果是验证页或空壳走 3.2 的内嵌数据兜底。如果结构变了用浏览器开发者工具重新确认选择器别死守旧 XPath。5.5 现象跑了一晚上发现抓的全是同一批文章原因搜狗搜索翻页时 URL 参数没变或者去重逻辑失效导致重复抓。解决翻页时检查page参数是否真的在变去重表确认唯一索引生效用SELECT COUNT(*)和SELECT COUNT(DISTINCT biz, mid, idx)对比数字不一致就是索引没建对。6. 进阶把抓下来的文章变成能检索的知识库抓到 JSONL 只是第一步真正有用的是能搜。我习惯用 SQLite 的 FTS5 做全文索引不用额外装 Elasticsearch几万篇以内够用。CREATE VIRTUAL TABLE articles_fts USING fts5( title, content, tokenizeunicode61 );unicode61对中文是按字切分搜「爬虫」能命中「爬虫系统」。建完索引后查询这样写SELECT a.title, a.url FROM articles_fts f JOIN articles a ON a.id f.rowid WHERE articles_fts MATCH 爬虫 AND 微信 ORDER BY rank LIMIT 20;MATCH支持 AND、OR、NEARrank是相关度排序。比 LIKE 快一个数量级而且支持短语搜索。验证抓取质量我有个笨办法但很管用随机抽 20 篇人工看标题和正文前 200 字是否匹配同时统计content_len的分布。如果中位数低于 300说明大部分抓空了得回去查渲染问题。这个检查我每次改完抓取逻辑都会跑一遍比看日志直观。最后一个习惯所有抓取脚本都加--dry-run参数只打印不写库。改选择器或调度逻辑时先 dry-run 跑十篇确认没问题再全量。这个习惯帮我省过至少三次重抓。希望帮到你。本文还有配套的精品资源点击获取