青南

青南 查看完整档案

上海编辑电子科技大学  |  电子信息工程 编辑高级研发工程师  |  NewsBreak 编辑 www.kingname.info 编辑
编辑

微软最有价值专家(MVP)。已出版图书《Python 爬虫开发,从入门到实战》、《左手 MongoDB,右手 Redis——从入门到商业实战》。独立开发维护开源项目 GNE(获得近2000 Star)。

个人动态

青南 发布了文章 · 2月18日

小问题大隐患:如何正确设置 Python 项目的入口文件?

今天在公众号粉丝群里面,有一位同学提到了 Python 找不到模块的问题:

问题涉及到的代码结构和代码截图如下:

这个问题的解决方法非常简单,就是把start.py文件从bin文件夹移出来就好了。

但如果对这个问题进一步分析,可以看到更多问题。

在我以前的文章:为什么Python代码能运行但是PyCharm给我画红线?中,我讲到了工作区(Workdir)对代码的影响。PyCharm、VSCode 识别的工作区,可能并不等于你直接在终端窗口运行.py文件时候的工作区。

今天这个问题本质上也是工作区导致的问题。 这个同学的项目根目录是MY_API,所以他使用的编辑器VSCode 就会默认把MY_API当做工作区。所以,当他在start.py文件中写上from lib.interface import server时,VScode 并不会给他标记红色波浪线。因为从 VSCode 的视角看,lib文件夹确实就是在工作区下面的。

但是,当他在 VSCode 里面运行这个start.py文件时,Python 是从bin文件夹下面运行的。此时,Python 会把bin文件夹当做工作区。在工作区里面就只有这一个start.py文件,所以当然找不到lib文件夹。

如果仅仅从技术上来说,你非要导入 bin 文件夹的父文件夹下面的其他模块,也并不困难,我在一日一技:导入父文件夹中的模块并读取当前文件夹内的资源一文中讲到了具体的做法。

但问题在于,你不应该这样做。你不应该把项目的入口文件,放到项目内部很深的文件夹中。

所谓入口文件,就是要首先经过它,才能到达其他的文件。当你拿到一个 Python 项目,你只需要首先从入口文件开始阅读代码,根据入口文件调用的模块,一路看下去,你就能读到它的所有实现逻辑。

但如果大家经常逛 Github,就会发现,有些人可能是被其他垃圾语言污染了思想,他的 Python 项目,根目录有五六个文件夹和七八个.py文件。你拿到这个项目的时候,你甚至不知道,当你想运行这个代码的时候,python3 xxx.py应该运行哪个文件。你多方打听,或者看了半天文档,才知道,哦,原来入口文件在com/xx/yy/zz/script/run.py

当你打开这个run.py文件,你发现它的顶部,文件导入的代码写的是from ../../../../aaa import bbb

简直是神经病写法。我知道有些垃圾语言流行这样写。但现在你用的是 Python,学聪明一点,别那样写。

对于一个 Python 项目来说,入口文件应该始终在最外层。例如:

当你要启动这个项目的时候,直接在最外层python3 main.py,就能把它启动起来。在main.py里面,你可以导入其他模块,然后调用其他模块里面的类或者函数。

这样做的好处是什么?这样做,你是在项目的根目录启动的这个项目,所以你的工作区就是项目的根目录。那么你在任何一个.py文件里面都可以很容易地基于工作区导入任意其他文件。例如,你现在在models/mongo-util/mongob_helper.py文件中,你想导入utils/abc.py中的time_format()函数,那么,你只需要这样写就可以了。

from utils.abc import time_format

你根本不可能出现需要导入父文件夹中的某个模块的情况。

只有工具脚本,才需要单独使用一个文件夹来存放,然后调用父文件夹中的其他文件。例如,我现在有一个工具脚本,它每天晚上0点会读写 MongoDB,清理无效数据,那么此时,我可以在根目录单独创建一个scripttools或者bin文件夹,然后把工具脚本放进去,例如:

在这个工具脚本里面,你可能会调用models/mongo-util/mongob_helper.py文件中的某个函数。这种情况下,你调用父文件夹中的内容是可以接受的。但这毕竟只是工具脚本。

可能还有同学要问,那如果我的项目是一个 Python 的包,它本身没有入口文件怎么办呢?这个时候,你可以把这个包的__init__.py当做它的入口文件。大家可以参考我在 GitHub - kingname/GeneralNewsExtractor: 新闻网页正文通用抽取器 Beta 版.的代码组织结构。在项目根目录留下一个example.py文件,用来演示如何调用这个包。而这个包本身的代码,是在一个叫做gne的文件夹中的。这个gne文件夹是一个包,它的入口文件在__init__.py中。

各位,当你写代码的时候,你先想一想,如果别人拿到了你的代码,想要梳理一下这个项目的逻辑,在不询问你的情况下,怎么让他知道应该从哪个文件开始读?应该按什么顺序读?他能不能轻易地看到数据在你的代码中是怎么运转的?

查看原文

赞 0 收藏 0 评论 0

青南 发布了文章 · 2月16日

在 Linux 服务器中创建假桌面运行模拟浏览器有头模式

经常使用 Selenium 或者 Puppeteer 的同学都知道,他们启动的 Chrome 浏览器分为有头模式和无头模式。在自己电脑上操作时,如果是有头模式,会弹出一个 Chrome 浏览器窗口,然后你能看到这个浏览器里面在自动操作。而无头模式则不会弹出任何窗口,只有进程。

别去送死了。Selenium 与 Puppeteer 能被网站探测的几十个特征这篇文章中,我们介绍了一个探测模拟浏览器特征的网站。通过他我们可以发现,在不做任何设置的情况下,Selenium 或者 Puppeteer 启动的浏览器有几十个特征能够被目标网站识别为爬虫。并且,无头模式的特征比有头模式的特征多得多。

也就是说,即使你不使用任何隐藏特征的技术,仅仅使用有头模式,你都会安全很多。如果网站不是非常严格的反爬虫,很多情况下,使用无头模式更容易被发现,但使用有头模式,更难被发现。

下图为有头模式,不使用任何隐藏特征的技术访问检测网站:

下图为无头模式,不使用任何隐藏特征的技术访问检测网站:

万里河山一片红

所以,一般情况下,你应该多使用有头模式。

但问题在于,当我们要在 Linux 服务器上面使用 Selenium 或者 Puppeteer 运行爬虫的时候,就会发现有头模式始终会报错。这是因为,有头模式需要系统提供图形界面支持,才能绘制浏览器窗口,但是 Linux 服务器一般来说是没有图形界面的,所以有头模式一定会失败。

在这种情况下,为了能够使用模拟浏览器的有头模式,我们需要搞一个假的图形界面出来,从而欺骗浏览器,让它的有头模式能够正常使用。

为了达到这个目的,我们可以使用一个叫做 Xvfb的东西。这个东西在维基百科上面的介绍如下:

Xvfb or X virtual framebuffer is a display server implementing the X11 display server protocol. In contrast to other display servers, Xvfb performs all graphical operations in virtual memory without showing any screen output.

Xvfb 在一个没有图像设备的机器上实现了 X11显示服务的协议。它实现了其他图形界面都有的各种接口,但并没有真正的图形界面。所以当一个程序在 Xvfb 中调用图形界面相关的操作时,这些操作都会在虚拟内存里面运行,只不过你什么都看不到而已。

使用 Xvfb,我们就可以欺骗 Selenium 或者 Puppeteer,让它以为自己运行在一个有图形界面的系统里面,这样一来就能够正常使用有头模式了。

要安装 Xvfb 非常简单,在 Ubuntu 中,只需要执行下面两行命令就可以了:

sudo apt-get update
sudo apt-get install xvfb

现在,我们来写一段非常简单的 Selenium 操作 Chrome 的代码:

import time
from selenium.webdriver import Chrome
driver = Chrome('./chromedriver')
driver.get('https://bot.sannysoft.com/')
time.sleep(5)
driver.save_screenshot('screenshot.png')
driver.close()
print('运行完成')

如果直接在服务器上运行,效果如下图所示:

因为没有图形界面,所以程序必定报错。

现在,我们只需要在运行这段代码的命令前面加上xvfb-run,再来看看运行效果:

代码成功运行,没有报错。现在我们从服务器上把这个生成的screenshot.png文件拉下来,打开以后可以看到内容如下:

可以看到,虽然窗口比较小,但确实是有头模式下面的检测结果。当然,我们也可以调整一下窗口大小,增加参数:xvfb-run python3 test.py -s -screen 0 1920x1080x16就能假装在一个分辨率为1920x1280的显示器上运行程序了。然后修改 Selenium 的代码,设置浏览器窗口的大小:

运行效果如下图所示:

本文演示使用的是 Python操作 Selenium,你也可以试一试使用 Puppeteer,只需要把启动命令改为xvfb-run node index.js就可以了。

有了本文以后,再结合我之前的两篇文章:

相信你的模拟浏览器能够绕过更多的网站。

查看原文

赞 11 收藏 6 评论 0

青南 分享了头条 · 2020-01-01

目前最好用的新闻网站通用抽取起,经过数百个网站测试,准确率接近100%

赞 0 收藏 0 评论 0

青南 发布了文章 · 2019-12-14

为什么每一个爬虫工程师都应该学习 Kafka

这篇文章不会涉及到Kafka 的具体操作,而是告诉你 Kafka 是什么,以及它能在爬虫开发中扮演什么重要角色。

一个简单的需求

假设我们需要写一个微博爬虫,老板给的需求如下:

开发爬虫对你来说非常简单,于是三下五除二你就把爬虫开发好了:

接下来开始做报警功能,逻辑也非常简单:

再来看看统计关键词的功能,这个功能背后有一个网页,会实时显示抓取数据量的变化情况,可以显示每分钟、每小时的某个关键词的抓取量。

这个功能对你来说也挺简单,于是你实现了如下逻辑:

最后一个需求,对微博数据进行情感分析。情感分析的模块有别的部门同事开发,你要做的就是每个小时拉取一批数据,发送到接口,获取返回,然后存入后端需要的数据库:

任务完成,于是你高兴地回家睡觉了。

困难接踵而至

爬虫变慢了

随着老板逐渐增加新的关键词,你发现每一次完整抓取的时间越来越长,一开始是2分钟抓取一轮,后来变成10分钟一轮,然后变成30分钟一轮,接下来变成1小时才能抓取一轮。随着延迟越来越高,你的报警越来越不准确,微博都发出来一小时了,你的报警还没有发出来,因为那一条微博还没有来得及入库。

你的爬虫技术非常好,能绕过所有反爬虫机制,你有无限个代理 IP,于是你轻轻松松就把爬虫提高到了每秒一百万并发。现在只需要1分钟你就能完成全部数据的抓取。这下没问题了吧。

可是报警还是没有发出来。这是怎么回事?

数据库撑不住了

经过排查,你发现了问题。数据抓取量上来了,但是 MongoDB 却无法同时接收那么多的数据写入。数据写入速度远远小于爬取数据,大量的数据堆积在内存中。于是你的服务器爆炸了。

你紧急搭建了100个数据库并编号0-99,对于抓取到的微博,先把每一条微博的 ID对100求余数,然后把数据存入余数对应的 MongoDB 中。每一台 MongoDB 的压力下降到了原来的1%。数据终于可以即时存进数据库里面了。

可是报警还是没有发出来,不仅如此,现在实时抓取量统计功能也不能用了,还有什么问题?

查询来不及了

现在报警程序要遍历100个数据库最近5分钟里面的每一条数据,确认是否有需要报警的内容。但是这个遍历过程就远远超过5分钟。

时间错开了

由于微博的综合搜索功能不是按照时间排序的,那么就会出现这样一种情况,早上10:01发的微博,你在12:02的时候才抓到。

不论你是在报警的时候筛选数据,还是筛选数据推送给 NLP 分析接口,如果你是以微博的发布时间来搜索,那么这一条都会被你直接漏掉——当你在10:05的时候检索10:00-10:05这5分钟发表的微博,由于这一条微博没有抓到,你自然搜索不到。

当你12:05开始检索12:00-12:05的数据时,你搜索的是发布时间为12:00-12:05的数据,于是10:01这条数据虽然是在12:02抓到的,但你也无法筛选出来。

那么是不是可以用抓取时间来搜索呢?例如10:05开始检索在10:00-10:05抓取到的数据,无论它的发布时间是多少,都检索出来。

这样做确实可以保证不漏掉数据,但这样做的代价是你必需保存、检索非常非常多的数据。例如每次抓取,只要发布时间是最近10小时的,都要保存下来。于是报警程序在检索数据时,就需要检索这5分钟入库的,实际上发布时间在10小时内的全部数据。

什么,你说每次保存之前检查一下这条微博是否已经存在,如果存在就不保存?别忘了批量写入时间都不够了,你还准备分一些时间去查询?

脏数据来了

老板突然来跟你说,关键词“篮球”里面有大量的关于 蔡徐坤的内容,所以要你把所有包含蔡徐坤的数据全部删掉。

那么,这个过滤逻辑放在哪里?放在爬虫的 pipelines.py 里面吗?那你要重新部署所有爬虫。今天是过滤蔡徐坤,明天是过滤范层层,后天是过滤王一博,每天增加关键词,你每天都得重新部署爬虫?

那你把关键词放在 Redis 或者 MongoDB 里面,每次插入数据前,读取所有关键词,看微博里面不包含再存。

还是那个问题,插入时间本来就不够了,你还要查数据库?

好,关键词过滤不放在爬虫里面了。你写了一个脚本,每分钟检查一次MongoDB新增的数据,如果发现包含 不需要的关键词,就把他删除。

现在问题来了,删除数据的程序每分钟检查一次,报警程序每5分钟检查一次。中间必定存在某些数据,还没有来得及删除,报警程序就报警了,老板收到报警来看数据,而你的删除程序又在这时把这个脏数据删了。

这下好了,天天报假警,狼来了的故事重演了。

5个问题1个救星

如果你在爬虫开发的过程中遇到过上面的诸多问题,那么,你就应该试一试使用 Kafka。一次性解决上面的所有问题。

把 Kafka 加入到你的爬虫流程中,那么你的爬虫架构变成了下面这样:

这看起来似乎和数据直接写进 MongoDB 里面,然后各个程序读取 MongoDB 没什么区别啊?那 Kafka 能解决什么问题?

我们来看看,在这个爬虫架构里面,我们将会用到的 Kafka 的特性:

与其说 Kafka 在这个爬虫架构中像 MongoDB,不如说更像 Redis 的列表。

现在来简化一下我们的模型,如果现在爬虫只有一个需求,就是搜索,然后报警。那么我们可以这样设计:

爬虫爬下来的数据,直接塞进 Redis 的列表右侧。报警程序从 Redis 列表左侧一条一条读取。读取一条检视一条,如果包含报警关键词,就报警。然后读取下一条。

这样做有什么好处?

因为报警程序直接从 Redis 里面一条一条读取,不存在按时间搜索数据的过程,所以不会有数据延迟的问题。由于 Redis 是单线程数据库,所以可以同时启动很多个报警程序。由于 lpop 读取一条就删除一条,如果报警程序因为某种原因崩溃了,再把它启动起来即可,它会接着工作,不会重复报警。

但使用 Redis 列表的优势也是劣势:列表中的信息只能消费1次,被弹出了就没有了。

所以如果既需要报警,还需要把数据存入 MongoDB 备份,那么只有一个办法,就是报警程序检查完数据以后,把数据存入 MongoDB。

可我只是一个哨兵,为什么要让我做后勤兵的工作?

一个报警程序,让它做报警的事情就好了,它不应该做储存数据的事情。

而使用 Kafka,它有 Redis 列表的这些好处,但又没有 Redis 列表的弊端!

我们完全可以分别实现4个程序,不同程序之间消费数据的快慢互不影响。但同一个程序,无论是关闭再打开,还是同时运行多次,都不会重复消费。

程序1:报警

从 Kafka 中一条一条读取数据,做报警相关的工作。程序1可以同时启动多个。关了再重新打开也不会重复消费。

程序2:储存原始数据

这个程序从 Kafka 中一条一条读取数据,每凑够1000条就批量写入到 MongoDB 中。这个程序不要求实时储存数据,有延迟也没关系。 存入MongoDB中也只是原始数据存档。一般情况下不会再从 MongoDB 里面读取出来。

程序3:统计

从 Kafka 中读取数据,记录关键词、发布时间。按小时和分钟分别对每个关键词的微博计数。最后把计数结果保存下来。

程序4:情感分析

从 Kafka 中读取每一条数据,凑够一批发送给 NLP 分析接口。拿到结果存入后端数据库中。

如果要清洗数据怎么办

4个需求都解决了,那么如果还是需要你首先移除脏数据,再分析怎么办呢?实际上非常简单,你加一个 Kafka(Topic) 就好了!

大批量通用爬虫

除了上面的微博例子以外,我们再来看看在开发通用爬虫的时候,如何应用 Kafka。

在任何时候,无论是 XPath 提取数据还是解析网站返回的 JSON,都不是爬虫开发的主要工作。爬虫开发的主要工作一直是爬虫的调度和反爬虫的开发。

我们现在写 Scrapy 的时候,处理反爬虫的逻辑和提取数据的逻辑都是写在一个爬虫项目中的,那么在开发的时候实际上很难实现多人协作。

现在我们把网站内容的爬虫和数据提取分开,实现下面这样一个爬虫架构:

爬虫开发技术好的同学,负责实现绕过反爬虫,获取网站的内容,无论是 HTML 源代码还是接口返回的JSON。拿到以后,直接塞进 Kafka。

爬虫技术相对一般的同学、实习生,需要做的只是从 Kafka 里面获取数据,不需要关心这个数据是来自于 Scrapy 还是 Selenium。他们要做的只是把这些HTML 或者JSON 按照产品要求解析成格式化的数据,然后塞进 Kafka,供后续数据分析的同学继续读取并使用。

如此一来,一个数据小组的工作就分开了,每个人做各自负责的事情,约定好格式,同步开发,互不影响。

为什么是 Kafka 而不是其他

上面描述的功能,实际上有不少 MQ 都能实现。但为什么是 Kafka 而不是其他呢?因为Kafka 集群的性能非常高,在垃圾电脑上搭建的集群能抗住每秒10万并发的数据写入量。而如果选择性能好一些的服务器,每秒100万的数据写入也能轻松应对。

总结

这篇文章通过两个例子介绍了 Kafka 在爬虫开发中的作用。作为一个爬虫工程师,作为我的读者。请一定要掌握 Kafka。

下一篇文章,我们来讲讲如何使用 Kafka。比你在网上看到的教程会更简单,更容易懂。

关注本公众号,回复“爬虫与Kafka”获取本文对应的思维导图原图。

查看原文

赞 15 收藏 10 评论 1

青南 发布了文章 · 2019-09-09

新闻类网页正文通用抽取器

项目起源

开发这个项目,源自于我在知网发现了一篇关于自动化抽取新闻类网站正文的算法论文——《基于文本及符号密度的网页正文提取方法》

这篇论文中描述的算法看起来简洁清晰,并且符合逻辑。但由于论文中只讲了算法原理,并没有具体的语言实现,所以我使用 Python 根据论文实现了这个抽取器。并分别使用今日头条、网易新闻、游民星空、观察者网、凤凰网、腾讯新闻、ReadHub、新浪新闻做了测试,发现提取效果非常出色,几乎能够达到100%的准确率。

项目现状

在论文中描述的正文提取基础上,我增加了标题、发布时间和文章作者的自动化探测与提取功能。

最后的输出效果如下图所示:

目前这个项目是一个非常非常早期的 Demo,发布出来是希望能够尽快得到大家的使用反馈,从而能够更好地有针对性地进行开发。

本项目取名为抽取器,而不是爬虫,是为了规避不必要的风险,因此,本项目的输入是 HTML,输出是一个字典。请自行使用恰当的方法获取目标网站的 HTML。

本项目现在不会,将来也不会提供主动请求网站 HTML 的功能。

如何使用

项目代码中的GeneralNewsCrawler.py提供了本项目的基本使用示例。

  • 本项目的测试代码在test文件夹中
  • 本项目的输入 HTML 为经过 JavaScript 渲染以后的 HTML,而不是普通的网页源代码。所以无论是后端渲染、Ajax 异步加载都适用于本项目。
  • 如果你要手动测试新的目标网站或者目标新闻,那么你可以在 Chrome 浏览器中打开对应页面,然后开启开发者工具,如下图所示:

Elements标签页定位到<html>标签,并右键,选择Copy-Copy OuterHTML,如下图所示

  • 当然,你可以使用 Puppeteer/Pyppeteer、Selenium 或者其他任何方式获取目标页面的JavaScript渲染后的源代码。
  • 获取到源代码以后,通过如下代码提取信息:
from GeneralNewsCrawler import GeneralNewsExtractor

extractor = GeneralNewsExtractor()
html = '你的目标网页正文'
result = extractor.extract(html)
print(result)

对大多数新闻页面而言,以上的写法就能够解决问题了。

但某些新闻网页下面会有评论,评论里面可能存在长篇大论,它们会看起来比真正的新闻正文更像是正文,因此extractor.extract()方法还有一个默认参数noise_mode_list,用于在网页预处理时提前把评论区域整个移除。

noise_mode_list的值是一个列表,列表里面的每一个元素都是 XPath,对应了你需要提前移除的,可能会导致干扰的目标标签。

例如,观察者网下面的评论区域对应的Xpath 为//div[@class="comment-list"]。所以在提取观察者网时,为了防止评论干扰,就可以加上这个参数:

result = extractor.extract(html, noise_node_list=['//div[@class="comment-list"]'])

test文件夹中的网页的提取结果,请查看result.txt

已知问题

  1. 目前本项目只适用于新闻页的信息提取。如果目标网站不是新闻页,或者是今日头条中的相册型文章,那么抽取结果可能不符合预期。
  2. 可能会有一些新闻页面出现抽取结果中的作者为空字符串的情况,这可能是由于文章本身没有作者,或者使用了已有正则表达式没有覆盖到的情况。

Todo

  • 使用一个配置文件来存放常量数据,而不是直接 Hard Code 写在代码中。
  • 允许自定义时间、作者的提取Pattern
  • 自动识别新闻列表页
  • 优化内容提取速度
  • 测试更多新闻网站
  • ……

交流沟通

  • 项目地址:https://github.com/kingname/G...
  • 如果您觉得GNE对您的日常开发或公司有帮助,请加作者微信 mxqiuchen(或扫描下方二维码) 并注明"GNE",作者会将你拉入群。

验证消息:GNE

查看原文

赞 6 收藏 5 评论 11

青南 评论了文章 · 2019-04-01

为什么你需要少看垃圾博客以及如何在Python里精确地四舍五入

今天又有一个Python初学者被中文技术博客中的垃圾文章给误导了。

这位初学者的问题是:

在Python中,如何精确地进行浮点数的四舍五入,保留两位小数?

如果你在Google或者百度上搜索,你会发现大量的来自CSDN或者简书上面的文章讲到这一点,但是他们的说法无外乎下面几种:

连例子都不举的垃圾文章

如下图所示,懒得吐槽。

使用round函数

他们举的例子为:

>>> round(1.234, 2)
1.23

这种文章,他只演示了四舍,但是却没有演示五入。所以如果你代码稍作修改,就会发现有问题:

>>> round(11.245, 2)
11.24

先放大再缩小

这种文章稍微好一点,知道多举几个例子:

然而这种文章也是漏洞百出,只要你多尝试几个数字就会发现问题,在Python 2和Python 3下面,效果是不一样的。先来看看Python 2下面的运行效果:

在Python 2里面,直接使用round1.125精确到两位小数后为1.13,而1.115精确到两位小数后是1.11

再来看看Python 3下面的效果:

在Python 3下面,1.125在精确到两位小数以后是1.12

他举的例子,在Python 3中先放大再缩小,也并不总是正确。

装逼货

还有一种装逼货,文章和先放大再缩小差不多,但是他还知道decimal这个模块。

不过他的使用方法,大家看他吧

具体原因不详 ????

不推荐使用这个方法???

这种人要先装个逼,表示自己知道有这样一个库,但是用起来发现有问题,而且不知道原因,所以不建议大家使用。

decimal是专门为高精度计算用的模块,他竟然说不建议大家使用???

round到底出了什么问题?

骂完了,我们来说说,在Python 3里面,round这个内置的函数到底有什么问题。

网上有人说,因为在计算机里面,小数是不精确的,例如1.115在计算机中实际上是1.1149999999999999911182,所以当你对这个小数精确到小数点后两位的时候,实际上小数点后第三位是4,所以四舍五入,因此结果为1.11

这种说法,对了一半。

因为并不是所有的小数在计算机中都是不精确的。例如0.125这个小数在计算机中就是精确的,它就是0.125,没有省略后面的值,没有近似,它确确实实就是0.125

但是如果我们在Python中把0.125精确到小数点后两位,那么它的就会变成0.12

>>> round(0.125, 2)
0.12

为什么在这里四舍了?

还有更奇怪的,另一个在计算机里面能够精确表示的小数0.375,我们来看看精确到小数点后两位是多少:

>>> round(0.375, 2)
0.38

为什么这里又五入了?

因为在Python 3里面,round对小数的精确度采用了四舍六入五成双的方式。

如果你写过大学物理的实验报告,那么你应该会记得老师讲过,直接使用四舍五入,最后的结果可能会偏高。所以需要使用奇进偶舍的处理方法。

例如对于一个小数a.bcd,需要精确到小数点后两位,那么就要看小数点后第三位:

  1. 如果d小于5,直接舍去
  2. 如果d大于5,直接进位
  3. 如果d等于5:

    1. d后面没有数据,且c为偶数,那么不进位,保留c
    2. d后面没有数据,且c为奇数,那么进位,c变成(c + 1)
    3. 如果d后面还有非0数字,例如实际上小数为a.bcdef,此时一定要进位,c变成(c + 1)

关于奇进偶舍,有兴趣的同学可以在维基百科搜索这两个词条:数值修约奇进偶舍

所以,round给出的结果如果与你设想的不一样,那么你需要考虑两个原因:

  1. 你的这个小数在计算机中能不能被精确储存?如果不能,那么它可能并没有达到四舍五入的标准,例如1.115,它的小数点后第三位实际上是4,当然会被舍去。
  2. 如果你的这个小数在计算机中能被精确表示,那么,round采用的进位机制是奇进偶舍,所以这取决于你要保留的那一位,它是奇数还是偶数,以及它的下一位后面还有没有数据。

如何正确进行四舍五入

如果要实现我们数学上的四舍五入,那么就需要使用decimal模块。

如何正确使用decimal模块呢?

看官方文档,不要看中文垃圾博客!!!

看官方文档,不要看中文垃圾博客!!!

看官方文档,不要看中文垃圾博客!!!

不要担心看不懂英文,Python已经推出了官方中文文档(有些函数的使用方法还没有翻译完成)。

我们来看一下:https://docs.python.org/zh-cn...

官方文档给出了具体的写法:

>>>Decimal('1.41421356').quantize(Decimal('1.000'))
Decimal('1.414')

那么我们来测试一下,0.1250.375分别保留两位小数是多少:

>>> from decimal import Decimal
>>> Decimal('0.125').quantize(Decimal('0.00'))
Decimal('0.12')
>>> Decimal('0.375').quantize(Decimal('0.00'))
Decimal('0.38')

怎么结果和round一样?我们来看看文档中quantize的函数原型和文档说明:

这里提到了可以通过指定rounding参数来确定进位方式。如果没有指定rounding参数,那么默认使用上下文提供的进位方式。

现在我们来查看一下默认上下文中的进位方式是什么:

>>> from decimal import getcontext
>>> getcontext().rounding
'ROUND_HALF_EVEN'

如下图所示:

ROUND_HALF_EVEN实际上就是奇进偶舍!如果要指定真正的四舍五入,那么我们需要在quantize中指定进位方式为ROUND_HALF_UP

>>> from decimal import Decimal, ROUND_HALF_UP
>>> Decimal('0.375').quantize(Decimal('0.00'), rounding=ROUND_HALF_UP)
Decimal('0.38')
>>> Decimal('0.125').quantize(Decimal('0.00'), rounding=ROUND_HALF_UP)
Decimal('0.13')

现在看起来一切都正常了。

那么会不会有人进一步追问一下,如果Decimal接收的参数不是字符串,而是浮点数会怎么样呢?

来实验一下:


>>> Decimal(0.375).quantize(Decimal('0.00'), rounding=ROUND_HALF_UP)
Decimal('0.38')
>>> Decimal(0.125).quantize(Decimal('0.00'), rounding=ROUND_HALF_UP)
Decimal('0.13')

那是不是说明,在Decimal的第一个参数,可以直接传浮点数呢?

我们换一个数来测试一下:

>>> Decimal(11.245).quantize(Decimal('0.00'), rounding=ROUND_HALF_UP)
Decimal('11.24')
>>> Decimal('11.245').quantize(Decimal('0.00'), rounding=ROUND_HALF_UP)
Decimal('11.25')

为什么浮点数11.245和字符串'11.245',传进去以后,结果不一样?

我们继续在文档在寻找答案。

官方文档已经很清楚地说明了,如果你传入的参数为浮点数,并且这个浮点值在计算机里面不能被精确存储,那么它会先被转换为一个不精确的二进制值,然后再把这个不精确的二进制值转换为等效的十进制值

对于不能精确表示的小数,当你传入的时候,Python在拿到这个数前,这个数就已经被转成了一个不精确的数了。所以你虽然参数传入的是11.245,但是Python拿到的实际上是11.244999999999...

但是如果你传入的是字符串'11.245',那么Python拿到它的时候,就能知道这是11.245,不会提前被转换为一个不精确的值,所以,建议给Decimal的第一个参数传入字符串型的浮点数,而不是直接写浮点数。

总结,如果想实现精确的四舍五入,代码应该这样写:

from decimal import Decimal, ROUND_HALF_UP

origin_num = Decimal('11.245')
answer_num = origin_num.quantize(Decimal('0.00'), rounding=ROUND_HALF_UP)
print(answer_num)

运行效果如下图所示:

特别注意,一旦要做精确计算,那么就不应该再单独使用浮点数,而是应该总是使用Decimal('浮点数')。否则,当你赋值的时候,精度已经被丢失了,建议全程使用Decimal举例:

a = Decimal('0.1')
b = Decimal('0.2')
c = a + b
print(c)

最后,如果有同学想知道为什么0.125和0.375能被精确的储存,而1.115、11.245不能被精确储存,请在这篇文章下面留言,如果想知道的同学多,我就写一篇文章来说明。

如果这篇文章对你有帮助,请考虑关注我的微信公众号 未闻Code:

查看原文

青南 发布了文章 · 2019-03-31

为什么你需要少看垃圾博客以及如何在Python里精确地四舍五入

今天又有一个Python初学者被中文技术博客中的垃圾文章给误导了。

这位初学者的问题是:

在Python中,如何精确地进行浮点数的四舍五入,保留两位小数?

如果你在Google或者百度上搜索,你会发现大量的来自CSDN或者简书上面的文章讲到这一点,但是他们的说法无外乎下面几种:

连例子都不举的垃圾文章

如下图所示,懒得吐槽。

使用round函数

他们举的例子为:

>>> round(1.234, 2)
1.23

这种文章,他只演示了四舍,但是却没有演示五入。所以如果你代码稍作修改,就会发现有问题:

>>> round(11.245, 2)
11.24

先放大再缩小

这种文章稍微好一点,知道多举几个例子:

然而这种文章也是漏洞百出,只要你多尝试几个数字就会发现问题,在Python 2和Python 3下面,效果是不一样的。先来看看Python 2下面的运行效果:

在Python 2里面,直接使用round1.125精确到两位小数后为1.13,而1.115精确到两位小数后是1.11

再来看看Python 3下面的效果:

在Python 3下面,1.125在精确到两位小数以后是1.12

他举的例子,在Python 3中先放大再缩小,也并不总是正确。

装逼货

还有一种装逼货,文章和先放大再缩小差不多,但是他还知道decimal这个模块。

不过他的使用方法,大家看他吧

具体原因不详 ????

不推荐使用这个方法???

这种人要先装个逼,表示自己知道有这样一个库,但是用起来发现有问题,而且不知道原因,所以不建议大家使用。

decimal是专门为高精度计算用的模块,他竟然说不建议大家使用???

round到底出了什么问题?

骂完了,我们来说说,在Python 3里面,round这个内置的函数到底有什么问题。

网上有人说,因为在计算机里面,小数是不精确的,例如1.115在计算机中实际上是1.1149999999999999911182,所以当你对这个小数精确到小数点后两位的时候,实际上小数点后第三位是4,所以四舍五入,因此结果为1.11

这种说法,对了一半。

因为并不是所有的小数在计算机中都是不精确的。例如0.125这个小数在计算机中就是精确的,它就是0.125,没有省略后面的值,没有近似,它确确实实就是0.125

但是如果我们在Python中把0.125精确到小数点后两位,那么它的就会变成0.12

>>> round(0.125, 2)
0.12

为什么在这里四舍了?

还有更奇怪的,另一个在计算机里面能够精确表示的小数0.375,我们来看看精确到小数点后两位是多少:

>>> round(0.375, 2)
0.38

为什么这里又五入了?

因为在Python 3里面,round对小数的精确度采用了四舍六入五成双的方式。

如果你写过大学物理的实验报告,那么你应该会记得老师讲过,直接使用四舍五入,最后的结果可能会偏高。所以需要使用奇进偶舍的处理方法。

例如对于一个小数a.bcd,需要精确到小数点后两位,那么就要看小数点后第三位:

  1. 如果d小于5,直接舍去
  2. 如果d大于5,直接进位
  3. 如果d等于5:

    1. d后面没有数据,且c为偶数,那么不进位,保留c
    2. d后面没有数据,且c为奇数,那么进位,c变成(c + 1)
    3. 如果d后面还有非0数字,例如实际上小数为a.bcdef,此时一定要进位,c变成(c + 1)

关于奇进偶舍,有兴趣的同学可以在维基百科搜索这两个词条:数值修约奇进偶舍

所以,round给出的结果如果与你设想的不一样,那么你需要考虑两个原因:

  1. 你的这个小数在计算机中能不能被精确储存?如果不能,那么它可能并没有达到四舍五入的标准,例如1.115,它的小数点后第三位实际上是4,当然会被舍去。
  2. 如果你的这个小数在计算机中能被精确表示,那么,round采用的进位机制是奇进偶舍,所以这取决于你要保留的那一位,它是奇数还是偶数,以及它的下一位后面还有没有数据。

如何正确进行四舍五入

如果要实现我们数学上的四舍五入,那么就需要使用decimal模块。

如何正确使用decimal模块呢?

看官方文档,不要看中文垃圾博客!!!

看官方文档,不要看中文垃圾博客!!!

看官方文档,不要看中文垃圾博客!!!

不要担心看不懂英文,Python已经推出了官方中文文档(有些函数的使用方法还没有翻译完成)。

我们来看一下:https://docs.python.org/zh-cn...

官方文档给出了具体的写法:

>>>Decimal('1.41421356').quantize(Decimal('1.000'))
Decimal('1.414')

那么我们来测试一下,0.1250.375分别保留两位小数是多少:

>>> from decimal import Decimal
>>> Decimal('0.125').quantize(Decimal('0.00'))
Decimal('0.12')
>>> Decimal('0.375').quantize(Decimal('0.00'))
Decimal('0.38')

怎么结果和round一样?我们来看看文档中quantize的函数原型和文档说明:

这里提到了可以通过指定rounding参数来确定进位方式。如果没有指定rounding参数,那么默认使用上下文提供的进位方式。

现在我们来查看一下默认上下文中的进位方式是什么:

>>> from decimal import getcontext
>>> getcontext().rounding
'ROUND_HALF_EVEN'

如下图所示:

ROUND_HALF_EVEN实际上就是奇进偶舍!如果要指定真正的四舍五入,那么我们需要在quantize中指定进位方式为ROUND_HALF_UP

>>> from decimal import Decimal, ROUND_HALF_UP
>>> Decimal('0.375').quantize(Decimal('0.00'), rounding=ROUND_HALF_UP)
Decimal('0.38')
>>> Decimal('0.125').quantize(Decimal('0.00'), rounding=ROUND_HALF_UP)
Decimal('0.13')

现在看起来一切都正常了。

那么会不会有人进一步追问一下,如果Decimal接收的参数不是字符串,而是浮点数会怎么样呢?

来实验一下:


>>> Decimal(0.375).quantize(Decimal('0.00'), rounding=ROUND_HALF_UP)
Decimal('0.38')
>>> Decimal(0.125).quantize(Decimal('0.00'), rounding=ROUND_HALF_UP)
Decimal('0.13')

那是不是说明,在Decimal的第一个参数,可以直接传浮点数呢?

我们换一个数来测试一下:

>>> Decimal(11.245).quantize(Decimal('0.00'), rounding=ROUND_HALF_UP)
Decimal('11.24')
>>> Decimal('11.245').quantize(Decimal('0.00'), rounding=ROUND_HALF_UP)
Decimal('11.25')

为什么浮点数11.245和字符串'11.245',传进去以后,结果不一样?

我们继续在文档在寻找答案。

官方文档已经很清楚地说明了,如果你传入的参数为浮点数,并且这个浮点值在计算机里面不能被精确存储,那么它会先被转换为一个不精确的二进制值,然后再把这个不精确的二进制值转换为等效的十进制值

对于不能精确表示的小数,当你传入的时候,Python在拿到这个数前,这个数就已经被转成了一个不精确的数了。所以你虽然参数传入的是11.245,但是Python拿到的实际上是11.244999999999...

但是如果你传入的是字符串'11.245',那么Python拿到它的时候,就能知道这是11.245,不会提前被转换为一个不精确的值,所以,建议给Decimal的第一个参数传入字符串型的浮点数,而不是直接写浮点数。

总结,如果想实现精确的四舍五入,代码应该这样写:

from decimal import Decimal, ROUND_HALF_UP

origin_num = Decimal('11.245')
answer_num = origin_num.quantize(Decimal('0.00'), rounding=ROUND_HALF_UP)
print(answer_num)

运行效果如下图所示:

特别注意,一旦要做精确计算,那么就不应该再单独使用浮点数,而是应该总是使用Decimal('浮点数')。否则,当你赋值的时候,精度已经被丢失了,建议全程使用Decimal举例:

a = Decimal('0.1')
b = Decimal('0.2')
c = a + b
print(c)

最后,如果有同学想知道为什么0.125和0.375能被精确的储存,而1.115、11.245不能被精确储存,请在这篇文章下面留言,如果想知道的同学多,我就写一篇文章来说明。

如果这篇文章对你有帮助,请考虑关注我的微信公众号 未闻Code:

查看原文

赞 16 收藏 7 评论 4

青南 评论了文章 · 2019-03-29

全面超越Appium,使用Airtest超快速开发App爬虫

想开发网页爬虫,发现被反爬了?想对 App 抓包,发现数据被加密了?不要担心,使用 Airtest 开发 App 爬虫,只要人眼能看到,你就能抓到,最快只需要2分钟,兼容 Unity3D、Cocos2dx-*、Android 原生 App、iOS App、Windows Mobile……。

Airtest是网易开发的手机UI界面自动化测试工具,它原本的目的是通过所见即所得,截图点击等等功能,简化手机App图形界面测试代码编写工作。

爬虫开发本着天下工具为我所用,能让我获取数据的工具都能用来开发爬虫这一信念,决定使用Airtest来开发手机App爬虫。

安装和使用

由于本文的目的是介绍如何使用Airtest来开发App爬虫,那么Airtest作为测试开发工具的方法介绍将会一带而过,仅仅说明如何安装并进行基本的操作。

安装Airtest

从Airtest官网:https://airtest.netease.com下载Airtest,然后像安装普通软件一样安装即可。安装过程没有什么需要特别说明的地方。Airtest已经帮你打包好了开发需要的全部环境,所以安装完成Airtest以后就能够直接使用了。

Airtest运行以后的界面如下图所示。

连接手机

以Android手机为例,由于Airtest会通过adb命令安装两个辅助App到手机上,再用adb命令通过控制这两个辅助App进而控制手机,因此首先需要确保手机的adb调试功能是打开的,并允许通过adb命令安装App到手机上。

启动Airtest以后,把Android手机连接到电脑上,点击下图方框中的refresh ADB

此时在Airtest界面右上角应该能够看到手机的信息,如下图所示。

点击connect按钮,此时可以在界面上看到手机的界面,并且当你手动操作手机屏幕时,Airtest中的手机画面实时更新。如下图所示。

对于某些手机,例如小米,在第一次使用Airtest时,请注意手机上将会弹出提示,询问你是否允许安装App,此时需要点击允许按钮。

打开微信

先通过一个简单的例子,来看看如何快速上手Airtest,稍后再来详解。

例如我现在想使用电脑控制手机,打开微信。

此时,点击下图中方框框住的touch按钮:

此时,把鼠标移动到Airtest右边的手机屏幕区域,鼠标会变成十字型。在微信图标的左上角按下鼠标左键不放,并拖到微信右下角松开鼠标。此时请注意中间代码区域发生了什么变化,如下图所示。

好了。以上就是你需要使用电脑打开微信所要进行的全部操作。

点击上方工具栏中的三角形图标,运行代码,如下图所示。

代码运行完成以后,微信被打开了。

界面介绍

在有了一个直观的使用以后,我们再来介绍一下Airtest的界面,将会更加有针对性。

Airtest的界面如下图所示。

这里,我把Airtest分成了A-F6个区域,他们的功能如下:

  • A区:常用操作功能区
  • B区:Python代码编写区
  • C区:运行日志区
  • D区:手机屏幕区
  • E区:App页面布局信息查看区
  • F区:工具栏

A区是常用的基于图像识别的屏幕操作功能,例如:

  • touch: 点击屏幕元素
  • swipe: 滑动屏幕
  • exists: 判断屏幕元素是否存在
  • text: 在输入框中输入文字
  • snashot: 截图
  • ……

一般来说,是点击A区里面的某一个功能,然后在D区屏幕上进行框选操作,B区就会自动生成相应的操作代码。

B区用来显示和编写Python代码。在多数情况下,不需要手动写代码,因为代码会根据你在手机屏幕上面的操作自动生成。只有一些需要特别定制化的动作才需要修改代码。

D区显示了手机屏幕,当你操作手机真机时,这个屏幕会实时刷新。你也可以直接在D区屏幕上使用鼠标操作手机,你的操作动作会被自动在真机上执行。

F区是一些常用工具,从左到右,依次为:

  1. 新建项目
  2. 打开项目
  3. 保存项目
  4. 运行代码
  5. 停止代码
  6. 查看运行报告

其中1-5很好理解,那么什么是查看运行报告呢?

当你至少运行了一次以后,点击这个功能,会自动给你打开一个网页。网页如下图所示,这是你的代码的运行报告,详细到每一步操作了什么元素。

通过截图功能操作手机虽然方便,但是截图涉及到分辨率的问题,代码不能在不同的手机上通用。所以对于A区的功能,做点简单操作即可,不用深入了解。

更高级的功能,需要通过E区实现。

基于App布局信息操作手机

初始化代码

App的布局信息就像网页的HTML一样,保存了App上面各个元素的相对位置和各个参数。对于一个App而言,在不同分辨率的手机上,可能相同的元素有着不同的坐标点,但是这个元素的属性参数一般是不会变的。因此,如果使用元素的属性参数来寻找并控制这个元素,就能实现在不同分辨率手机上的精确定位。

App的布局信息的格式与App的开发环境有关。点击F区的下拉菜单,可以看到这里能够指定不同的App开发环境。其中的UnityCocos-*等等一般是做游戏用的,Android是安卓原生App,iOS是苹果的App……如下图所示。

以手机版知乎为例,由于它是Android原生的App,所以在F区下拉菜单选择Android,此时注意B区弹出提示,询问你是否要插入poco初始代码到当前输入光标的位置,点击Yes,如下图所示。

此时,B区自动插入了一段代码,如下图所示。

定位并点击

现在,点击E区的锁形图标,如下图所示。

锁形图标激活以后,你再操作D区的屏幕,点击知乎App下面的知乎两个字,会发现屏幕上被点击的App并不会打开。但E区和C区却发生了变化,如下图所示。

其中E区显示的树状结构就是当前屏幕的布局信息,这与Chrome开发者工具里面显示的HTML结构如出一辙。C区显示的是当前被我点中的元素的信息。

请注意在这些元素信息中,有一个text属性,它的值为知乎。那么,这个属性就可以作为一个定位元素,于是可以在B区编写代码:

poco(text="知乎").click()

写完代码以后运行程序,可以看到知乎App被打开了。如下图所示。

注意,如果你发现手机真机显示的界面与Airtest屏幕显示的手机界面不一致,可能是因为Airtest的屏幕被你锁定了。在F区点一下锁形图标,取消锁定,Airtest中的手机屏幕就会更新了。

定位并输入

打开知乎以后,我想使用知乎的搜索功能,那么继续,把锁形图标激活,然后点击知乎顶部的搜索框,如下图所示:

继续看C区显示的搜索框属性,可以看到这里有一个name属性,它的值是com.zhihu.android:id/input,还有一个text属性,它的值为蔡徐坤任 NBA 新春贺岁大使。能不能像前面打开知乎一样,使用text这个属性呢?也行,也不行。说它行,是因为你这么做确实现在能工作;说它不行,因为这是知乎的热门搜索关键词,随时会改变。你今天使用这一句话成功了,明天热门关键词变化了,那么你的代码就无法使用了。所以此时需要使用name这个属性。

常见的基本上不会变化的属性包含但不限于:nametyperesourceIdpackage

另外还有一点,知乎首页的这个搜索框,实际上是不能输入内容的,当你点击以后,会跳转到另一个页面,如下图所示。

因此你需要先点击一下这个输入框,跳转到真正的搜索界面:

poco(name="com.zhihu.android:id/input").click()

在真正的搜索界面如下图所示。

可以看到,name属性的值依然是com.zhihu.android:id/input,此时就可以输入内容了。

输入内容使用的方法为set_text,用法为:

poco(name="com.zhihu.android:id/input").set_text('古剑奇谭三')

定位并筛选

输入了搜索关键词以后,再来看看当前页面,搜索出现了三个结果:

通过对比这三个结果的属性信息,发现他们的name属性都是相同的,而text不同。如果像下面这样写点击动作:

poco(name='com.zhihu.android:id/magi_title').click()

那么默认就会点击第一个搜索结果。

如果我想点击第二个搜索结果怎么办呢?可以这样写代码:

poco(name='com.zhihu.android:id/magi_title', text='古剑奇谭(电视剧)').click()

或者你也可以像列表一样使用索引定位:

poco(name='com.zhihu.android:id/magi_title')[1].click()

这两种写法的前提,都是我们已经知道了每个结果分别是什么。假设现在我就想搜索古剑奇谭三,但我不知道搜索结果是第几项,又应该怎么办呢?此时还可以使用正则表达式:

poco(name='com.zhihu.android:id/magi_title', textMatches='^古剑奇谭三.*$').click()

滑动屏幕

进入搜索结果以后,需要查看下面的各种问题,此时就需要不断向上滑动屏幕。这里有一点需要特别注意,Airtest只能获取当前屏幕上的元素布局信息,不在屏幕上的内容是无法获取的。这一点和Selenium是不一样的。

滑动屏幕使用的命令为swipe,滑动屏幕需要使用坐标信息。但这种坐标和屏幕分辨率无关。这里的坐标定义为:(x, y),其中x为横坐标,y为纵坐标。屏幕左上角为(0, 0),屏幕右下角为(1, 1),从左向右,横坐标从0逐渐增大到1,从上到下,纵坐标从0逐渐增大到1。

现在我要把屏幕向上滑动,那么在真机上面,我是先按住屏幕下方,然后把屏幕向上滑动,所以代码可以这样写:


# poco.swipe(起点坐标,终点左边)
poco.swipe([0.5, 0.8], [0.5, 0.2])

方向示意图如下图所示:

在一般情况下:

  • 向上滑动,只需要改动纵坐标,且起点值大于终点值
  • 向下滑动,只需要改动纵坐标,且起点值小于终点值
  • 向左滑动,只需要改动横坐标,且起点值大于终点值
  • 向右滑动,只需要改动横坐标,且起点值小于终点值

在爬虫开发中,涉及到的Airtest操作基本上已经介绍完毕。

单独使用Python控制手机

在Airtest操作手机虽然方便,但是不可能在每一台电脑上都安装Airtest吧。所以需要想办法把代码从Airtest这个程序中分离出来。

Airtest基于Python的一个开源库Poco开发,而在Airtest的B区写的Python代码,实际上就是Poco的代码。所以只要安装Poco库,就可以在Python中直接控制手机。

安装Poco库的命令为:

pip install pocoui

这个库依赖的东西有点多,安装稍稍慢一些。安装完成以后,我们把代码复制到PyCharm中,如下图所示。

运行这段代码,如果是Linux或者macOS的用户,请注意看运行结果是不是有报错,提示adb没有运行权限。这是因为随Poco安装的adb没有运行权限,需要给它添加权限,在终端执行命令:

# chmod +x 报错信息中给出的adb地址

chmod +x /Users/kingname/.local/share/virtualenvs/ZhihuSpider/lib/python3.7/site-packages/airtest/core/android/static/adb/mac/adb(实际执行时请换成你的地址)

命令运行完成以后再次执行代码,可以看到代码运行成功,手机被成功控制了,如下图所示。

如何获取屏幕文字

由于Airtest的编辑器中的代码运行后无法正常打印出中文,因此后面的代码都直接在PyCharm中执行。

既然要做爬虫,就需要获取手机上的文字内容。回到搜索页面,我想知道“古剑奇谭”三这个关键字能搜索出多少条结果,每条结果有多少个讨论,如下图所示:

此时我们需要做两件事情:

  1. 分别查看每一个搜索结果
  2. 获取屏幕上的文字

E区的树状结构如下图所示:

每一个搜索结果的标题作为text属性的值,在name='com.zhihu.android:id/magi_title'对应的元素中;每一个搜索结果的讨论数作为text属性的值,在name='com.zhihu.android:id/magi_count'对应的元素中。

最直接的做法就是分别获取三个标题和三个讨论数,然后把它们合并在一起:

title_obj_list = poco(name='com.zhihu.android:id/magi_title')
title_list = [title.get_text() for title in title_obj_list]

discuss_obj_list = poco(name='com.zhihu.android:id/magi_count')
discuss_list = [discuss.get_text() for discuss in discuss_obj_list]

for title, discuss in zip(title_list, discuss_list):
    print(title, discuss)

运行效果如下图所示:

但是这种做法实际上是很危险的,假设会有某一个很生僻的搜索结果,只有标题没有讨论数,那么这样分开抓取再组合的做法,就会导致最后匹配错位。所以合理的做法是先抓大再抓小。每一组标题和讨论数,他们都有自己的父节点,如下图箭头所指向的三个android.widget.LinearLayout:

那么现在,使用先抓大再抓小的技巧,先把每一组结果的父节点抓下来,再到每一个结果里面分别获取标题和讨论数。

然而这个父节点又怎么获取呢?如下图所示,这个父节点每一个属性值都没有什么特殊的,写任何一个都有可能与别的节点撞上。

此时,最简单的办法,就是在E区,双击父节点。定位代码就会自动添加,如下图所示。

这个定位代码看起来非常复杂,但实际上它的内在逻辑非常简单,就是从顶层一层一层往下找而已。

自动生成的定位代码如下:

poco("android.widget.LinearLayout").offspring("com.zhihu.android:id/action_bar_root").offspring("com.zhihu.android:id/parent_fragment_content_id").offspring("android.support.v7.widget.RecyclerView").child("android.widget.LinearLayout")[0]

在这个自动生成的定位代码中,我们看到了offspringchild这两种方法。其中child代表子节点,offspring代表孙节点、孙节点的子节点、孙节点的孙节点……。简言之,使用child只会在子节点中搜索需要的内容,而使用offspring会像文件夹递归一样把里面的所有节点都遍历一次,直到找到符合条件的属性为止。显然,offspring速度会比child慢。

实际上,我们可以对这个定位代码做一些精简:

poco("com.zhihu.android:id/parent_fragment_content_id").offspring("android.support.v7.widget.RecyclerView").child("android.widget.LinearLayout")[0]

这个精简的方法,与从Chrome复制的XPath中进行精简是一样的逻辑,根本原则就是找到“独一无二”的属性值,然后用这个属性值来进行定位。

由于我点击的是第一个搜索结果,所以定位代码的最后有一个[0]。现在由于需要获得所有搜索结果的内容,所以应该去掉[0]而使用for循环展开,然后获取里面的内容:

result_obj = poco("com.zhihu.android:id/parent_fragment_content_id").offspring("android.support.v7.widget.RecyclerView").child("android.widget.LinearLayout")
for result in result_obj:
    title = result.child(name='com.zhihu.android:id/magi_title').get_text()
    count = result.child(name='com.zhihu.android:id/magi_count').get_text()
    print(title, count)

运行效果如下图所示。

控制多台手机

当我们在电脑上插入多个Android手机时,执行命令:

adb devices -l

运行效果如下图所示。

每个手机都会被列出来。在最左边的编号就是手机串号。使用这个串号可以指定多个手机:

from airtest.core.api import auto_setup
from airtest.core.android import Android
from poco.drivers.android.uiautomation import AndroidUiautomationPoco
auto_setup(__file__)

device_1 = Android('76efadf3a7ce4')
device_2 = Android('adfasdfasf23')
device_3 = Android('adifu39ernla')

poco_1 = AndroidUiautomationPoco(device_1, use_airtest_input=True, screenshot_each_action=False)
poco_2 = AndroidUiautomationPoco(device_2, use_airtest_input=True, screenshot_each_action=False)
poco_3 = AndroidUiautomationPoco(device_3, use_airtest_input=True, screenshot_each_action=False)

通过这种方式,在一台电脑上使用USBHub,连上二三十台手机是完全没有问题的。

无线模式

Airtest支持无线模式,不需要USB,只要电脑和手机连接同一个WIFI就能控制:

如果大家对如何开启无线模式有兴趣,请留言,我就会继续写。

搭建手机爬虫集群

一台电脑可以连接三十台手机,那么如果有很多电脑和很多手机,就可以实现手机爬虫集群,其运行效果如下图所示。

关于如何搭建爬虫集群,已经超出本文的范围了。如果大家有兴趣,可以阅读我的书:Python爬虫开发 从入门到实战第十章对于如何搭建手机爬虫集群有详细的说明和注意事项。

如果对我的书有兴趣,请关注我的微信公众号与我交流。

查看原文

青南 评论了文章 · 2019-03-27

全面超越Appium,使用Airtest超快速开发App爬虫

想开发网页爬虫,发现被反爬了?想对 App 抓包,发现数据被加密了?不要担心,使用 Airtest 开发 App 爬虫,只要人眼能看到,你就能抓到,最快只需要2分钟,兼容 Unity3D、Cocos2dx-*、Android 原生 App、iOS App、Windows Mobile……。

Airtest是网易开发的手机UI界面自动化测试工具,它原本的目的是通过所见即所得,截图点击等等功能,简化手机App图形界面测试代码编写工作。

爬虫开发本着天下工具为我所用,能让我获取数据的工具都能用来开发爬虫这一信念,决定使用Airtest来开发手机App爬虫。

安装和使用

由于本文的目的是介绍如何使用Airtest来开发App爬虫,那么Airtest作为测试开发工具的方法介绍将会一带而过,仅仅说明如何安装并进行基本的操作。

安装Airtest

从Airtest官网:https://airtest.netease.com下载Airtest,然后像安装普通软件一样安装即可。安装过程没有什么需要特别说明的地方。Airtest已经帮你打包好了开发需要的全部环境,所以安装完成Airtest以后就能够直接使用了。

Airtest运行以后的界面如下图所示。

连接手机

以Android手机为例,由于Airtest会通过adb命令安装两个辅助App到手机上,再用adb命令通过控制这两个辅助App进而控制手机,因此首先需要确保手机的adb调试功能是打开的,并允许通过adb命令安装App到手机上。

启动Airtest以后,把Android手机连接到电脑上,点击下图方框中的refresh ADB

此时在Airtest界面右上角应该能够看到手机的信息,如下图所示。

点击connect按钮,此时可以在界面上看到手机的界面,并且当你手动操作手机屏幕时,Airtest中的手机画面实时更新。如下图所示。

对于某些手机,例如小米,在第一次使用Airtest时,请注意手机上将会弹出提示,询问你是否允许安装App,此时需要点击允许按钮。

打开微信

先通过一个简单的例子,来看看如何快速上手Airtest,稍后再来详解。

例如我现在想使用电脑控制手机,打开微信。

此时,点击下图中方框框住的touch按钮:

此时,把鼠标移动到Airtest右边的手机屏幕区域,鼠标会变成十字型。在微信图标的左上角按下鼠标左键不放,并拖到微信右下角松开鼠标。此时请注意中间代码区域发生了什么变化,如下图所示。

好了。以上就是你需要使用电脑打开微信所要进行的全部操作。

点击上方工具栏中的三角形图标,运行代码,如下图所示。

代码运行完成以后,微信被打开了。

界面介绍

在有了一个直观的使用以后,我们再来介绍一下Airtest的界面,将会更加有针对性。

Airtest的界面如下图所示。

这里,我把Airtest分成了A-F6个区域,他们的功能如下:

  • A区:常用操作功能区
  • B区:Python代码编写区
  • C区:运行日志区
  • D区:手机屏幕区
  • E区:App页面布局信息查看区
  • F区:工具栏

A区是常用的基于图像识别的屏幕操作功能,例如:

  • touch: 点击屏幕元素
  • swipe: 滑动屏幕
  • exists: 判断屏幕元素是否存在
  • text: 在输入框中输入文字
  • snashot: 截图
  • ……

一般来说,是点击A区里面的某一个功能,然后在D区屏幕上进行框选操作,B区就会自动生成相应的操作代码。

B区用来显示和编写Python代码。在多数情况下,不需要手动写代码,因为代码会根据你在手机屏幕上面的操作自动生成。只有一些需要特别定制化的动作才需要修改代码。

D区显示了手机屏幕,当你操作手机真机时,这个屏幕会实时刷新。你也可以直接在D区屏幕上使用鼠标操作手机,你的操作动作会被自动在真机上执行。

F区是一些常用工具,从左到右,依次为:

  1. 新建项目
  2. 打开项目
  3. 保存项目
  4. 运行代码
  5. 停止代码
  6. 查看运行报告

其中1-5很好理解,那么什么是查看运行报告呢?

当你至少运行了一次以后,点击这个功能,会自动给你打开一个网页。网页如下图所示,这是你的代码的运行报告,详细到每一步操作了什么元素。

通过截图功能操作手机虽然方便,但是截图涉及到分辨率的问题,代码不能在不同的手机上通用。所以对于A区的功能,做点简单操作即可,不用深入了解。

更高级的功能,需要通过E区实现。

基于App布局信息操作手机

初始化代码

App的布局信息就像网页的HTML一样,保存了App上面各个元素的相对位置和各个参数。对于一个App而言,在不同分辨率的手机上,可能相同的元素有着不同的坐标点,但是这个元素的属性参数一般是不会变的。因此,如果使用元素的属性参数来寻找并控制这个元素,就能实现在不同分辨率手机上的精确定位。

App的布局信息的格式与App的开发环境有关。点击F区的下拉菜单,可以看到这里能够指定不同的App开发环境。其中的UnityCocos-*等等一般是做游戏用的,Android是安卓原生App,iOS是苹果的App……如下图所示。

以手机版知乎为例,由于它是Android原生的App,所以在F区下拉菜单选择Android,此时注意B区弹出提示,询问你是否要插入poco初始代码到当前输入光标的位置,点击Yes,如下图所示。

此时,B区自动插入了一段代码,如下图所示。

定位并点击

现在,点击E区的锁形图标,如下图所示。

锁形图标激活以后,你再操作D区的屏幕,点击知乎App下面的知乎两个字,会发现屏幕上被点击的App并不会打开。但E区和C区却发生了变化,如下图所示。

其中E区显示的树状结构就是当前屏幕的布局信息,这与Chrome开发者工具里面显示的HTML结构如出一辙。C区显示的是当前被我点中的元素的信息。

请注意在这些元素信息中,有一个text属性,它的值为知乎。那么,这个属性就可以作为一个定位元素,于是可以在B区编写代码:

poco(text="知乎").click()

写完代码以后运行程序,可以看到知乎App被打开了。如下图所示。

注意,如果你发现手机真机显示的界面与Airtest屏幕显示的手机界面不一致,可能是因为Airtest的屏幕被你锁定了。在F区点一下锁形图标,取消锁定,Airtest中的手机屏幕就会更新了。

定位并输入

打开知乎以后,我想使用知乎的搜索功能,那么继续,把锁形图标激活,然后点击知乎顶部的搜索框,如下图所示:

继续看C区显示的搜索框属性,可以看到这里有一个name属性,它的值是com.zhihu.android:id/input,还有一个text属性,它的值为蔡徐坤任 NBA 新春贺岁大使。能不能像前面打开知乎一样,使用text这个属性呢?也行,也不行。说它行,是因为你这么做确实现在能工作;说它不行,因为这是知乎的热门搜索关键词,随时会改变。你今天使用这一句话成功了,明天热门关键词变化了,那么你的代码就无法使用了。所以此时需要使用name这个属性。

常见的基本上不会变化的属性包含但不限于:nametyperesourceIdpackage

另外还有一点,知乎首页的这个搜索框,实际上是不能输入内容的,当你点击以后,会跳转到另一个页面,如下图所示。

因此你需要先点击一下这个输入框,跳转到真正的搜索界面:

poco(name="com.zhihu.android:id/input").click()

在真正的搜索界面如下图所示。

可以看到,name属性的值依然是com.zhihu.android:id/input,此时就可以输入内容了。

输入内容使用的方法为set_text,用法为:

poco(name="com.zhihu.android:id/input").set_text('古剑奇谭三')

定位并筛选

输入了搜索关键词以后,再来看看当前页面,搜索出现了三个结果:

通过对比这三个结果的属性信息,发现他们的name属性都是相同的,而text不同。如果像下面这样写点击动作:

poco(name='com.zhihu.android:id/magi_title').click()

那么默认就会点击第一个搜索结果。

如果我想点击第二个搜索结果怎么办呢?可以这样写代码:

poco(name='com.zhihu.android:id/magi_title', text='古剑奇谭(电视剧)').click()

或者你也可以像列表一样使用索引定位:

poco(name='com.zhihu.android:id/magi_title')[1].click()

这两种写法的前提,都是我们已经知道了每个结果分别是什么。假设现在我就想搜索古剑奇谭三,但我不知道搜索结果是第几项,又应该怎么办呢?此时还可以使用正则表达式:

poco(name='com.zhihu.android:id/magi_title', textMatches='^古剑奇谭三.*$').click()

滑动屏幕

进入搜索结果以后,需要查看下面的各种问题,此时就需要不断向上滑动屏幕。这里有一点需要特别注意,Airtest只能获取当前屏幕上的元素布局信息,不在屏幕上的内容是无法获取的。这一点和Selenium是不一样的。

滑动屏幕使用的命令为swipe,滑动屏幕需要使用坐标信息。但这种坐标和屏幕分辨率无关。这里的坐标定义为:(x, y),其中x为横坐标,y为纵坐标。屏幕左上角为(0, 0),屏幕右下角为(1, 1),从左向右,横坐标从0逐渐增大到1,从上到下,纵坐标从0逐渐增大到1。

现在我要把屏幕向上滑动,那么在真机上面,我是先按住屏幕下方,然后把屏幕向上滑动,所以代码可以这样写:


# poco.swipe(起点坐标,终点左边)
poco.swipe([0.5, 0.8], [0.5, 0.2])

方向示意图如下图所示:

在一般情况下:

  • 向上滑动,只需要改动纵坐标,且起点值大于终点值
  • 向下滑动,只需要改动纵坐标,且起点值小于终点值
  • 向左滑动,只需要改动横坐标,且起点值大于终点值
  • 向右滑动,只需要改动横坐标,且起点值小于终点值

在爬虫开发中,涉及到的Airtest操作基本上已经介绍完毕。

单独使用Python控制手机

在Airtest操作手机虽然方便,但是不可能在每一台电脑上都安装Airtest吧。所以需要想办法把代码从Airtest这个程序中分离出来。

Airtest基于Python的一个开源库Poco开发,而在Airtest的B区写的Python代码,实际上就是Poco的代码。所以只要安装Poco库,就可以在Python中直接控制手机。

安装Poco库的命令为:

pip install pocoui

这个库依赖的东西有点多,安装稍稍慢一些。安装完成以后,我们把代码复制到PyCharm中,如下图所示。

运行这段代码,如果是Linux或者macOS的用户,请注意看运行结果是不是有报错,提示adb没有运行权限。这是因为随Poco安装的adb没有运行权限,需要给它添加权限,在终端执行命令:

# chmod +x 报错信息中给出的adb地址

chmod +x /Users/kingname/.local/share/virtualenvs/ZhihuSpider/lib/python3.7/site-packages/airtest/core/android/static/adb/mac/adb(实际执行时请换成你的地址)

命令运行完成以后再次执行代码,可以看到代码运行成功,手机被成功控制了,如下图所示。

如何获取屏幕文字

由于Airtest的编辑器中的代码运行后无法正常打印出中文,因此后面的代码都直接在PyCharm中执行。

既然要做爬虫,就需要获取手机上的文字内容。回到搜索页面,我想知道“古剑奇谭”三这个关键字能搜索出多少条结果,每条结果有多少个讨论,如下图所示:

此时我们需要做两件事情:

  1. 分别查看每一个搜索结果
  2. 获取屏幕上的文字

E区的树状结构如下图所示:

每一个搜索结果的标题作为text属性的值,在name='com.zhihu.android:id/magi_title'对应的元素中;每一个搜索结果的讨论数作为text属性的值,在name='com.zhihu.android:id/magi_count'对应的元素中。

最直接的做法就是分别获取三个标题和三个讨论数,然后把它们合并在一起:

title_obj_list = poco(name='com.zhihu.android:id/magi_title')
title_list = [title.get_text() for title in title_obj_list]

discuss_obj_list = poco(name='com.zhihu.android:id/magi_count')
discuss_list = [discuss.get_text() for discuss in discuss_obj_list]

for title, discuss in zip(title_list, discuss_list):
    print(title, discuss)

运行效果如下图所示:

但是这种做法实际上是很危险的,假设会有某一个很生僻的搜索结果,只有标题没有讨论数,那么这样分开抓取再组合的做法,就会导致最后匹配错位。所以合理的做法是先抓大再抓小。每一组标题和讨论数,他们都有自己的父节点,如下图箭头所指向的三个android.widget.LinearLayout:

那么现在,使用先抓大再抓小的技巧,先把每一组结果的父节点抓下来,再到每一个结果里面分别获取标题和讨论数。

然而这个父节点又怎么获取呢?如下图所示,这个父节点每一个属性值都没有什么特殊的,写任何一个都有可能与别的节点撞上。

此时,最简单的办法,就是在E区,双击父节点。定位代码就会自动添加,如下图所示。

这个定位代码看起来非常复杂,但实际上它的内在逻辑非常简单,就是从顶层一层一层往下找而已。

自动生成的定位代码如下:

poco("android.widget.LinearLayout").offspring("com.zhihu.android:id/action_bar_root").offspring("com.zhihu.android:id/parent_fragment_content_id").offspring("android.support.v7.widget.RecyclerView").child("android.widget.LinearLayout")[0]

在这个自动生成的定位代码中,我们看到了offspringchild这两种方法。其中child代表子节点,offspring代表孙节点、孙节点的子节点、孙节点的孙节点……。简言之,使用child只会在子节点中搜索需要的内容,而使用offspring会像文件夹递归一样把里面的所有节点都遍历一次,直到找到符合条件的属性为止。显然,offspring速度会比child慢。

实际上,我们可以对这个定位代码做一些精简:

poco("com.zhihu.android:id/parent_fragment_content_id").offspring("android.support.v7.widget.RecyclerView").child("android.widget.LinearLayout")[0]

这个精简的方法,与从Chrome复制的XPath中进行精简是一样的逻辑,根本原则就是找到“独一无二”的属性值,然后用这个属性值来进行定位。

由于我点击的是第一个搜索结果,所以定位代码的最后有一个[0]。现在由于需要获得所有搜索结果的内容,所以应该去掉[0]而使用for循环展开,然后获取里面的内容:

result_obj = poco("com.zhihu.android:id/parent_fragment_content_id").offspring("android.support.v7.widget.RecyclerView").child("android.widget.LinearLayout")
for result in result_obj:
    title = result.child(name='com.zhihu.android:id/magi_title').get_text()
    count = result.child(name='com.zhihu.android:id/magi_count').get_text()
    print(title, count)

运行效果如下图所示。

控制多台手机

当我们在电脑上插入多个Android手机时,执行命令:

adb devices -l

运行效果如下图所示。

每个手机都会被列出来。在最左边的编号就是手机串号。使用这个串号可以指定多个手机:

from airtest.core.api import auto_setup
from airtest.core.android import Android
from poco.drivers.android.uiautomation import AndroidUiautomationPoco
auto_setup(__file__)

device_1 = Android('76efadf3a7ce4')
device_2 = Android('adfasdfasf23')
device_3 = Android('adifu39ernla')

poco_1 = AndroidUiautomationPoco(device_1, use_airtest_input=True, screenshot_each_action=False)
poco_2 = AndroidUiautomationPoco(device_2, use_airtest_input=True, screenshot_each_action=False)
poco_3 = AndroidUiautomationPoco(device_3, use_airtest_input=True, screenshot_each_action=False)

通过这种方式,在一台电脑上使用USBHub,连上二三十台手机是完全没有问题的。

无线模式

Airtest支持无线模式,不需要USB,只要电脑和手机连接同一个WIFI就能控制:

如果大家对如何开启无线模式有兴趣,请留言,我就会继续写。

搭建手机爬虫集群

一台电脑可以连接三十台手机,那么如果有很多电脑和很多手机,就可以实现手机爬虫集群,其运行效果如下图所示。

关于如何搭建爬虫集群,已经超出本文的范围了。如果大家有兴趣,可以阅读我的书:Python爬虫开发 从入门到实战第十章对于如何搭建手机爬虫集群有详细的说明和注意事项。

如果对我的书有兴趣,请关注我的微信公众号与我交流。

查看原文

青南 评论了文章 · 2019-03-27

全面超越Appium,使用Airtest超快速开发App爬虫

想开发网页爬虫,发现被反爬了?想对 App 抓包,发现数据被加密了?不要担心,使用 Airtest 开发 App 爬虫,只要人眼能看到,你就能抓到,最快只需要2分钟,兼容 Unity3D、Cocos2dx-*、Android 原生 App、iOS App、Windows Mobile……。

Airtest是网易开发的手机UI界面自动化测试工具,它原本的目的是通过所见即所得,截图点击等等功能,简化手机App图形界面测试代码编写工作。

爬虫开发本着天下工具为我所用,能让我获取数据的工具都能用来开发爬虫这一信念,决定使用Airtest来开发手机App爬虫。

安装和使用

由于本文的目的是介绍如何使用Airtest来开发App爬虫,那么Airtest作为测试开发工具的方法介绍将会一带而过,仅仅说明如何安装并进行基本的操作。

安装Airtest

从Airtest官网:https://airtest.netease.com下载Airtest,然后像安装普通软件一样安装即可。安装过程没有什么需要特别说明的地方。Airtest已经帮你打包好了开发需要的全部环境,所以安装完成Airtest以后就能够直接使用了。

Airtest运行以后的界面如下图所示。

连接手机

以Android手机为例,由于Airtest会通过adb命令安装两个辅助App到手机上,再用adb命令通过控制这两个辅助App进而控制手机,因此首先需要确保手机的adb调试功能是打开的,并允许通过adb命令安装App到手机上。

启动Airtest以后,把Android手机连接到电脑上,点击下图方框中的refresh ADB

此时在Airtest界面右上角应该能够看到手机的信息,如下图所示。

点击connect按钮,此时可以在界面上看到手机的界面,并且当你手动操作手机屏幕时,Airtest中的手机画面实时更新。如下图所示。

对于某些手机,例如小米,在第一次使用Airtest时,请注意手机上将会弹出提示,询问你是否允许安装App,此时需要点击允许按钮。

打开微信

先通过一个简单的例子,来看看如何快速上手Airtest,稍后再来详解。

例如我现在想使用电脑控制手机,打开微信。

此时,点击下图中方框框住的touch按钮:

此时,把鼠标移动到Airtest右边的手机屏幕区域,鼠标会变成十字型。在微信图标的左上角按下鼠标左键不放,并拖到微信右下角松开鼠标。此时请注意中间代码区域发生了什么变化,如下图所示。

好了。以上就是你需要使用电脑打开微信所要进行的全部操作。

点击上方工具栏中的三角形图标,运行代码,如下图所示。

代码运行完成以后,微信被打开了。

界面介绍

在有了一个直观的使用以后,我们再来介绍一下Airtest的界面,将会更加有针对性。

Airtest的界面如下图所示。

这里,我把Airtest分成了A-F6个区域,他们的功能如下:

  • A区:常用操作功能区
  • B区:Python代码编写区
  • C区:运行日志区
  • D区:手机屏幕区
  • E区:App页面布局信息查看区
  • F区:工具栏

A区是常用的基于图像识别的屏幕操作功能,例如:

  • touch: 点击屏幕元素
  • swipe: 滑动屏幕
  • exists: 判断屏幕元素是否存在
  • text: 在输入框中输入文字
  • snashot: 截图
  • ……

一般来说,是点击A区里面的某一个功能,然后在D区屏幕上进行框选操作,B区就会自动生成相应的操作代码。

B区用来显示和编写Python代码。在多数情况下,不需要手动写代码,因为代码会根据你在手机屏幕上面的操作自动生成。只有一些需要特别定制化的动作才需要修改代码。

D区显示了手机屏幕,当你操作手机真机时,这个屏幕会实时刷新。你也可以直接在D区屏幕上使用鼠标操作手机,你的操作动作会被自动在真机上执行。

F区是一些常用工具,从左到右,依次为:

  1. 新建项目
  2. 打开项目
  3. 保存项目
  4. 运行代码
  5. 停止代码
  6. 查看运行报告

其中1-5很好理解,那么什么是查看运行报告呢?

当你至少运行了一次以后,点击这个功能,会自动给你打开一个网页。网页如下图所示,这是你的代码的运行报告,详细到每一步操作了什么元素。

通过截图功能操作手机虽然方便,但是截图涉及到分辨率的问题,代码不能在不同的手机上通用。所以对于A区的功能,做点简单操作即可,不用深入了解。

更高级的功能,需要通过E区实现。

基于App布局信息操作手机

初始化代码

App的布局信息就像网页的HTML一样,保存了App上面各个元素的相对位置和各个参数。对于一个App而言,在不同分辨率的手机上,可能相同的元素有着不同的坐标点,但是这个元素的属性参数一般是不会变的。因此,如果使用元素的属性参数来寻找并控制这个元素,就能实现在不同分辨率手机上的精确定位。

App的布局信息的格式与App的开发环境有关。点击F区的下拉菜单,可以看到这里能够指定不同的App开发环境。其中的UnityCocos-*等等一般是做游戏用的,Android是安卓原生App,iOS是苹果的App……如下图所示。

以手机版知乎为例,由于它是Android原生的App,所以在F区下拉菜单选择Android,此时注意B区弹出提示,询问你是否要插入poco初始代码到当前输入光标的位置,点击Yes,如下图所示。

此时,B区自动插入了一段代码,如下图所示。

定位并点击

现在,点击E区的锁形图标,如下图所示。

锁形图标激活以后,你再操作D区的屏幕,点击知乎App下面的知乎两个字,会发现屏幕上被点击的App并不会打开。但E区和C区却发生了变化,如下图所示。

其中E区显示的树状结构就是当前屏幕的布局信息,这与Chrome开发者工具里面显示的HTML结构如出一辙。C区显示的是当前被我点中的元素的信息。

请注意在这些元素信息中,有一个text属性,它的值为知乎。那么,这个属性就可以作为一个定位元素,于是可以在B区编写代码:

poco(text="知乎").click()

写完代码以后运行程序,可以看到知乎App被打开了。如下图所示。

注意,如果你发现手机真机显示的界面与Airtest屏幕显示的手机界面不一致,可能是因为Airtest的屏幕被你锁定了。在F区点一下锁形图标,取消锁定,Airtest中的手机屏幕就会更新了。

定位并输入

打开知乎以后,我想使用知乎的搜索功能,那么继续,把锁形图标激活,然后点击知乎顶部的搜索框,如下图所示:

继续看C区显示的搜索框属性,可以看到这里有一个name属性,它的值是com.zhihu.android:id/input,还有一个text属性,它的值为蔡徐坤任 NBA 新春贺岁大使。能不能像前面打开知乎一样,使用text这个属性呢?也行,也不行。说它行,是因为你这么做确实现在能工作;说它不行,因为这是知乎的热门搜索关键词,随时会改变。你今天使用这一句话成功了,明天热门关键词变化了,那么你的代码就无法使用了。所以此时需要使用name这个属性。

常见的基本上不会变化的属性包含但不限于:nametyperesourceIdpackage

另外还有一点,知乎首页的这个搜索框,实际上是不能输入内容的,当你点击以后,会跳转到另一个页面,如下图所示。

因此你需要先点击一下这个输入框,跳转到真正的搜索界面:

poco(name="com.zhihu.android:id/input").click()

在真正的搜索界面如下图所示。

可以看到,name属性的值依然是com.zhihu.android:id/input,此时就可以输入内容了。

输入内容使用的方法为set_text,用法为:

poco(name="com.zhihu.android:id/input").set_text('古剑奇谭三')

定位并筛选

输入了搜索关键词以后,再来看看当前页面,搜索出现了三个结果:

通过对比这三个结果的属性信息,发现他们的name属性都是相同的,而text不同。如果像下面这样写点击动作:

poco(name='com.zhihu.android:id/magi_title').click()

那么默认就会点击第一个搜索结果。

如果我想点击第二个搜索结果怎么办呢?可以这样写代码:

poco(name='com.zhihu.android:id/magi_title', text='古剑奇谭(电视剧)').click()

或者你也可以像列表一样使用索引定位:

poco(name='com.zhihu.android:id/magi_title')[1].click()

这两种写法的前提,都是我们已经知道了每个结果分别是什么。假设现在我就想搜索古剑奇谭三,但我不知道搜索结果是第几项,又应该怎么办呢?此时还可以使用正则表达式:

poco(name='com.zhihu.android:id/magi_title', textMatches='^古剑奇谭三.*$').click()

滑动屏幕

进入搜索结果以后,需要查看下面的各种问题,此时就需要不断向上滑动屏幕。这里有一点需要特别注意,Airtest只能获取当前屏幕上的元素布局信息,不在屏幕上的内容是无法获取的。这一点和Selenium是不一样的。

滑动屏幕使用的命令为swipe,滑动屏幕需要使用坐标信息。但这种坐标和屏幕分辨率无关。这里的坐标定义为:(x, y),其中x为横坐标,y为纵坐标。屏幕左上角为(0, 0),屏幕右下角为(1, 1),从左向右,横坐标从0逐渐增大到1,从上到下,纵坐标从0逐渐增大到1。

现在我要把屏幕向上滑动,那么在真机上面,我是先按住屏幕下方,然后把屏幕向上滑动,所以代码可以这样写:


# poco.swipe(起点坐标,终点左边)
poco.swipe([0.5, 0.8], [0.5, 0.2])

方向示意图如下图所示:

在一般情况下:

  • 向上滑动,只需要改动纵坐标,且起点值大于终点值
  • 向下滑动,只需要改动纵坐标,且起点值小于终点值
  • 向左滑动,只需要改动横坐标,且起点值大于终点值
  • 向右滑动,只需要改动横坐标,且起点值小于终点值

在爬虫开发中,涉及到的Airtest操作基本上已经介绍完毕。

单独使用Python控制手机

在Airtest操作手机虽然方便,但是不可能在每一台电脑上都安装Airtest吧。所以需要想办法把代码从Airtest这个程序中分离出来。

Airtest基于Python的一个开源库Poco开发,而在Airtest的B区写的Python代码,实际上就是Poco的代码。所以只要安装Poco库,就可以在Python中直接控制手机。

安装Poco库的命令为:

pip install pocoui

这个库依赖的东西有点多,安装稍稍慢一些。安装完成以后,我们把代码复制到PyCharm中,如下图所示。

运行这段代码,如果是Linux或者macOS的用户,请注意看运行结果是不是有报错,提示adb没有运行权限。这是因为随Poco安装的adb没有运行权限,需要给它添加权限,在终端执行命令:

# chmod +x 报错信息中给出的adb地址

chmod +x /Users/kingname/.local/share/virtualenvs/ZhihuSpider/lib/python3.7/site-packages/airtest/core/android/static/adb/mac/adb(实际执行时请换成你的地址)

命令运行完成以后再次执行代码,可以看到代码运行成功,手机被成功控制了,如下图所示。

如何获取屏幕文字

由于Airtest的编辑器中的代码运行后无法正常打印出中文,因此后面的代码都直接在PyCharm中执行。

既然要做爬虫,就需要获取手机上的文字内容。回到搜索页面,我想知道“古剑奇谭”三这个关键字能搜索出多少条结果,每条结果有多少个讨论,如下图所示:

此时我们需要做两件事情:

  1. 分别查看每一个搜索结果
  2. 获取屏幕上的文字

E区的树状结构如下图所示:

每一个搜索结果的标题作为text属性的值,在name='com.zhihu.android:id/magi_title'对应的元素中;每一个搜索结果的讨论数作为text属性的值,在name='com.zhihu.android:id/magi_count'对应的元素中。

最直接的做法就是分别获取三个标题和三个讨论数,然后把它们合并在一起:

title_obj_list = poco(name='com.zhihu.android:id/magi_title')
title_list = [title.get_text() for title in title_obj_list]

discuss_obj_list = poco(name='com.zhihu.android:id/magi_count')
discuss_list = [discuss.get_text() for discuss in discuss_obj_list]

for title, discuss in zip(title_list, discuss_list):
    print(title, discuss)

运行效果如下图所示:

但是这种做法实际上是很危险的,假设会有某一个很生僻的搜索结果,只有标题没有讨论数,那么这样分开抓取再组合的做法,就会导致最后匹配错位。所以合理的做法是先抓大再抓小。每一组标题和讨论数,他们都有自己的父节点,如下图箭头所指向的三个android.widget.LinearLayout:

那么现在,使用先抓大再抓小的技巧,先把每一组结果的父节点抓下来,再到每一个结果里面分别获取标题和讨论数。

然而这个父节点又怎么获取呢?如下图所示,这个父节点每一个属性值都没有什么特殊的,写任何一个都有可能与别的节点撞上。

此时,最简单的办法,就是在E区,双击父节点。定位代码就会自动添加,如下图所示。

这个定位代码看起来非常复杂,但实际上它的内在逻辑非常简单,就是从顶层一层一层往下找而已。

自动生成的定位代码如下:

poco("android.widget.LinearLayout").offspring("com.zhihu.android:id/action_bar_root").offspring("com.zhihu.android:id/parent_fragment_content_id").offspring("android.support.v7.widget.RecyclerView").child("android.widget.LinearLayout")[0]

在这个自动生成的定位代码中,我们看到了offspringchild这两种方法。其中child代表子节点,offspring代表孙节点、孙节点的子节点、孙节点的孙节点……。简言之,使用child只会在子节点中搜索需要的内容,而使用offspring会像文件夹递归一样把里面的所有节点都遍历一次,直到找到符合条件的属性为止。显然,offspring速度会比child慢。

实际上,我们可以对这个定位代码做一些精简:

poco("com.zhihu.android:id/parent_fragment_content_id").offspring("android.support.v7.widget.RecyclerView").child("android.widget.LinearLayout")[0]

这个精简的方法,与从Chrome复制的XPath中进行精简是一样的逻辑,根本原则就是找到“独一无二”的属性值,然后用这个属性值来进行定位。

由于我点击的是第一个搜索结果,所以定位代码的最后有一个[0]。现在由于需要获得所有搜索结果的内容,所以应该去掉[0]而使用for循环展开,然后获取里面的内容:

result_obj = poco("com.zhihu.android:id/parent_fragment_content_id").offspring("android.support.v7.widget.RecyclerView").child("android.widget.LinearLayout")
for result in result_obj:
    title = result.child(name='com.zhihu.android:id/magi_title').get_text()
    count = result.child(name='com.zhihu.android:id/magi_count').get_text()
    print(title, count)

运行效果如下图所示。

控制多台手机

当我们在电脑上插入多个Android手机时,执行命令:

adb devices -l

运行效果如下图所示。

每个手机都会被列出来。在最左边的编号就是手机串号。使用这个串号可以指定多个手机:

from airtest.core.api import auto_setup
from airtest.core.android import Android
from poco.drivers.android.uiautomation import AndroidUiautomationPoco
auto_setup(__file__)

device_1 = Android('76efadf3a7ce4')
device_2 = Android('adfasdfasf23')
device_3 = Android('adifu39ernla')

poco_1 = AndroidUiautomationPoco(device_1, use_airtest_input=True, screenshot_each_action=False)
poco_2 = AndroidUiautomationPoco(device_2, use_airtest_input=True, screenshot_each_action=False)
poco_3 = AndroidUiautomationPoco(device_3, use_airtest_input=True, screenshot_each_action=False)

通过这种方式,在一台电脑上使用USBHub,连上二三十台手机是完全没有问题的。

无线模式

Airtest支持无线模式,不需要USB,只要电脑和手机连接同一个WIFI就能控制:

如果大家对如何开启无线模式有兴趣,请留言,我就会继续写。

搭建手机爬虫集群

一台电脑可以连接三十台手机,那么如果有很多电脑和很多手机,就可以实现手机爬虫集群,其运行效果如下图所示。

关于如何搭建爬虫集群,已经超出本文的范围了。如果大家有兴趣,可以阅读我的书:Python爬虫开发 从入门到实战第十章对于如何搭建手机爬虫集群有详细的说明和注意事项。

如果对我的书有兴趣,请关注我的微信公众号与我交流。

查看原文

认证与成就

  • 获得 138 次点赞
  • 获得 2 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 2 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

  • GNE

    从新闻网页自动抽取正文。

注册于 2015-09-21
个人主页被 2.2k 人浏览