最近在做微信公众号采集的时候,延申出来一个需求,我要对公众号进行批量采集的话,势必需要先获取我自己关注的公众号列表,后面的自动化已经非常完整,所以只需要获取所有公众号的中文名称,整个流程就打通了。但问题来了,中文名称也不好搞哇。要不我手动一个一个输?列表拉下来一看,嗬,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)
最终结果,能用
因为调研到实现的时间比较少,因此笔者对文中部分功能的实现原理还不是很了解,也就是能用。就这,我还是得说,从头到尾搞这玩意花了整整一晚上,还不如我一个一个输进去来得快呢。
原文首发于 个人博客 与公众号:rabyte
HWS计划·2020安全精英夏令营来了!我们在华为松山湖欧洲小镇等你
最后于 14小时前 被ttimasdf编辑 ,原因: 忘记删除hexo的标记了