最近在做微信公众号采集的时候,延申出来一个需求,我要对公众号进行批量采集的话,势必需要先获取我自己关注的公众号列表,后面的自动化已经非常完整,所以只需要获取所有公众号的中文名称,整个流程就打通了。但问题来了,中文名称也不好搞哇。要不我手动一个一个输?列表拉下来一看,嗬,346个。我掐指一算,10秒一个,我得机械的在那儿打打打打字一个小时。不行不行,太要命了。
那就找找自动化的方法?微信现在PC端、移动端的接口都是铁板一块,像我这种工具小子,开个Fiddler毛都抓不到,毕竟人家也没用HTTP去通信。从通信角度肯定没办法了。一般这个时候我会想到Frida/Xposed,但现在的目标是微信,之前因为插件已经封过一个号了,需要获取数据的又是大号,不敢造次。因此只能去调研一下其他非侵入式的方法了。找来找去,在UI上做文章最为保险,本文就简单讲一下,如何利用应用自动化测试框架 Appium ,在非root环境的情况下获取任意安卓App界面上任意数据。
数据抓取方法汇总
读者会有疑问了,前面我们说获取App界面上的数据,这应该是爬虫呀,怎么就跑到自动化测试上去了?其实这与安卓的内部机制有关。我们现在在做的事情是,从一个进程内控制另一个进程的运行方式。想要做到这一点,必然需要安卓系统提供API来支持。安卓提供了两个组件(至少是我在调研时想到的两个思路)可以实现这个需求,一个是为残疾人准备的 Accessibility 组件(用于屏幕阅读器、语音助手等等),一个是为测试狗准备的 UIAutomator 组件(用于单元测试)。
基于 Accessibility 的方案呢,现成的没有,找了一圈,都得写代码,既然都是写代码,我干啥不写Python呢?在 Reddit 上,有一个帖子提到了可以用 Tasker 配合一些杂七杂八的插件读取界面的内容,看网友的回复中,有一个插件也确实可以读取界面中的文本。但我感觉完成度还是比较低,在手机上用 Tasker 那个垃圾 GUI 完成剩下的功能……想想都挺拉胯的。而且 Tasker 还要钱,即使弄好了也没有写出来的价值,经验也不能复用。
基于 UIAutomator 的呢,随便搜搜就能找到一堆,比如 UIAutomator、Selendroid、Espresso等,Appium甚至都没排到谷歌的第一页上,最后选了 Appium 仅仅是因为我搜索关键字 微信 自动化
的时候,Appium 出现在其中一篇文章里了。可能大家都和我一样菜,只会写Python吧。
选择 Appium 有两大原因,说出来不怕害臊。一是,服务端(控制手机的host)有图形界面,下载个 exe 双击就启动了。二就是,客户端(调用API控制手机)可以用Python脚本,当然官方还支持 Java、JS、Ruby、C#、PHP 等各种语言,本质都是对服务器 REST API 的封装,看文档 一目了然。当然啦,它还有其他优点,比如 Appium 是 UIAutomator 、Espresso 的上层封装,在客户端上可以用参数指定到底用 UIAutomator 还是用 Espresso 作为 Driver,这些就留给读者自行探索啦。
依赖安装
Appium的架构分为服务端和客户端,服务端是一个缝合怪,在电脑上运行(支持Win/Mac/Linux),负责与设备(如安卓手机、或模拟器)通信,并把UI自动化接口通过 Web API 暴露出来。服务端从官方Github下载解压即可。但安卓调试还需要安装额外的依赖。
我的环境是 Win10 且安装了 chocolatey (一个包管理器),用下面一句命令可以装好所有依赖。
说白了,就是 Android SDK、adb 和 JDK 。
choco install AndroidStudio adb adoptopenjdk11
同时,需要确保 ANDROID_SDK_ROOT
和 JAVA_HOME
环境变量正确设置。
ANDROID_SDK_ROOT=%USERPROFILE%\AppData\Local\Android\Sdk
JAVA_HOME=C:\Program Files\AdoptOpenJDK\jdk-11.0.8.10-hotspot
上面步骤完成后,启动 Appium 程序,点蓝色大按钮启动服务就行了。
编写客户端脚本
下面我们就要写脚本获取微信公众号列表中的内容了。安卓应用的界面,在数据层面也是一个树状结构,实现上用 XML 表示,就像我们在写 Web 爬虫时需要处理的 DOM 树一样,而我们现在要获取一个应用某个界面下、某个组件中的数据,也可以通过 xpath 定位对应的组件,然后从实例里提取我们所需的信息。在 Chromium 里面我们有开发者工具,在 Appium 里呢?我们也有!
现在我们来启动一下 Appium 的“开发者工具”。Appium 的配置比较晦涩,主要是它搞了一大堆不明所以的名词,比如启动配置参数,在这里叫 Desired Capabilities 。
我们在主界面里打开一个 Session Window 后,在 Desired Capabilities 的 JSON Representation 里面,输入以下的配置参数并保存。记得将 device id 替换成 adb devices
里显示出来的设备ID。非常关键的一点是,在任何情况下不要漏掉 noReset
这个参数。如果不加这个参数,默认会在每次启动 Session 时清除掉目标应用的全部数据!我一个微信号的聊天记录就这么被清理没了……
{
"platformName": "Android",
"deviceName": "YOUR_DEVICE_ID",
"appPackage": "com.tencent.mm",
"appActivity": ".ui.LauncherUI",
"noReset": true
}
配置好参数以后,一样点击蓝色大按钮启动。此时 Appium 会强行 kill 掉并重启微信客户端,然后就可以用类似 Chrome 开发者工具一样的方式,用鼠标点击确认目标组件所在的层级结构了。
以微信为例,我在这里选中的是某个公众号的名字,这个组件是个 TextView ,它的 resource-id 是 com.tencent.mm:id/a71
。尝试过后发现,列表中所有相同类型元素的ID都是一样的(比如“阿里云”这个标签和“敖厂长”标签的 resource-id 字段都是相同的)。
但很明显这是个混淆过后的 ID,而且应该会随微信版本更新而变化,这么搞不优雅。我们可以通过 XPath 来定位这个元素的位置,再动态获取它的ID,这样写了脚本就不怕微信更新了,一样能用。
经过一些尝试,我发现通过已知公众号名字来定位最为高效。最终我采用的表达式是 //android.widget.TextView[@text="阿里云"]
。通过这个表达式获取到 element,再通过这个 element 的 resource-id 即可获取到可视范围内所有的标签字段。
但还有一个难点,我提到了 可视范围内 ,每次获取最多也就能获取到一页,怎么翻页呢?
这里是我最终没有解决的一个难点。因为我遇到了两个问题。
一个是翻页这个 API 似乎在 Python SDK 里面他就没实现(虽然文档里写的是实现了),这就很糟心。最终的这个 API 和文档里也不一样,和文档里贴的 selenium API 也不一样。不一样你写它干啥??翻页不行我就模拟点击呗,结果模拟点击的步骤执行之间存在很大的延迟,我想实现的效果是,按下、拖动、松手,结果调用的时候,按下和拖动之间隔了有一秒还多,触发了微信菜单里长按操作的 context menu,无论如何无法解决。想换个Driver,结果又碰到了问题二。
二就是Espresso的实现似乎是基于Instrumentation的,启动的时候,花半天编译一个专用的apk出来,结果运行时报错,提示说被插桩的应用需要和源应用相同的签名证书。对于我们来讲这当然不可能实现了,签名证书如果可以伪造,我就可以写一个假冒的微信了。当然,签名伪造从 Xposed 层或者在 framework 做一些修改都可以实现,但我的主力手机连 root 都没有,所以也不折腾这些有的没的了。
因此最终的妥协就是,每次识别完成以后程序 sleep 两秒,然后我手动拖动一下界面,还是很low的样子……
不过总结下来,代码量还是很小的,浓缩下来的精华也就三四十行。运行服务端以后,再运行这个 python 脚本就行了。
import json
import time
import appium.webdriver
from appium.webdriver.common.touch_action import TouchAction
dc = dc_wechat = {
"platformName": "Android",
"deviceName": "DEVICE_ID",
"appPackage": "com.tencent.mm",
"appActivity": ".ui.LauncherUI",
"noReset": True,
"newCommandTimeout": 3600,
}
FIRST_ACCOUNT_NAME = "阿里云"
def main():
driver = appium.webdriver.Remote("http://localhost:4723/wd/hub", dc)
driver.implicitly_wait(3600)
sample_element = driver.find_element_by_xpath(
f'//android.widget.TextView[@text="{FIRST_ACCOUNT_NAME}"]')
rid = sample_element.get_attribute("resourceId") # 'com.tencent.mm:id/a71'
accounts = set()
prev_count = -1
retry = 3
while retry > 0:
prev_count = len(accounts)
elements = driver.find_elements_by_id(rid)
for e in elements:
accounts.add(e.text)
if prev_count == len(accounts):
retry -= 1
print(f"about to stop, {retry}")
else:
print(f"retrieved {len(accounts) - prev_count} accounts")
retry = 3
time.sleep(2)
print(list(accounts))
with open("output.json", 'w') as f:
json.dump(list(accounts), f)
最终结果,能用
结语
因为调研到实现的时间比较少,因此笔者对文中部分功能的实现原理还不是很了解,也就是能用。就这,我还是得说,从头到尾搞这玩意花了整整一晚上,还不如我一个一个输进去来得快呢。
参考资料
- Appium: Mobile App Automation Made Awesome. https://appium.io/
- Using Tasker to read text on a screen : tasker https://www.reddit.com/r/tasker/comments/99gheb/using_tasker_to_read_text_on_a_screen/
- Task Assist - Run a "UI Query" easily on ANY screen to grab all its info for AutoInput. | AutoApps Forums https://forum.joaoapps.com/index.php?resources/task-assist-run-a-ui-query-easily-on-any-screen-to-grab-all-its-info-for-autoinput.293/
- 谈谈微信自动化的几种方案 - 知乎 https://zhuanlan.zhihu.com/p/109342914
- Top 5 UI Frameworks For Android Automated Testing | Sauce Labs https://saucelabs.com/blog/the-top-5-android-ui-frameworks-for-automated-testing
- Installation via Desktop App Download - Getting Started - Appium http://appium.io/docs/en/about-appium/getting-started/?lang=zh#installation-via-desktop-app-download
- Status API - Appium http://appium.io/docs/en/commands/status/
- UIAutomator2 (Android) - Appium http://appium.io/docs/en/drivers/android-uiautomator2/
- Espresso (Android) - Appium http://appium.io/docs/en/drivers/android-espresso/
- Releases · appium/appium-desktop https://github.com/appium/appium-desktop/releases
- Chocolatey Software | Chocolatey - The package manager for Windows https://chocolatey.org/
- Scroll - Appium http://appium.io/docs/en/commands/interactions/touch/scroll/
原文首发于 个人博客 与公众号:rabyte