1. 项目缘起:为什么我们需要一个公众号数据抓取脚本?
做内容运营、市场分析或者竞品研究的朋友,十有八九都动过这个念头:要是能把某个公众号的历史文章一股脑儿全扒下来就好了。无论是想分析某个大号的爆款规律,还是想给自己的内容库做个备份,手动一篇篇点开、复制、粘贴,效率低到令人发指,而且一旦遇到文章被删除或者账号迁移,数据就彻底丢了。市面上虽然有一些现成的工具,但要么收费不菲,要么功能受限,要么就是操作复杂,对非技术背景的同事不够友好。
我自己就经常遇到这种需求。团队需要分析行业头部账号的内容策略,老板一句话“把这个号近三年的文章都拉出来看看”,如果手动操作,这工作量简直不敢想。于是,我就琢磨着自己写一个脚本。我的核心诉求很简单:一键、全自动、稳定、数据全。所谓“一键”,就是输入目标公众号的名称或者ID,脚本就能自动运行,把文章列表、标题、发布时间、阅读数(如果能获取到)、点赞数、摘要、原文链接,甚至正文内容都给我抓取下来,并规整地保存成结构化的数据,比如Excel或者数据库。经过一段时间的折腾和优化,这个脚本终于能比较稳定地跑起来了,今天就来和大家分享一下我的实现思路、踩过的坑以及一些实用的技巧。
2. 核心思路与方案选型:不走寻常路的抓取策略
在动手之前,我们必须先搞清楚微信公众号数据的获取途径。直接去爬取微信公众平台的网页是条死路,因为它的反爬机制非常严格,页面结构复杂且动态加载,登录态也难以维持。经过一番调研和测试,我总结出几条可行的路径,并最终选择了其中一条相对稳定和高效的路。
2.1 主流抓取路径分析
目前,获取公众号历史文章数据主要有以下几种方式,各有优劣:
- 通过搜狗微信搜索:这是最古老的方法。搜狗有一个专门的微信搜索频道,可以搜索到公众号和文章。但问题很多:首先,搜狗的数据本身就不全,很多新号或者文章可能搜不到;其次,搜狗页面也有反爬,需要处理验证码和频率限制;最重要的是,它无法获取到阅读数、点赞数等关键互动数据,数据维度不全。
- 通过微信公众号手机端接口逆向:这是目前最主流、数据最全的方法。微信手机客户端(App)在查看公众号历史消息列表和文章详情时,会调用后台的API接口。如果我们能模拟手机端的请求,就能拿到最原始、最完整的数据,包括阅读量、点赞量、评论等。这需要抓包分析App的请求,找到关键的接口和参数。这种方法技术门槛较高,但一旦跑通,效果最好。
- 通过第三方聚合平台或工具:一些数据公司提供了付费的API服务,可以直接调用获取公众号数据。这对于企业级应用且预算充足的情况是很好的选择,但对于个人或小团队来说,成本是首要考虑因素。
- 通过微信公众号后台(仅限自己管理的号):如果你本身就是公众号的管理员,那么后台提供了最全面的数据导出功能。但这显然不适用于抓取别人的公众号。
综合来看,逆向手机端API接口是平衡了数据完整性、稳定性和自主可控性的最佳选择。我的脚本也正是基于这个思路构建的。
2.2 技术栈选择:Python生态是首选
确定了抓取路径,接下来是技术选型。对于这类自动化抓取任务,Python几乎是唯一的选择,因为它有极其丰富的网络请求和HTML解析库。
- 请求库:
requests和httpx。requests简单易用,是标准选择。但在处理大量异步请求时,我选择了httpx,因为它同时支持同步和异步客户端,性能更好,能显著提升抓取大量文章详情页的速度。 - 解析库:
BeautifulSoup4和lxml。BeautifulSoup4解析HTML非常友好,写起来快。但lxml的解析速度更快,在需要处理成千上万页面时优势明显。我的脚本里混合使用,列表页用BeautifulSoup快速开发,详情页解析用lxml提升效率。 - 数据存储:
pandas+SQLite/MySQL。pandas是数据处理的瑞士军刀,抓下来的数据先用它清洗、整理,然后可以轻松导出为Excel(.xlsx)或CSV文件。对于需要长期存储或关系查询的数据,我会选择存入SQLite(轻量级,单文件)或MySQL。 - 任务调度与异步:
asyncio+aiohttp。为了实现“一键”和高效,异步IO是关键。使用asyncio和aiohttp可以同时发起数十上百个网络请求去获取文章详情,将原本需要数小时的串行抓取任务缩短到几分钟内完成。 - 模拟请求关键:
fake-useragent和 代理IP池。为了避免被目标服务器封禁IP,随机生成User-Agent和使用代理IP是必须的。fake-useragent库可以方便地生成各种浏览器的随机UA。对于代理IP,可以根据实际情况使用付费服务或维护一个免费IP池(稳定性较差)。
注意:逆向和抓取他人公开数据需注意法律风险与平台规则。本脚本及分享仅用于技术学习与个人已授权数据的备份,请勿用于侵犯他人权益、商业爬取或对目标服务器造成压力的场景。务必设置合理的请求间隔(如每次请求间休眠1-3秒),体现技术人的操守。
3. 实操拆解:从零构建抓取脚本的关键步骤
下面,我将分步拆解整个脚本的核心模块。为了清晰,我会用伪代码和关键代码片段来说明思路,你完全可以基于此骨架填充实现。
3.1 第一步:获取目标公众号的唯一标识(biz或__biz)
这是整个流程的起点,也是最棘手的一步之一。微信公众号没有一个像微博ID那样显而易见的、固定的公开标识。我们通常需要找到一个包含__biz参数的URL。这个__biz值就是公众号在微信系统中的唯一ID。
如何获取?
- 从历史文章链接中提取:在微信里打开任意一篇该公众号的历史文章,点击右上角“...”,选择“复制链接”。你会得到一个长链接,其中包含
__biz=XXXXXX==这样的参数。这个XXXXXX==就是我们要的。 - 通过公众号名片页:在微信搜索公众号,进入其主页,分享名片到文件传输助手,然后在电脑浏览器中打开这个名片页,查看页面源代码,搜索
__biz也能找到。 - 通过第三方工具查询:有些网站提供了通过公众号名称查询
__biz的服务,但可靠性需要自行甄别。
拿到__biz后,我们就有了叩开数据大门的钥匙。
import re def extract_biz_from_url(url): """ 从公众号文章URL中提取__biz参数 """ pattern = r‘__biz=([A-Za-z0-9_=-]+)’ match = re.search(pattern, url) if match: return match.group(1) else: raise ValueError(f“无法从URL中提取__biz参数: {url}”) # 示例用法 sample_url = “https://mp.weixin.qq.com/s?__biz=MzA5NDk4MDA1MA==&mid=2641234567&idx=1&sn=xxxxx” biz = extract_biz_from_url(sample_url) print(f“提取的biz为: {biz}”)3.2 第二步:模拟请求,获取历史文章列表
有了__biz,我们就可以构造请求,获取公众号的历史消息列表。这里我们模拟的是微信手机端翻看历史消息的动作。
关键接口是https://mp.weixin.qq.com/mp/profile_ext。这是一个GET请求,需要携带一系列参数,其中最重要的除了__biz,还有:
action: 操作类型,getmsg表示获取消息。offset: 偏移量,用于分页。微信一次返回10条文章信息,offset控制从第几条开始获取。count: 每次获取的数量,通常是10。uin/key/pass_ticket: 这些是登录态或令牌参数,是难点所在。它们通常包含在公众号主页的页面源代码中,或者需要通过更底层的接口获取。一种常见做法是,先请求一次公众号主页(https://mp.weixin.qq.com/mp/profile_ext?action=home&__biz=...),从返回的HTML中解析出这些动态生成的令牌。
import requests from bs4 import BeautifulSoup import json import time def get_history_list(biz, offset=0, count=10): """ 获取一页历史文章列表 """ base_url = “https://mp.weixin.qq.com/mp/profile_ext” # 注意:以下参数需要从主页HTML中动态获取,此处为示例 params = { ‘action’: ‘getmsg’, ‘__biz’: biz, ‘offset’: offset, ‘count’: count, ‘uin’: ‘MTIzNDU2Nzg=’, # 示例,需动态获取 ‘key’: ‘abcdef123456’, # 示例,需动态获取 ‘pass_ticket’: ‘xyz789’, # 示例,需动态获取 ‘appmsg_token’: ‘token_here’, # 可能需要的令牌 ‘f’: ‘json’, ‘scene’: ‘124’, } headers = { ‘User-Agent’: ‘Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 MicroMessenger/8.0.10(0x18000a2a) NetType/WIFI Language/zh_CN’, ‘Referer’: f‘https://mp.weixin.qq.com/mp/profile_ext?action=home&__biz={biz}’, } try: resp = requests.get(base_url, params=params, headers=headers, timeout=10) resp.raise_for_status() data = resp.json() # 解析返回的JSON,提取文章基本信息 if data.get(‘ret’) == 0: article_list = data.get(‘general_msg_list’, {}).get(‘list’, []) return parse_article_list(article_list) else: print(f“请求失败,返回码: {data.get(‘ret’)}”) return [] except Exception as e: print(f“获取历史列表出错: {e}”) return [] def parse_article_list(msg_list): """ 解析JSON中的文章列表信息 """ articles = [] for item in msg_list: app_msg = item.get(‘app_msg_ext_info’) if app_msg: # 主文章 articles.append({ ‘title’: app_msg.get(‘title’), ‘link’: app_msg.get(‘content_url’), ‘digest’: app_msg.get(‘digest’), ‘cover’: app_msg.get(‘cover’), ‘create_time’: app_msg.get(‘create_time’), ‘article_id’: app_msg.get(‘aid’), }) # 同一推送下的多篇文章(副条) multi_app_msg = app_msg.get(‘multi_app_msg_item_list’, []) for sub in multi_app_msg: articles.append({ ‘title’: sub.get(‘title’), ‘link’: sub.get(‘content_url’), ‘digest’: sub.get(‘digest’), ‘cover’: sub.get(‘cover’), ‘create_time’: app_msg.get(‘create_time’), # 通常与主文相同 ‘article_id’: sub.get(‘aid’), }) return articles实操心得一:令牌(token)的获取与维持脚本稳定性的核心在于如何稳定获取uin、key、pass_ticket等参数。我的经验是,不能硬编码。必须在每次启动抓取任务前,先模拟访问一次公众号主页(action=home),用BeautifulSoup或正则表达式从返回的HTML中提取这些动态生成的参数。有时这些参数的有效期很短,如果抓取过程中中断,可能需要重新获取。可以考虑将获取令牌的逻辑封装成一个独立函数,并在检测到请求失败(如返回ret非0)时自动重新获取令牌并重试。
3.3 第三步:异步抓取文章详情与关键数据
获取到文章列表后,我们得到了每篇文章的content_url(内容链接)。但这个链接是经过加密的,需要解密才能得到真实的文章地址。解密算法是固定的,通常是Base64解码后与一个固定字符串进行异或运算。网上有公开的解密函数,这里不展开。
得到真实URL后,就可以抓取文章详情页了。这里我强烈推荐使用异步IO,因为文章数量可能成百上千,串行抓取太慢。
import asyncio import aiohttp from lxml import etree async def fetch_article_detail(session, article_info, semaphore): """ 异步获取单篇文章详情 """ async with semaphore: # 使用信号量控制并发数,避免被封 url = decrypt_content_url(article_info[‘link’]) # 先解密URL if not url.startswith(‘http’): url = ‘https://mp.weixin.qq.com’ + url try: async with session.get(url, timeout=10) as response: html = await response.text() # 使用lxml解析,效率高 tree = etree.HTML(html) # 提取阅读数、点赞数、评论数等 # 这些数据通常藏在特定的js变量或标签属性里 read_num = extract_read_count(tree) like_num = extract_like_count(tree) comment_data = extract_comment_info(tree) # 提取正文 content_div = tree.xpath(‘//div[@id=“js_content”]’)[0] # 清理正文中的样式、脚本等无关标签 cleaned_content = clean_html_content(content_div) article_info[‘read_num’] = read_num article_info[‘like_num’] = like_num article_info[‘comment_info’] = comment_data article_info[‘content’] = cleaned_content article_info[‘detail_url’] = url print(f“已抓取: {article_info[‘title’]}”) return article_info except Exception as e: print(f“抓取文章失败 {article_info[‘title’]}: {e}”) article_info[‘error’] = str(e) return article_info async def batch_fetch_details(article_list): """ 批量异步抓取文章详情 """ connector = aiohttp.TCPConnector(limit=20, ssl=False) # 限制连接数 timeout = aiohttp.ClientTimeout(total=30) semaphore = asyncio.Semaphore(10) # 控制最大并发数为10 async with aiohttp.ClientSession(connector=connector, timeout=timeout) as session: tasks = [] for article in article_list: task = asyncio.create_task(fetch_article_detail(session, article, semaphore)) tasks.append(task) results = await asyncio.gather(*tasks, return_exceptions=True) # 过滤掉异常结果 valid_results = [r for r in results if isinstance(r, dict)] return valid_results实操心得二:解析细节与反爬应对文章详情页的数据提取是另一个难点。阅读数、点赞数并非直接写在HTML里,而是通过JavaScript动态加载的。你需要分析页面源代码,找到包含这些数据的JS变量或特定的接口。例如,阅读数可能在一个名为var msg_read_num = 10000;的变量里,或者在一个window.__stat的对象里。使用正则表达式或简单的字符串查找可以提取出来。此外,微信页面结构可能变动,所以解析规则要有一定的容错性,最好能定期检查。对于反爬,除了控制并发、使用代理IP和随机UA外,在请求头中正确设置Referer(通常为公众号主页或历史列表页)和Cookie(如果维持了会话)也非常重要。
3.4 第四步:数据清洗、存储与导出
所有数据抓取完毕后,我们得到的是一个字典列表。接下来就是用pandas进行清洗和整理。
import pandas as pd from datetime import datetime def save_to_excel(articles_data, filename=“公众号数据.xlsx”): """ 将抓取的数据保存到Excel """ df = pd.DataFrame(articles_data) # 数据清洗 # 1. 处理时间戳 if ‘create_time’ in df.columns: df[‘publish_date’] = df[‘create_time’].apply(lambda x: datetime.fromtimestamp(x) if x else None) # 2. 确保阅读数、点赞数为数值类型 for col in [‘read_num’, ‘like_num’]: if col in df.columns: df[col] = pd.to_numeric(df[col], errors=‘coerce’).fillna(0).astype(int) # 3. 选择要保存的列并排序 columns_to_save = [‘title’, ‘publish_date’, ‘read_num’, ‘like_num’, ‘digest’, ‘detail_url’] # 如果抓取了正文,可以单独存一个文件,因为正文可能很长 df_text = df[[‘title’, ‘publish_date’, ‘content’]].copy() if ‘content’ in df.columns else None df[columns_to_save].to_excel(filename, index=False) print(f“数据已保存到 {filename}”) if df_text is not None: text_filename = filename.replace(‘.xlsx’, ‘_正文.xlsx’) df_text.to_excel(text_filename, index=False) print(f“正文内容已保存到 {text_filename}”) return df除了Excel,你也可以选择存入数据库,便于后续的复杂查询和分析。使用sqlalchemy库可以轻松地将DataFrame写入SQLite或MySQL。
4. 一键整合与调度:让脚本真正“一键”运行
将上述模块组合起来,并添加一些用户交互和错误处理,就构成了我们的主脚本。
import asyncio import sys def main(): print(“=== 公众号历史数据一键抓取脚本 ===”) # 1. 用户输入 biz_input = input(“请输入公众号的__biz参数或包含该参数的文章链接: “).strip() biz = extract_biz_from_url(biz_input) if ‘http’ in biz_input else biz_input max_pages = input(“请输入要抓取的页数(每页约10篇文章): “).strip() try: max_pages = int(max_pages) except: max_pages = 10 print(f“输入无效,默认抓取{max_pages}页”) # 2. 获取令牌(这里简化,实际需从主页获取) token_params = get_token_params(biz) if not token_params: print(“获取令牌失败,请检查网络或__biz参数是否正确。”) return all_articles = [] offset = 0 # 3. 分页获取文章列表 for page in range(max_pages): print(f“正在获取第 {page+1} 页列表...”) articles = get_history_list(biz, offset=offset, count=10, **token_params) if not articles: print(“没有更多文章或获取失败。”) break all_articles.extend(articles) offset += len(articles) time.sleep(2) # 列表页请求间隔 print(f“共获取到 {len(all_articles)} 篇文章列表。”) if not all_articles: print(“未获取到任何文章,程序结束。”) return # 4. 异步抓取文章详情 print(“开始异步抓取文章详情,请稍候...”) loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) detailed_articles = loop.run_until_complete(batch_fetch_details(all_articles)) loop.close() # 5. 保存数据 df = save_to_excel(detailed_articles, f“{biz[:10]}_data.xlsx”) # 6. 简单统计 print(“\n抓取完成!简单统计:”) print(f“文章总数: {len(df)}”) if ‘read_num’ in df.columns: print(f“总阅读量: {df[‘read_num’].sum():,}”) print(f“平均阅读量: {df[‘read_num’].mean():,.0f}”) print(f“数据文件: {biz[:10]}_data.xlsx”) if __name__ == “__main__”: main()这个主函数流程清晰:输入参数 -> 获取令牌 -> 循环翻页抓列表 -> 异步批量抓详情 -> 保存数据。你可以将其打包成命令行工具,或者加上简单的GUI(比如用tkinter),使其对非技术人员更友好。
5. 常见问题与避坑指南实录
在实际开发和运行过程中,我遇到了不少坑。这里总结一下,希望能帮你节省时间。
5.1 请求频繁被限制或封禁
这是最常见的问题。微信的反爬策略会检测异常流量。
- 症状:返回
ret值为非0(如 -1),或请求直接超时,或返回验证页面。 - 解决方案:
- 降低请求频率:在每次请求(无论是列表页还是详情页)之间加入随机延时,例如
time.sleep(random.uniform(2, 5))。列表页请求间隔可以更长一些。 - 使用高质量代理IP:这是应对IP封禁最有效的方法。建议使用付费的代理IP服务,它们通常提供高匿、稳定的IP池,并支持自动切换。在
requests或aiohttp的请求中设置proxies参数即可。 - 模拟真实用户行为:除了随机UA,还可以随机化请求间隔,模拟人的阅读速度。在抓取详情页时,不要一次性发起几百个并发,用
asyncio.Semaphore将并发数控制在10-20以内。 - 维护Cookie池:如果可能,尝试获取并维护有效的登录态Cookie,但这部分难度较大。
- 降低请求频率:在每次请求(无论是列表页还是详情页)之间加入随机延时,例如
5.2 无法获取阅读数、点赞数等数据
- 症状:抓取到的文章详情页HTML中找不到阅读数等元素。
- 原因与解决:
- 页面结构变化:微信前端可能改版。需要重新分析页面,找到新的数据存放位置。通常数据在
<script>标签的JavaScript变量中。用浏览器的开发者工具,在“网络”选项卡中筛选XHR/Fetch请求,有时这些数据是通过额外接口异步加载的,你需要找到并模拟那个接口。 - 数据需要授权:某些公众号的详细数据(如阅读数来源分布)可能只有管理员可见。公开页面只能看到基础阅读数和点赞数。如果连基础数据都没有,可能是该公众号选择了不公开,或者你的请求缺少了必要的参数(如
appmsg_token)。
- 页面结构变化:微信前端可能改版。需要重新分析页面,找到新的数据存放位置。通常数据在
5.3 抓取到的文章链接是加密的(content_url)
- 症状:从列表接口获取的
content_url是一串看似乱码的字符串,无法直接访问。 - 解决:这是微信的固定加密方式。你需要一个固定的解密函数。这个函数通常是Base64解码后,与一个固定的密钥(如
‘https://mp.weixin.qq.com/s?‘的某部分)进行逐字节异或操作。这个解密算法在网上是公开的,搜索“微信公众号 content_url 解密”就能找到多种语言的实现。务必确保你使用的解密函数是正确的,否则会得到错误的链接。
5.4 异步抓取时出现大量错误或数据丢失
- 症状:使用
asyncio并发抓取详情时,部分任务失败,返回None或异常。 - 解决方案:
- 做好异常捕获:就像示例代码中那样,在每个异步任务内部用
try...except包裹,即使出错也不影响其他任务,并将错误信息记录到结果中。 - 使用
return_exceptions=True:在asyncio.gather中设置此参数,让异常作为结果返回,而不是直接抛出中断整个程序。 - 实现重试机制:对于失败的请求,可以将其放入一个重试队列,稍后重新尝试。可以定义一个简单的装饰器或函数来实现带指数退避的重试逻辑。
- 控制并发量:过高的并发会导致本地端口耗尽或服务器拒绝。通过
asyncio.Semaphore严格控制并发数,并根据网络情况和目标服务器响应动态调整。
- 做好异常捕获:就像示例代码中那样,在每个异步任务内部用
5.5 数据存储混乱或格式错误
- 症状:导出的Excel文件乱码、日期格式不对、数字被当成字符串。
- 解决:
- 编码问题:确保在读写文件时指定编码为
utf-8-sig(Excel兼容的UTF-8带BOM格式)。 - 数据类型:使用
pandas的pd.to_numeric、pd.to_datetime函数进行强制类型转换,并处理转换错误(errors=‘coerce’)。 - 清理HTML:正文内容包含大量HTML标签和样式,直接存入Excel会很乱。可以使用
BeautifulSoup的get_text()方法提取纯文本,或者用lxml的xpath(‘string(.)’)。如果希望保留部分格式(如加粗),可以编写自定义的清理函数。
- 编码问题:确保在读写文件时指定编码为
最后,这个脚本是一个强大的工具,但能力越大,责任越大。请务必遵守robots.txt(虽然微信可能没有明确的),以合理的频率抓取,不要对服务器造成负担。将抓取的数据用于个人学习、分析或已授权的内容备份,远离任何可能侵权的商业用途。技术本身无罪,关键在于使用它的人。希望这个分享能帮你打开一扇窗,更高效地处理信息,而不是带来麻烦。