前言
近期在研究微信公众号文章的抓取方案。之前觉得就是单纯的文章内容加上若干个API就完了,但检查后才发现没那么简单,毕竟和普通的网站不是一回事。后来在网上搜索和测试了很久才终于打通了一条路。虽然不完美,但至少实现了。
一、目标分析
获取以下数据,以关注的某个公众号为例:
二、逻辑分析
其实这些字段出现在不同的接口,单纯的获取文章信息毕竟容易,但是阅读数、点赞和在读等等获取的就比较麻烦。
主要涉及以下接口:
- 文章URL、概要、链接
/cgi-bin/appmsg
- 阅读数、在看数、点赞
/mp/getappmsgext
- 评论数
/mp/appmsg_comment
到了这里,分析以下几个问题。
首先文章链接这个可以通过访问公众号后台,通过引用的方式搜索到公众号下的文章列表,那么该如何批量获取呢?
截图不再放了,由于被限流,暂时无法截图了。研究了很久,参考一些大佬的做法,但是可能某些不清楚的原因,无法复现。因此最后决定使用两步来解决这个问题。首先通过微信公众号的后台获取文章链接信息,然后根据URL发送到移动端的微信上使用Appium进行点击操作,这个时候使用mitmproxy
进行抓包处理。
三、代码实现
1. 获取文章列表
刚才提到了是从公众号后台获取的文章列表,那么代码怎么实现呢?核心逻辑分为以下几部分:
- 加载cookie,访问官网主页,获取token,认定已经登录了
- 搜索公众号,使用查询参数
- 模拟点击下一页
核心代码如下:
# 1.访问官网主页
url = 'https://mp.weixin.qq.com'
res = s.get(url=url, headers=headers, cookies=cookies, verify=False)
# 获得token
token = re.findall(r'.*?token=(\d+)', res.url)
if token:
token = token[0]
# 2.检索公众号
url = 'https://mp.weixin.qq.com/cgi-bin/searchbiz'
params = {
"action": "search_biz",
"begin": "0",
"count": "5",
"query": key, # 公众号名称
"token": token, # 前面获取的token
"lang": "zh_CN",
"f": "json",
"ajax": "1"
}
# 3.请求搜索页
res = s.get(url=url, params=data, cookies=cookies, headers=headers, verify=False)
# 根据请求结果获取fakeid
fakeid = res.json()['list'][0]['fakeid']
print('fakeid', fakeid)
# 拼接参数,获取文章URL
url = 'https://mp.weixin.qq.com/cgi-bin/appmsg'
params = {
"action": "list_ex",
"begin": str(page_size * (cur_page - 1)), # 从0开始,每次最多5条
"count": str(page_size),
"fakeid": fakeid,
"type": "9",
"query": "",
"token": token,
"need_author_name": "1",
"lang": "zh_CN",
"f": "json",
"ajax": "1"
}
# 4. 获取文章列表,翻页
res = s.get(url=url, params=params, cookies=cookies, headers=headers, verify=False)
之后根据响应的数据进行存储即可。
2. mitmproxy
刚才已经获取了文章列表,接下来就是获取文章数据了。
首先简单介绍一下mitmproxy。mitmproxy
是一款用于拦截、修改、截获和重放网络流量的开源工具。它常用于网络安全、逆向工程、API 开发和调试等场景。以下是 mitmproxy
的一些主要特点和用途:
- 拦截和修改流量:
mitmproxy
允许你在代理层面拦截和修改网络请求和响应。这使得你可以实时查看、编辑和修改应用程序和服务之间的通信。 - HTTPS 解密:
mitmproxy
能够解密通过 HTTPS 发送的流量,这意味着你可以查看加密的流量内容,对于逆向工程和调试来说非常有用。 - 流量记录和回放:
mitmproxy
具有记录和回放网络流量的功能。你可以捕获一系列的请求和响应,然后将它们保存为文件,之后可以使用mitmproxy
进行回放,模拟先前的网络流量。 - Web 界面和命令行界面:
mitmproxy
提供了一个基于命令行的界面,以及一个可选的 Web 界面,使用户可以方便地查看和操作流量。这些界面提供了强大的过滤、搜索和导出功能。 - 脚本支持:
mitmproxy
提供了一个 Python API,允许用户编写脚本以自定义和扩展其功能。这使得用户可以根据需要编写脚本来处理流量、修改请求和响应等。 - 跨平台:
mitmproxy
是一个跨平台工具,支持在 Windows、macOS 和 Linux 等操作系统上运行。
使用 mitmproxy
通常包括以下步骤:
- 启动
mitmproxy
代理服务器。 - 将设备或应用程序的网络配置设置为使用
mitmproxy
作为代理。 - 在
mitmproxy
控制台或 Web 界面中查看、编辑和修改网络流量。
由于其灵活性和强大的功能,mitmproxy
成为网络安全专业、开发人员和逆向工程师的常用工具之一。
所以,为什么选择mitmproxy而不是Charles,根据前面的功能特点也能看出来了,就是因为可以提供灵活的脚步支持,可以自定义修改来处理请求和响应。
安装软件
# mac
brew install mitmproxy
# python
pip install mitmproxy
mitmproxy 有两种使用方式:
- 命令行
$ mitmdump -p 端口 -s 插件.py
- Web
$ mitmweb -p 端口 -s 插件.py
安装证书
启动后访问 http://mitm.it/,根据平台和系统选择并安装。
查看输出
启动后就会看到如下的日志,当然输出的内容是根据代码展示的。
检查数据
mitmproxy操作实例方法:
flow.request.headers #获取所有头信息,包含Host、User-Agent、Content-type等字段
flow.request.url #完整的请求地址,包含域名及请求参数,但是不包含放在body里面的请求参数
flow.request.pretty_url #同flow.request.url目前没看出什么差别
flow.request.host #域名
flow.request.method #请求方式。POST、GET等
flow.request.scheme #什么请求 ,如https
flow.request.path # 请求的路径,url除域名之外的内容
flow.request.get_text() #请求中body内容,有一些http会把请求参数放在body里面,那么可通过此方法获取,返回字典类型
flow.request.query #返回MultiDictView类型的数据,url直接带的键值参数
flow.request.get_content()#bytes,结果如flow.request.get_text()
flow.request.raw_content #bytes,结果如flow.request.get_content()
flow.request.urlencoded_form #MultiDictView,content-type:application/x-www-form-urlencoded时的请求参数,不包含url直接带的键值参数
flow.request.multipart_form #MultiDictView,content-type:multipart/form-data时的请求参数,不包含url直接带的键值参数
# 以上均为获取request信息的一些常用方法,对于response,同理
flow.response.status_code #状态码
flow.response.text#返回内容,已解码
flow.response.content #返回内容,二进制
flow.response.setText()#修改返回内容,不需要转码
实际应用
其实我这里是先把文章URL写入MongoDB,点击URL后通过监听脚本再次将点赞等值写入MongoDB。以下是部分监听代码:
def response(self, flow: http.HTTPFlow):
""" HTTP 事件钩子 - 收到完整的响应 """
if flow.request.host == self.filter_host:
path = flow.request.path.split('?')[0]
if path == '/s':
# 获取请求参数,假设关联信息存储在参数中,你可以根据实际情况修改
ctx.current_url = process_url(flow.request.url)
print('当前请求的URL', ctx.current_url)
if path in self.filter_path_list:
if path == '/mp/getappmsgext':
response_json = json.loads(flow.response.text)
if 'appmsgstat' in response_json:
appmsgstat = response_json['appmsgstat']
self.article_data['read_num'] = int(appmsgstat['read_num']) # 阅读数
self.article_data['like_num'] = int(appmsgstat['like_num']) # 在看数
self.article_data['old_like_num'] = int(appmsgstat['old_like_num']) # 点赞数
elif path == '/mp/appmsg_comment':
response_json = json.loads(flow.response.text)
if 'elected_comment_total_cnt' in response_json:
comment_total = response_json['elected_comment_total_cnt']
self.article_data['comment_total'] = int(comment_total) # 评论数
else:
self.article_data['comment_total'] = 0 # 评论数
注意这里使用了ctx,主要功能就是传递URL,不然不知道当前请求的是哪篇文章。
ctx
是 mitmproxy 中的上下文对象,用于在不同的事件处理函数之间传递数据和共享状态。ctx
对象包含有关当前 mitmproxy 会话的信息,并允许你存储和检索自定义数据。
以下是 ctx
的主要用途:
- 传递数据: 在不同的事件处理函数之间传递数据。你可以在一个事件处理函数中设置某个值,然后在另一个事件处理函数中获取该值。
- 存储状态:
ctx
允许你在整个 mitmproxy 会话期间存储状态。例如,你可以在request
函数中设置某个状态,然后在response
函数中检查并相应地处理。 - 共享变量: 通过
ctx
对象,你可以在不同的事件处理函数中共享变量,而不是将它们作为参数传递。
注意mitmporxy
的监听脚本是从上到下重复执行的,每次有请求进来所有流程都要走一遍。开始的时候不知道这个逻辑,导致在匹配文章链接的时候始终获取为None。直到后来查询到ctx,使用这样一个对象才匹配到了正确的值,便于后续的点赞等值的传递。
其实这个时候如果在手机上点击公众号文章就已经完成了最基础的数据采集,但是完全手动就没什么意义了,所以要实现自动点击。方案有很多,不同平台也有不同的做法。我这里没有Windows系统,在Mac上自动点击也没有太好的方案。因此决定使用Appium自动点击聊天窗口的URL。
3. Appium
Appium是一个开源的移动应用程序自动化测试工具,它支持iOS和Android平台上的原生、混合和移动Web应用程序。Appium使用WebDriver协议,可以使用多种编程语言编写测试脚本,包括Java、Python、Ruby、C#等。
以下是一些Appium的主要特点和优势:
- 跨平台支持: Appium可以同时用于iOS和Android平台,使得开发人员和测试人员能够使用相同的工具和脚本来测试不同平台的应用程序。
- 支持多种编程语言: Appium支持多种编程语言,包括Java、Python、Ruby、C#等,这使得开发人员可以选择他们熟悉的语言来编写测试脚本。
- 支持多种应用类型: Appium支持测试原生应用、混合应用和移动Web应用。这种灵活性使得它适用于各种不同类型的移动应用。
- 使用标准WebDriver协议: Appium使用WebDriver协议来与应用程序进行交互,这意味着开发人员可以使用熟悉的WebDriver API来编写测试脚本。
- 无需修改应用程序: 与一些其他自动化工具不同,Appium不需要在应用程序中插入特殊的代码或库。这意味着应用程序的原始版本可以被测试,而不需要进行修改。
- 支持多种设备: Appium支持连接到物理设备和模拟器,使得测试人员能够在不同的环境中执行测试。
- 活跃的社区支持: Appium拥有庞大而活跃的社区,因此用户可以轻松地找到文档、教程和解决方案。
- 集成性: Appium可以与各种测试框架和持续集成工具集成,从而实现更广泛的自动化测试流程。
总体而言,Appium是一个功能强大、灵活且跨平台的移动应用程序自动化测试工具,适用于移动应用程序的开发和测试。
具体使用而言,Appium慢是真慢,但是不清楚是否还有其他更好的方案,但是目前来说可能Appium的方案可能最成熟。新版本的Appium软件分为两部分:
Appium-Server
用来启动服务Appium-inspector
用来定位元素
之前不熟悉啊,被坑了很久,一直不清楚咋回事,一直以为这是同一个软件的相互替代品。其实单独只启动server也是没问题的,但是首次使用的时候必须得定位元素啊,所以Appium-inspector就必须的。其次就是选择微信版本的时候,尽量低一点,毕竟不是拿来日常使用的,否则版本越高,程序运行的时候就会很慢。而且不同版本的APP往往定位元素的资源ID是不一样的,一定要注意。
首先是Appium-Server
的启动,这个没啥好说的,配置网上搜搜就行。然后是Appium-inspector
,这个需要进行一些配置,填写设备和APP的信息,如下所示:
配置:
{
"appium:platformName": "Android",
"appium:deviceName": "99261FFAZ00FJ7",
"appium:platformVersion": "10",
"appium:appPackage": "com.tencent.mm",
"appium:appActivity": "com.tencent.mm.ui.LauncherUI",
"appium:unicodeKeyboard": true,
"appium:noReset": true
}
deviceName 来自adb devices 展示的列表
appPackage 这个一般一个APP都是固定
appActivity 这个需要检查一下,可以先使用,不行再查
noReset 一定要设置,不然原来的微信就会被自动退出登录
获取到所有元素:
xml_source = driver.page_source
from xml.etree import ElementTree as ET
root = ET.fromstring(xml_source)
# 提取所有元素的资源 ID
resource_ids = [element.get('resource-id') for element in root.iter()]
resource_ids = [res_id for res_id in resource_ids if res_id is not None]
# 打印资源 ID
print(resource_ids)
输出:
['com.tencent.mm:id/e8x', 'com.tencent.mm:id/fpn', 'com.tencent.mm:id/fnj', 'com.tencent.mm:id/bl6', 'android:id/content', 'com.tencent.mm:id/d5e', 'com.tencent.mm:id/g8f', 'com.tencent.mm:id/ffv', 'com.tencent.mm:id/f6r', 'com.tencent.mm:id/no', 'com.tencent.mm:id/f67', 'com.tencent.mm:id/nn', 'com.tencent.mm:id/nl', 'com.tencent.mm:id/be3', 'com.tencent.mm:id/eh', 'com.tencent.mm:id/nk', 'com.tencent.mm:id/he6', 'com.tencent.mm:id/gdh', 'com.tencent.mm:id/dmt', 'com.tencent.mm:id/bg1', 'com.tencent.mm:id/x1', 'com.tencent.mm:id/ikd', 'com.tencent.mm:id/fzg', 'com.tencent.mm:id/j0l', 'com.tencent.mm:id/e7t', 'com.tencent.mm:id/bg1', 'com.tencent.mm:id/x1', 'com.tencent.mm:id/ikd', 'com.tencent.mm:id/fzg', 'com.tencent.mm:id/j0l', 'com.tencent.mm:id/e7t', 'com.tencent.mm:id/bg1', 'com.tencent.mm:id/x1', 'com.tencent.mm:id/ikd', 'com.tencent.mm:id/fzg', 'com.tencent.mm:id/j0l', 'com.tencent.mm:id/e7t', 'com.tencent.mm:id/e8y', 'com.tencent.mm:id/dtf', 'com.tencent.mm:id/dtx', 'com.tencent.mm:id/dub', 'com.tencent.mm:id/dtf', 'com.tencent.mm:id/dtx', 'com.tencent.mm:id/dub', 'com.tencent.mm:id/dtf', 'com.tencent.mm:id/dtx', 'com.tencent.mm:id/dub', 'com.tencent.mm:id/dtf', 'com.tencent.mm:id/dtx', 'com.tencent.mm:id/dub', 'com.tencent.mm:id/c_', 'com.tencent.mm:id/c7', 'com.tencent.mm:id/ef', 'com.tencent.mm:id/eh', 'android:id/text1', 'com.tencent.mm:id/fdi', 'com.tencent.mm:id/dt5', 'com.tencent.mm:id/fcu', 'com.tencent.mm:id/dt5', 'android:id/navigationBarBackground']
如果存在疑问,可以输出全部的资源ID来排查一下。
4. MongoDB
存储是放在了MongoDB中,以下是Mac系统的安装方式。
brew tap mongodb/brew
brew install mongodb-community
brew services start mongodb-community
在 MongoDB 中,你可以使用 find
方法来执行查询。以下是一些常见的 MongoDB 查询语句:
-
简单查询:
javascriptCopy code db.collection_name.find({ key: value }) db.getCollection("未闻Code").find({ 'read_num': { $exists: false } }).count()
-
查询嵌套字段:
javascriptCopy code db.collection_name.find({ "nested.key": value })
-
范围查询:
javascriptCopy code db.collection_name.find({ key: { $gt: value1, $lt: value2 } })
-
逻辑 AND 查询:
javascriptCopy code db.collection_name.find({ key1: value1, key2: value2 })
-
逻辑 OR 查询:
javascriptCopy code db.collection_name.find({ $or: [ { key1: value1 }, { key2: value2 } ] })
-
查询指定字段:
javascriptCopy code db.collection_name.find({}, { key1: 1, key2: 1, _id: 0 })
-
排序查询结果:
javascriptCopy code db.collection_name.find().sort({ key: 1 }) // 升序 db.collection_name.find().sort({ key: -1 }) // 降序
-
限制查询结果数量:
javascriptCopy code db.collection_name.find().limit(10)
-
跳过前 N 个文档:
javascriptCopy code db.collection_name.find().skip(5)
-
模糊查询(正则表达式):
javascriptCopy code db.collection_name.find({ key: /pattern/ })
请替换 collection_name
、key
、value
等为你实际的集合名、字段名和值。这些是基本的查询示例,你可以根据实际需要组合使用它们。
# 查询是否有重复记录
db.getCollection("WeChat").aggregate([
{ $group: { _id: "$link", count: { $sum: 1 } } },
{ $match: { count: { $gt: 1 } } },
{ $project: { _id: 0, link: "$_id", count: 1 } }
])
# 查询是否有重复记录并删除
var duplicates = db.getCollection("wechat").aggregate([
{ $group: { _id: "$link", count: { $sum: 1 }, docs: { $push: "$_id" } } },
{ $match: { count: { $gt: 1 } } }
])
duplicates.forEach(function(duplicate) {
duplicate.docs.shift(); // 保留第一个记录,删除其他重复记录
db.getCollection("WeChat").deleteMany({ _id: { $in: duplicate.docs } })
})
# 查询完成的记录总数
db.getCollection("WeChat").find({ 'read_num': { $exists: false } }).count()
四、总结
目前依然存在的问题如下:
- 单批获取文章大概在50页左右,无论如何睡眠等待均未有明显效果。解决方式:使用多个微信公众号的cookie,堆号
- 使用Appium自动点击,速度慢。暂未想到更好的方法。
通过这几天的实践,熟悉了mitmproxy、appium以及mongodb的使用,基本实现了自动化获取文章信息。虽然存在若干问题,但至少实现了,之后有机会再慢慢摸索了。