基于 Airtest 的 App 爬取实战
本节中我们通过实例讲述如何使用 Airtest 爬取一个 App 。
思路分析
由于这里的爬取流程和 12.5 节的一样,因此可以通过对比感受使用 Airtest 和 Appium 的不同。具体的爬取原理这里不再赞述,同样可以参考12.5节。下面再总结一次基本的爬取流程。
-
遍历首页已有的所有电影条目,依次模拟点击每个电影条目,进入详情页。
-
爬取详情页的数据,之后模拟点击回退按钮返回首页。
-
当首页已有的电影条目即将爬取完毕时,模拟上拉操作,加载更多数据。
-
爬取过程中将已经爬取的数据记录下来,以免重复爬取。
-
100 条数据全部爬取完毕后,终止爬取。
实战爬取
请再次确保 app5 已经正常安装在了 Android 手机上,并且可以正常启动,然后打开 AirtestDE 切换到 Poco 模式,如图 12-81 所示。
图 12-81 做好准备后的 AirtestIDE 界面
本节中, AirtestIDE 仅仅是辅助我们审查节点属性的, 所以界面左侧可以只展示“Poco 辅助窗”, 中间栏只保留“Log 查看窗”, 右侧依旧展示“设备窗”。至于代码, 可以在单独的 Python 文件中编写, 不一定非要在这里。
首先引入一些必要的库, 并初始化一些变量:
from airtest.core.api import *
from poco.drivers.android.uiautomation import AndroidUiAutomati onPoco
poco = AndroidUiAutomati onPoco(
use_airtest_input=True, screenshot_each_action=False)
window_width, window_height = poco.get_screen_size()
PACKAGE_NAME = 'com.goldze.mvvmhabit'
TOTAL_NUMBER = 100
这里引入了 Airtest 的 API 和 AndroidUiAutomati onPoco 类, 然后初始化了 poco 对象。接着调用 poco 对象的 get_screen_size 方法获取了屏幕的宽高, 并分别赋值为 window_width 和 window_height。之后定义了两个常量,PACKAGENAME代表包名,TOTALNUMBER 代表爬取数据的总条数。
接下来就先爬取首页的所有电影数据,用AirtestIDE来查看一下节点的属性,选中一个电影条目,如图12-82所示。
图 12-82 选中首页的一个电影条目
从图 12-82 可以看到, 所选中节点的 name 是 com.goldze.mvvmhabit:id/item, 而且不会和其他层级节点的 name 有重复, 所以我们可以直接使用 name 属性选择节点, 实现一个 scrape_index 方法:
def scrape_index(): elements = poco(f'{PACKAGE_NAME}:id/item') elements.wait_for_appearance() return elements
这里直接将 name 作为参数传给了 poco 对象, 并赋值为 elements 变量, 然后调用它的 wait_for_ appearance 方法等待节点加载出来。加载出来后返回。在正常情况下, scrape_index 方法可以获得首 页当前呈现的所有电影条目。和 12.5 节一样, 我们定义一个 main 方法来调用 scrape_index 方法:
from loguru import logger
def main(): elements = scrape_index() for element in elements: element_data = scrape_detail(element) logger.debug(f’scraped data {element_data}')
if name == 'main': init_device("Android") stop_app(PACKAGE_NAME) start_app(PACKAGE_NAME) main()
在 main 方法中, 我们首先调用 scrape_index 方法提取了首页当前已有的所有电影条目, 赋值为 elements 变量。然后就遍历这个变量中的元素, 并希望通过一个 scrape_detail 方法爬取每部电影的详情信息,之后输出日志,返回。
这里提到的scrape_detail方法也和12.5节一样,基本实现思路如下。
-
模拟点击 element,即首页中的某个电影条目。
-
进入电影详情页之后,爬取详情信息。
-
点击回退按钮返回首页。
在 AirtestIDE 中,点击首页的任意一个电影条目,进入详情页,查看节点信息,如图12-83所示。
图 12-83 《霸王别姬》电影的详情页
可以看到整体详情信息的最外侧是 name 为 com.goldze.mvvmhabit:id/content 的面板, 内部是一 个个具体的 TextView, 所以这里可以先选定这个面板节点, 然后等待其加载, 加载出来之后, 再依次 选择标题、类别、评分等节点, 通过调用 attr 方法传入对应的属性名称 text, 即可获取节点文本。 scrape_detail 方法的实现如下:
def scrape_detail(element): element.click() panel = poco(f'{PACKAGE_NAME}:id/content') panel.wait_for_appearance() title = poco(f'{PACKAGE_NAME}:id/title').attr('text') categories = poco(f'{PACKAGE_NAME}:id/categories_value').attr('text') score = poco(f'{PACKAGE_NAME}:id/score_value').attr('text') published_at = poco(f'{PACKAGE_NAME}:id/published_at_value').attr('text') drama = poco(f'{PACKAGE_NAME}:id/drama_value').attr('text') keyevent('BACK') return { 'title': title, 'categories': categories, 'score': score, 'published_at': published_at, 'drama': drama }
这里 scrapy_detail 方法的 element 参数就是某个电影条目, 对应一个 UIObjectProxy 对象, 调 用其 click 方法就会跳转到对应的详情页, 然后爬取其中的信息, 爬取完毕后调用 keyevent 方法传 入 BACK 参数返回首页, 最后将爬取的信息返回即可。
运行一下代码, 结果如下:
[DEBUG]airtest.core.android.adb /usr/local/adb -s R5CN30RM0QL shell input keyevent BACK
2021-02-30 16:53:30.446 | DEBUG | __main__:45 - scraped data {'title': '霸王别姬', 'categories': '剧情,爱情', 'score':'9.5','published_at':'1993-07-26', 'drama':'影片借一出《霸王别姬》的京戏,牵扯出三个人之间一段随时代风云变幻的爱恨情仇。段小楼(张丰毅饰)与程蝶衣(张国荣饰)是一对打小一起长大的师兄弟,两个人演生、一个饰旦,一向配合天衣无缝。...'}[DEBUG]airtest.core.android.adb /usr/local/adb -s R5CN30RM0QL shell input keyevent BACK
会发现, 到目前为止, 我们已经可以成功获取首页最开始加载的几条电影信息了。
上拉加载逻辑
现在添加上拉加载逻辑——当爬取的节点对应的电影条目差不多位于页面高度的 80% 以下时, 就触发加载。将 main 方法改写如下:
def main():
elements = scrape_index()
for element in elements:
element_y = element.get_position()
if element_y > 0.8:
scroll_up()
element_data = scrape_detail(element)
logger.debug(f'scraped data {element_data}')
这里调用 element 的 get_position 方法获取了当前节点的纵坐标, 返回结果是 0 和 1 之间的数字, 而非绝对的像素点位置, 所以这里可以直接做判断, 当返回的数字大于 0.8 时, 就调用 scroll_up 方 法模拟上拉, 以加载新的数据。scroll_up 方法的定义如下:
def scroll_up():
swipe((window_width * 0.5, window_height * 0.8),
vector=[0, -0.5], duration=1)
这里我们直接调用了 Airtest API 里的 swipe 方法, 第一个参数是初始点击位置, 第二个参数是滑 动方向, 第三个参数是滑动时间 (这里传入 1, 代表 1 秒)。
这样, 在爬取过程中就可以自动触发下一页的数据的加载了。
去重、终止和保存数据
我们需要额外添加根据标题进行去重和判断终止的逻辑, 所以在遍历首页中每个电影条目的时候 还需要爬取一下标题, 并将其存入一个全局变量中。将 main 方法改写如下:
def main():
while len(scraped_titles) < TOTAL_NUMBER:
elements = scrape_index()
for element in elements:
element_title = element.offspring(f'{PACKAGE_NAME}:id/tv_title')
if not element_title.exists():
continue
title = element_title.attr('text')
logger.debug(f'get title {title}')
if title in scraped_titles:
continue
_, element_y = element.get_position()
if element_y > 0.7:
scroll_up()
element_data = scrape_detail(element)
scraped_titles.append(title)
这里我们调用 element 的 offspring 方法传入了标题对应的 name, 并提取了其内容, 然后声明全 局变量 scraped_titles 来存储已经爬取的电影标题。每次爬取之前, 先判断 title 是否已经存在于 scraped_titles 中, 如果已经存在, 就跳过, 否则接着爬取, 爬取完后将得到的标题存到 scraped_titles 里, 这样就实现去重了。另外, 我们在 main 方法中添加了 while 循环, 如果爬取的电影条目数尚未达 到目标数量 TOTAL_NUMBER, 就接着爬取, 直到爬取完毕。
保存数据
现在再添加一个保存数据的逻辑, 将爬取的数据以 JSON 形式保存保存到本地的 movie 文件夹, 相关方法的定义如下:
import os
import json
OUTPUT_FOLDER = 'movie'
os.path.exists(OUTPUT_FOLDER) or os.makedirs(OUTPUT_FOLDER)
def save_data(element_data):
with open(f'{OUTPUT_FOLDER}/{element_data["title"]}.json', 'w', encoding='utf-8') as f:
f.write(json.dumps(element_data, ensure_ascii=False, indent=2))
logger.debug(f'saved as file {element_data["title"]}.json')
最后在 main 方法添加对 save_data 方法的调用逻辑即可。
运行结果
运行一下最终的 main 方法, 控制台会输出如下结果:
[DEBUG]airtest.core.android.adb /usr/local/adb -s R5CN30RM0QL shell input keyevent BACK
2021-02-30 17:05:37.501 | DEBUG | __main__:74 - scraped data {'title': '霸王别姬', 'categories': '剧情,爱情', 'score':'9.5', 'published_at':'1993-07-26', 'drama':'影片借一出《霸王别姬》的京戏,牵扯出三个人之间一段随时代风云变幻的爱恨情仇。段小楼(张丰毅饰)与程蝶衣(张国荣饰)是一对打小一起长大的师兄弟,两个人演生、一个饰旦,一向配合天衣无缝。...'}
2021-02-30 17:05:37.503 | DEBUG | __main__:save_data:26 - saved as file 霸王别姬.json
2021-02-30 17:05:37.584 | DEBUG | __main__:main:67 - get title 这个杀手不太冷[DEBUG]airtest.core.android.adb /usr/local/adb -s R5CN30RM0QL shell input keyevent BACK
本地 movie 文件夹下生成的文件的如图 12-84 所示。
图12-84 本地生成的 JSON 文件
至此,我们成功爬取了示例 App 的所有电影数据,并保存为 JSON 文件,和12.5节的结果是一样的。