基于Xposed的爬取实战案例

13.3 节我们介绍了 Xposed 模块的基本使用方法,本节中我们结合一个真实的案例学习如何使用 Xposed 爬取 App 的数据。

准备工作

本节需要的环境与 13.3 节是一样的,请参考那里的内容来配置环境。除此之外,还需要额外安装 jadx-gui 工具并掌握它的基本用法,这个我们在 13.1 节已经学习过,如果忘记了,那么可以回顾一下。

由于本节的内容是实战,所以也需要一个示例 App,这个 App 和前 3 节是一样的,下载地址依然为 https://app5.scrape.center ,下载之后依然保存为 scrape-app5.apk。

做好这些后,因为本节我们需要用 Flask 搭建一个简易的测试服务器,所以还要安装好 Flask 和 loguru,使用 pip3 工具安装即可:

pip3 install flask loguru

反编译

既然要用 Xposed 模块爬取数据,就免不了要借助 Xposed 提供的一些 Hook 方法,那具体 Hook 什么内容呢?选择其实有很多,例如我们可以 Hook 与构造 HTTP 请求参数相关的方法,之后可以得到一些 token 字符串;又如可以 Hook 用来获取 HTTP 响应结果的方法,这样相当于直接拿到了数据。

可以看出,目标方法是关键。既然我们要爬取数据,那么干脆一步到位好了,直接通过 Hook 的方式拦截 HTTP 响应结果,然后用某种方式保存下来,数据爬取就完成了。

那用来获取 HTTP 响应结果的方法究竟是什么呢?目前我们无从知道,所以有必要对 apk 文件做一些反编译操作,反编译之后尝试分析一下代码逻辑,应该就能知道哪个方法是我们想要 Hook 的方法了。

和 13.1 节介绍的一样,打开 jadx-gui 工具,然后直接打开 apk 文件,就可以看到反编译的结果了,如图 13-58 所示。

图13-58 反编译的结果

我们还是以 /api/movie 为突破口进行搜索,同时打开 jadx-gui 的反混淆开关,就可以找到与请求定义相关的逻辑,如图 13-59 所示,

图13-59 搜索得到的结果

很明显可以看到,这里定义了一个 index 方法,接收参数 offset、limit 和 token,这和示例 App 加载列表数据时发出的请求完全一致:

public interface MovieApiService {
    @GET("/api/movie")
    Observable<HttpResponse<MovieEntity>> index(@Query("offset") int i, @Query("limit") int i2, @Query("token")String str);

可以看到它的返回结果是一个 Observable<HttpResponse<MovieEntity>> 对象,为了 Hook 获取响应结果的方法,我们可以试着搜索 Observable、HttpResponse 和 MovieEntity 相关的引用及定义。

例如搜索 HttpResponse 相关的引用,结果如图 13-60 所示。

图13-60 与 HttpResponse 相关的引用

一共返回了 7 个结果,这里我们分析一下第一个,其外层是 requestNetwork 方法,该方法的定义如图 13-61 所示。

图13-61 requestNetwork 方法的定义

通过名字,我们初步推测这个方法是用来发起网络请求的。另外,可以看到这里调用了图 13-59 中定义的 index 方法,还定义了一个 subscribe 方法来接收一些处理回调逻辑。再仔细观察,可以看到比较关键的 accept 方法,其参数为 jVar,是一个 HttpResponse<MovieEntity> 对象,方法内部调用 jVar 变量的 getResults 方法得到响应结果,然后用一个 for 循环遍历这些结果,并把结果添加到 IndexViewModel 里。

图13-62 HttpResponse 类的定义

到这里我们可以推测,这很可能就是 App 获取了首页的电影列表数据后,处理响应结果的过程,不然也不会有 for 循环相关的逻辑。既然数据是通过 jVar 变量的 getResults 方法获取的,那 getResults 方法返回的一定是一个 MovieEntity 列表,我们可以进一步追踪,看看 getResutls 方法是怎样定义的,于是我们跳转到定义 HttpResponse 类的位置,如图 13-62 所示。

可以看到 HttpResponse 类使用了泛型,有一个占位符 T,这是什么意思呢?例如给 HttpResponse<T> 中的 T 传入 MovieEntity 类,这里就会变成 HttpResponse<MovieEntity>,代表 HttpResponse 绑定的是 MovieEntity 类,包含的数据也和 MovieEntity 类相关,而刚才往 accept 方法传入的参数正是 HttpResponse<MovieEntity> 对象,所以我们可以认为这里的 T 就是 MovieEntity。还可以看到,getResults 方法的返回值类型是 List<T>,所以获取的响应结果是 List<MovieEntity> 类型的,是 MovieEntity 类返回的列表。所以,如果我们可以 Hook getResults 方法,其实就能拿到响应结果中包含的 MovieEntity 列表数据了。

并不能保证目前的推测 100% 正确,只不过正确的概率很大。

实现 Hook

如果前面的推测是正确的,那我们通过 Hook 就可以直接拿到响应数据了。如果数据无效,再接着进行分析和尝试。

我们还是在 13.3 节的 XposedTest 项目下尝试 Hook,新建一个类,名字为 HookResponse,同时还是按照之前的方法修改包名、类名、方法名等,修改后的内容如下:

这里我们修改了如下几处代码。

  • 将当前 App 的包名修改为 com.goldze.mvvmhabit。

  • 将 loadClass 方法中类的路径修改为 com.goldze.mvvmhabit.data.source.HttpResponse。

  • 将 findAndHookMethod 方法的第二个参数修改为 getResults,由于 getResults 方法没有任何参数,因此直接往 findAndHookMethod 方法的第三个参数传入 XC_MethodHook 的回调定义。

  • 这里的 beforeHookedMethod 方法和 afterHookedMethod 方法仅仅是打印对应的日志。

另外,需要在 xposed_init 方法里定义好这个入口文件,添加如下引用:

com.germey.xposedtest.HookAPI

添加好后,先重新安装并启动 XposedTest 这个 Xposed 模块,另外 App 当然也要重新安装到手机上并运行,我们来看看能不能成功 Hook getResults 方法。重新启动 App 后,运行结果和往常一样,如图 13-63 所示。

这时再打开 Xposed Installer 的日志页面,可以看到输出了这些日志:

Called beforeHookedMethod
Called afterHookedMethod
Called beforeHookedMethod
Called afterHookedMethod
Called beforeHookedMethod
Called afterHookedMethod

App 的运行结果如图 13-64 所示。

图13-63 重启 App 的结果 图13-64 App 的运行结果

这证明我们成功 Hook 了 getResults 方法!

提取结果

我们现在回过头看看 getResults 方法的定义:

publicList<T> getResults() {
    return this.f3149b;
}

这个定义非常简单,我们最关心的就是返回结果,那怎么可以拿到这个结果呢?很简单, afterHookedMethod 方法是专门做这件事的,我们可以利用它获取或者修改返回结果。这里我们不做修改,只获取,所以把 afterHookedMethod 方法的内容修改为下面这样:

protected void afterHookedMethod(MethodHookParam param) throws Throwable {
    XposedBridge.log("CalledafterHookedMethod");
    List results = (List)param.getResult();
}

这里我们做了一个强制类型转换,将返回结果转换成了 List 类型,并赋值为 results 变量,这个 results 其实就是 List<MovieEntity>。

那怎么把真实数据提取出来呢?我们可以进一步看看 MovieEntity 类的定义,回到 jadx-gui 工具搜索 MovieEntity 类的定义,可以看到其中包含很多字段:

@SerializedName("alias")
private String alias;
@SerializedName("categories")
private List<String> categories = new ArrayList();
@SerializedName("cover")
privateStringcover;
@SerializedName("drama")
private Stringdrama;
@SerializedName("id")

另外,MovieEntity 类中还定义了一个 toString 方法,其返回值包含我们想要的很多字段信息:

@NonNull
public String tostring() {
    returnString.format("MovieEntity { id=%s,name=%s,alias=%s,publishedAt=%s,cover=%s,drama=%s,categories=%s,regions=%s,score=%s,minute=%s}", Integer.valueOf(this.f41oid), this.name,this.alias, this.publishedAt,this.cover,this.drama,this.categories,this.regions,Float.valueOf(this.score), Integer.valueof(this.minute));
}

我们可以逐个提取想要的字段,也可以直接使用 toString 方法获取所有字段。为了方便,我们采取后者,于是按下面这样修改 afterHookedMethod 方法的内容:

protected void afterHookedMethod(MethodHookParam param) throws Throwable {
    XposedBridge.log("Called afterHookedMethod");
    Listresults = (List)param.getResult();
    for (Object o: results) {
        XposedBridge.log(o.tostring());
        Stringentity = o.toString();
        XposedBridge.log("MovieEntity" + entity);
    }
}

这里我们遍历了 results 变量中的元素,每次都将当前元素赋值为变量 o,这个 o 其实就是 MovieEntity 对象,我们调用它的 toString 方法可以得到一个长字符串,这个字符串中包含 id、name、alias 等我们想要的字段信息。

现在重新运行一下 Xposed 模块和 App,并再次观察 XposedInstaller 的日志页面,结果如图 13-65 所示。

图13-65 日志内容

可以看到,每条电影数据都成功被解析出来并输出到了日志中,例如第一条电影数据:

MovieEntity { id=1, name=霸王别姬, alias=Farewell My Concubine, publishedAt=1993-07-26, cover=https://po.meituan.net/movie/ce4da3e03e655b5b88ed31b5cd7896cf62472.jpg@464w_644h_1e_1c, drama=影片借一出《霸王别姬》的京戏,牵扯出三个人之间一段随时代风云变幻的爱恨情优仇。段小楼(张丰毅饰)与程蝶衣(张国荣饰)是一对打小一起长大的师兄弟,两人一个演生,一个饰旦,一向配合天衣无缝,尤其一出《霸王别姬》,更是誉满京城,为此,两人约定合演一辈子《霸王别姬》。但两人对戏剧与人生关系的理解有本质不同,段小楼深知戏非人生,程蝶衣则是人戏不分。段小楼在认为该成家立业之时迎娶了名妓菊仙(巩俐饰),致使程蝶衣认定菊仙是可耻的第三者,使段小楼做了叛徒,自此,三人围绕一出《霸王别姬》生出的爱恨情仇战开始随着时代风云的变迁不断升级,终酿成悲剧。, categories=[剧情, 爱情], regions=[中国大陆, 中国香港], score=9.5, minute=171 }

我们想要信息都包含在这面!

数据保存

我们已经成功在手机端拿到电影数据了,还剩两个问题需要解决一一怎么把数据保存下来和保存到哪里?

如果直接保存在手机上,可行是可行,但是不方便我们做后续的数据处理:如果保存到指定的数据库中,那我们还需要从手机中进一步提取数据。两种方式好像都有弊端。

一个简单方便的方案是通过 API 把数据转发出来。我们可以自已搭建一个 HTTP 服务器用于接收数据,然后在手机上通过 HTTP 客户端程序把数据转发到刚搭建的服务器上,服务器接收到数据后直接入库。

那接下来我们就有两件事需要做。

  • 搭建服务器:搭建一个 HTTP 服务器,这个服务器可以接收 HTTP 客户端的请求,从请求中解析出数据,然后将数据保存下来。

  • 发送数据:在手机上通过 Xposed 模块截获数据后,将数据通过 HTTP 客户端程序发送到搭建的 HTTP 服务器上。

搭建 HTTP 服务器

我们可以使用一些轻量级的框架(例如 Flask)搭建 HTTP 服务器。Flask 提供一个支持 POST 请求的 API,能从请求体中解析出数据,然后做后续处理,代码实现如下:

这个实现过程非常简单,就是从请求体的表单数据中提取出 data 字段,然后将其打印出来。运行此 Python 脚本,Flask 会默认在 5000 端口上提供服务,运行结果如下:

如果手机和电脑处在同一局域网下,用手机其实就能访问到该服务器了,调用客户端程序直接发送数据即可。

如果手机和电脑不在同一局域网下,那么我们可以使用 ngrok 命令将电脑上的服务暴露出去:

ngrok http 5000

这个命令运行之后,会提供公网可以访问的 HTTP URL 和 HTTPS URL,这两个 URL 和电脑的 5000 端口相映射,这样即使手机和电脑不在同一局域网下,手机也能把数据发送给电脑。

发送数据

那怎么在手机上发送数据呢?我们可以借助 Android 中比较流行的 OkHttp 库,其 GitHub 地址是 https://github.com/square/okhttp 。在 XposedTest App 中的 build.gradle 文件中的 dependencies 部分添加对 okHttp 库的引用:

implementation 'com.squareup.okhttp3:okhttp3.10.0'

然后在刚才定义的 HookResponse 类中添加一个 sendDataToServer 方法,方法定义如下:

在sendDataToServer方法中,我们首先声明了一个server变量,在具体运行的时候,请把其值中的SERVERHOST修改成自己电脑的IP或者ngrok命令暴露出的地址。然后用OkHttp库构造了一个 RequestBody对象,该对象包括三个字段,其中data是字符串类型的数据;from是爬取来源,此处值为Xposed,代表数据是从Xposed模块爬取的;crawledat是当前的时间戳。最后新建了一个 OkHttpClient 对象,赋值给 client 变量,并根据 server 变量和 RequestBody 对象构造了一个 Request 对象,发起 HTTP 请求。再修改一下 afterHookedMethod 方法:

重新运行 Xposed 模块和 XposedTestApp,这时 Flask 服务器的输出结果如下:

以看出,在手机端获取的数据已经成功转发到 Flask 服务器上了!后面我们只需要完善一下 Flask 服务器的相关逻辑,对数据进行处理并保存即可,具体流程这里不再展开讲解。

总结

本节我们通过实例讲解了利用 Xposed 模块 Hook 关键方法的实现过程,利用 Xposed 模块,我们可以成功拦截想要的数据,还可以对数据做进一步处理,将其转发到电脑上保存起来。

有了 Xposed,我们几乎可以 Hook 所有方法来截获想要的内容,App 尽在我们掌握之中,“为所欲为” 不再是奢望,爬虫自然也不在话下。