【Python】 Selenium 模拟浏览器 寻路
selenium
最开始我碰到SE,是上学期期末,我们那个商务小组做田野调查时发的问卷的事情。当时在问卷星上发了个问卷,但是当时我对另外几个组员的做法颇有微词,又恰好开始学一些软件知识了,就想恶作剧(一方面是小小地报复下他们,另一方面也是为了让做数据分析的自己分析起来更方便)。当时就是用了SE操作浏览器刷了一波问卷,知道页面出现了验证码不让我再刷。虽然最终没想到问卷星还会统计每张问卷的完成时间导致最终的报表有一堆问卷两三秒就完成了,不知道被组员看出破绽没有,不过就刷问卷而言是顺利结束了。之后再深入了解了下SE,觉得这个实在是太吊了,居然可模仿一切人在浏览器中的行为,就单独开了一章,还买了一本书想学习下以后炫技用。。至于能有多少用处,也不太清楚。
■ 安装 & 基本用法
SE经常被用于web自动测试和爬虫,它可模仿用户打开浏览器,输入文字,点击DOM元素等操作。SE并不只是python的一个模块,他是一个独立的工具,用于操作浏览器,python中的selenium模块只不过是python对selenium工具一些API的包装而已。
可以用pip来安装SE,pip install selenium即可。在安装完之后,可以到python解释器中键入>>>from selenium import webdriver 来到如浏览器驱动对象,然后利用这个对象中的不同构造方法来创建不同品牌浏览器的对象。比如要打开一个火狐浏览器,通常这么做:
>>>from selenium import webdriver >>>browser = webdriver.Firefox()
得到的browser就是一个浏览器对象,用它来模拟火狐浏览器即可。其他的浏览器有其他浏览器方法比如Ie(),Chrome(),PhatomJS()(PhatomJS并不是一个浏览器,但是它可以隐形地进行浏览器的所有操作包括获取页面,运行JS等等。在SE中可以把它理解成一个不显示任何界面的浏览器就好了)。
如果是第一次安装selenium,这里可能会出现很多错误。
首先是缺少驱动程序的错误WebDriverException。不同浏览器的驱动程序不同,且单单有python程序是无法驱动浏览器的,所以我们需要单独下载一个驱动程序。比如Firefox43版本以上的火狐要下载geckdriver.exe,而驱动IE得下载IEDriverServer.exe,驱动PhatomJS也要下载PhantomJS.exe,这些在网上搜一下基本上都有的下载的。下载后就是放在哪的问题,其实错误信息里也提示了,说要把驱动程序放在环境变量PATH的某个目录下即可。我一般就是把它放在python.exe解释器的同目录下的。正因为SE是需要浏览器和浏览器驱动的合作才能正常工作,所以要尽量保证两者的兼容性。当浏览器升级之后要及时关注,更新浏览器驱动程序。
其次,如果浏览器不是安装在默认路径,依然可能会报WebDriverException错误,错误信息里提示xxx No Such File or Directory等,这时其实可以改下源码或者直接在Firefox()构造方法中加上参数firefox_binary="浏览器firefox.exe所在path"即可。
*SE中所有的多单词方法和参数等都采用下划线连接而不是驼峰写法。
在成功运行第二句语句之后,一个新的浏览器窗口就会跳出来了(有点像webbrowser模块),之后所有的模拟都会在这个浏览器窗口中进行。在程序中的浏览器对象browser和外面的浏览器窗口是联动的,当程序让browser做一些什么事的时候我们也可以看到浏览器窗口发生相应的变化,同样我们如果手动地改变浏览器窗口,程序中的对象也会发生相应的变化。
运行borwser.get("URL")可以加载一个网页。注意URL还是得写完整,包含http://或者https://之类的,否则get方法虽然不报错,但是得到的是空内容。
■ 和BOM互动的方法
BOM指代浏览器本身,SE中的这个浏览器驱动可以和浏览器互动,基本方法包括但不限于:
browser.maximize_window() 最大化窗口
browser.set_window_size(480,800) 设置窗口大小
browser.refresh() 刷新页面
browser.close() 关闭当前窗口(页面)
browser.quit() 关闭浏览器并退出驱动程序
//close和quit之间的区别表现之一就是,close是关闭当前的窗口或者页面,像火狐里面的话就是标签页。而quit会关闭整个浏览器。
■ 定位DOM元素方法
和其他的一些HTML分析工具类似,定位DOM元素是SE的一大役割,总的来说,SE中有8种定位元素的指标和两种寻找元素的方式。8种指标分别是:
id 通过元素id寻找
name 通过元素的name属性寻找
class_name 通过类名寻找
tag_name 通过元素的标签名寻找
link_text 通过元素的文本寻找
partial_link_text 通过部分元素的文本寻找
xpath 通过xpath寻找
css_selector 通过CSS的selector格式的表达式寻找
两种寻找方式就是find_element_by_xxx和find_elements_by_xxx。两者的区别就在于前者只找到并返回第一个匹配到的元素,而后者会把所有找到的元素对象组成一个列表返回。
以上两个结合一下,就组成了SE中寻找定位元素的8*2=16个定位方法了。比如可以find_element_by_id("...")也可以find_elements_by_class_name("...")。
在众多定位指标中,比较有意思的是xpath和css:
● Xpath
Xpath是XML中一种定位的语法。因为HTML是XML的一种实现,所以也可以应用Xpath。一个Xpath的例子如下:
/html/body/div/div[2]/input 从整个文件的跟节点开始,像linux的目录一样展开深入定位一个元素。div[2]是指当前 层级下可以找到的第二个div标签。这个例子是一个Xpath绝对路径,除了绝对,还可以有如:
//input[@id="kw"] "//"两个斜杠在这里表示的是“某个目录”,整个表达式的意思就是在某个目录下存在id是kw的input元素,我们定位它。input可以用通配符*代替从而不指出元素的标签名。这种写法结合绝对路径,可以让定位更加灵活比如"//span[@class='form']/input"就是定位了class是form下的input元素。此外在表达式中还可以有and,or,大小号,不等号等逻辑判断的运算符。比如"//input[@id='kw' and @class!='su']/span/input"
● CSS Selector
这个selector已经在前端的文章里面提到过很多次,稍微再讲一下。除了熟悉且常用的"tag","tag1,tag2",".class","#id","[attribute=\"value\"]"以外还别忘了:
"parent>child" 选择所有父元素为parent的child元素
"sib1+sib2" 选择所有在sib1元素后同级别的sib2元素
"[attribute]" 选择所有带attribute属性的元素
"[attribute~=\"value\"]" 选择在属性中含attribute且attribute的值中含value的元素(通常考虑像class这种可以有好多值的属性可以用)
"[attribute|=\"value\"]" 选择属性中有attribute且attribute以value开头的元素
■ 一些简单的操作和DOM接口
在定位了元素之后,就可以对它进行操作,一些简单的操作包括:
click() 点击元素,比如元素是个按钮的话,定位元素之后用元素对象调用click方法就可以模拟人点击了一下这个按钮。
send_keys("xxx") 模仿人通过键盘在被定位元素中输入了xxx的字符串,特殊按键的输入下面会再提
clear() 清空某个元素中的内容,比如输入框等
除此之外SE还提供了一些简单的DOM接口:
submit() 通常让表单的input元素调用,相当于填完表单按回车。
size 元素尺寸
text 元素的文本节点的内容(这个节点内容其实是指本节点下所有文本内容,或者不严谨地说是本节点下所有内容除去所有html标签之后的部分,有点像BeautifulSoup里面的stripped_strings代表的内容,而区别于BeautifulSoup里面的tag.string,那个是单指本节点下的文本节点,如果是个div之类本身没有文本的节点的话那么就没有内容,返回None了。另外,bs里面的stripped_strings返回的生成器的内容中包括了CSS,JS脚本等,也就是说bs默认把这些内容也算作是文本。但是selenium不会,它只关注显示在页面上的元素的文本信息)
get_attribute(name) 获取元素的属性名为name的属性值
is_displayed() 判断某个元素是否被显示了
is_enabled() 判断某个元素是否可用
is_selected() 判断某个元素是否被选中了
■ 鼠标事件和ActionChains
除了简单的click,SE可以模仿更多的鼠标动作,这些动作被封装在selenium.webdriver.common.action_chains的ActionChains中,在使用前记得import
用法:
首先,定位一个需要进行鼠标操作的元素,然后把当前webdriver对象作为参数传递给ActionChains()的构造方法,获得一个ActionChains对象,利用这个对象调用下面的动作方法。每个方法都会返回这个ActionChains对象本身所以可以连锁地调用方法来实现一个动作链(只是在实际应用时要注意HTML本身的变化,以及实际HTML变化多少需要花费时间,而在程序中调用方法是一瞬间,需要着重关注这种差异。)。把动作链编辑完成之后最后调用perform()来执行动作连。
动作方法有:(参数element指一个SE中的DOM元素的对象)
context_click(element) 右击元素(注意,是指页面的右击而不是浏览器的右击。一般浏览器右击出现的什么在新窗口中打开,查看页面源代码什么的那个菜单是不能通过这样的形式调出来的。有些页面上右击出现的不是浏览器右击菜单而是这个页面的开发人员设计的菜单,这种菜单才是可以用这个方法来调的)
move_to_element(element) 悬停鼠标到某元素
double_click(element) 双击元素
drag_and_drop(srouce,target) source和target是两个不同的element,鼠标在source上点下按住然后拖动到target处松开
一个完整的例子:ActionChains(driver).context_click(driver.find_element_by_id("xxx")).perform()
■ 键盘事件
上面说过用send_keys(...)可以模拟键盘输入。而对于特殊按键比如ctrl,回车等,我们需要from selenium.webdriver.common.keys import Keys
这个Keys中有Keys.BACK_SPACE(下同前跟Keys.) 退格键 ;
SPACE 空格键
TAB tab键
ESCAPE ecs键
ENTER 回车键
CONTROL,'a' Ctrl+A
CONTROL,'c' Ctrl+C
F1 F1
F5 F5等等
组合普通字符串和这些特殊按键只要在按照按键的顺序依次用逗号隔开各个按键(相当于给send_keys传递多个参数)即可。比如send_keys("hello,world",Keys.ENTER)
■ 当前页面的一些信息
SE的浏览器对象browser可以获取一些当前页面的信息,调用
browser.page_source 获得当前页面的html源代码
browser.current_url 获得当前页面的url
browser.title 获取当前页面的title
■ 关于等待
定位元素时,默认情况下,SE的扫描会在文档加载完成后立即执行。如果要定位的元素是在页面加载完成之后隔一段时间再出来,或者说是通过某些事件新生成出来的,那么默认的“不等待”策略必然会导致无法找到元素的错误。为了避免这些错误,有必要在程序中加上一些等待语句来确保页面加载完成。
● 休眠等待
最简单,最容易想到的等待就是休眠等待,time.sleep(x)。这种方法虽然方便,但是最大的一个缺点就是不论定位元素是否出现了,都要等那么久的时间。当程序变大,代码变多之后,这里一句那里一句既不好看也影响程序的整体性能。
好在SE还提供了另外的等待方式,分成两种,隐式等待和显式等待。
● 隐式等待
隐式等待:主要是通过浏览器对象调用implicitly_wait(sec)来实现。这个方法的意思就是设置当前浏览器对象的隐式等待时间的秒数。比如browser.implicitly_wait(10)就是设置当前浏览器隐式等待10秒。隐式等待时间的设置是一劳永逸的,即设置一次过后之后所有需要等待的地方都会自动应用,而不用像time.sleep一样到每一个需要等待的地方设置。而隐式等待的意思,相当于给定位元素的操作设置一个超时时间。每一次定位元素时,如果定位到了那么继续往下执行程序,如果没有定位到,那么SE将会继续扫描轮询页面,直到定位到或者时间超过设置的隐式等待时间。隐式等待的好处,不言而喻,就是不是固定的等待时间,需要等的时候等不需要等的时候就不等,另外不用设置好多次,在程序最开始那里设置一次就OK了。比如例子:
browser = webdriver.Firefox() browser.implicitly_wait(10) browser.get("https://www.baidu.com") try: browser.find_element_by_id("test").send_keys("hello") except NoSuchElementException as e: print str(e)
这个例子就是设置了一次隐式等待为10秒之后,在百度页面上查找#test元素,过了10秒依然没有找到就raise了异常。
需要注意的是,隐式等待仍然需要等待文档加载完成。对于本身文档很大,加载比较慢的网页,隐式等待无法做到一出现要定位的元素就立刻定位。
● 显式等待
显式等待:其实更加准确来说,显式等待并不只是设置等待这么简单。在设置等待的过程中它还顺便包办了定位元素的操作(毕竟设置等待大多就是在定位元素的过程中)。首先介绍WebDriverWait类
from selenium.webdriver.support.ui import WebDriverWait WebDriverWait(driver,timeout,poll_frequency=0.5)
构造方法中,driver表示需要设置的那个浏览器对象,timeout设置超时时间,poll_frequency设置轮询时间间隔。总的来说,这个类的作用就是在相应的浏览器对象中设置一个轮询机制,每隔一段时间轮询一次,知道超时。光是轮询没有实际内容也是不行的,所以这个类通常会调用它的until或者until_not方法来做一些实际的事情。until和until_not的参数是(method,message='')。一个例子:
from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.common.by import By
element = WebDriverWait(driver,5,0.5).until(EC.presence_of_element_located((By.ID,"kw"))) element.send_keys("hello")
上面这段代码中,构造了一个WebDriverWait类以设置一个轮询机制,这个轮询机制的判断条件就是直到DOM树中存在一个id是kw的元素。可以看到,until方法的参数是一个条件判断方法,类似的条件判断方法还有很多,附在下面,它们基本上都是来自于expected_conditions这个预设好的一些方法,它也可以有第二个参数message来规定当超时时提示什么信息。另外until方法返回了它找到的那个元素对象,这就是为什么我说显式等待不仅仅是设置等待时间而且顺便把元素给定位了出来。不过并不是所有until都会返回元素对象,这得看它里面的那个条件判断方法写的是什么。比如title_contains这些方法作为until的参数时就会让until返回True或者False等等。当实在搞不清到底有没有返回元素对象的时候干脆就当他没有返回,自己再到下面稳妥地find_element_byxxx来定位相关元素进行操作好了。
条件判断方法中定位元素用的是一种比较接近SE本身的方法(其他语言如java的SEAPI的话定位元素都是用这种形式的),它传递一个元组给方法,第一项是webdriver.common.by.By中的一个属性,可以是By.ID,By.CLASS_NAME,By.CSS_SELECTOR,By.LINK_TEXT等等,可以去查看By.__dict__查看对应关系。这个第一项指明了是通过何种指标来定位元素的,而第二项是一个值,指明了通过第一项指标值是什么时定位元素。虽然看起来有些晦涩,不过原理上和前面介绍的定位方式是一样的。
一些常见的expected_conditions中提供的条件判断方法:
title_is("...") 判断当前页面title是否是指定值,如果检测到是了until方法就返回True
title_contains('...') 类似,判断title中是否含有指定值
presence_of_element_located((By.xxx)) 判断某个元素是否被加在DOM树中(该元素不一定可见),如果检测到了until方法就返回这个元素对象
visibility_of_element_located((By.xxx)) 判断某个元素是否可见(元素非隐藏且宽高不为零)
visibility_of(Element) 判断条件和上面这个一样,只不过这个方法接受的参数不是(By.xxx)的元组而是一个已经被定位好的元素对象。这也就说可以有visibility_of(driver.find_element_byxxx(...))的形式了
text_to_be_present_in_element((By.xxx),"...") 判断某个元素的文本节点是否包含了预期的字符串,两个参数,第一个是定位元组,第二个是要判断的字符串。如果找到字符串until方法就返回True
text_to_be_present_in_element_value((By.xxx),"...") 判断某个元素的value属性的值是不是预期的值,如果确定是,until方法就返回True
frame_to_be_available_and_switch_to_it((By.xxx)) 判断一个表单是否可以切换进入,如果是until方法返回True并切入
element_to_be_clickable((By.xxx)) 判断某个元素是否可点击
alert_is_presented() 判断当前页面是否存在提示框(不仅是alert,包括prompt,confirm等),如果检测到存在了,那么until方法返回这个提示框对象
总之从形式上来说,显式等待和休眠等待差不太多,显示等待也需要在每一个需要等待的地方写一条语句。但是相比于休眠等待,显式等待更加智能,他可以根据不同条件来设置不同的等待方式从而更加精确地控制等待时间长短。
■ 多表单与多窗口切换
多窗口和多表单切换在代码里都用到了浏览器对象的switch_to属性。这个属性可以再调用不同的方法以实现不同主体之间的切换。
多表单切换
很多页面会有<iframe>或者其他的一些标签,意思是一个子表单。面对这种情况,SE是没有直接读取子表单中HTML的能力的,因此需要进行表单切换工作。表单切换语句是browser.switch_to.frame("frame的id")。如果一个frame没有id的话也可通过其他的find_element_byxxx的方法来获取一个frame对象,把这个对象作为参数传递进来就好了。在切换完成后,browser就已经找不到原来表单里的内容了,但却可以定位到子表单中的内容。(值得注意的是,尽管此时browser看起来已经不像是个浏览器对象而是个子表单对象了,但是如果在这里让browser.get一个新网页或者refresh一下的话,依然是整个网页被更新的,而不会是仅仅子表单被更新)
完成在子表单中的工作后,需要切回原先的表单。此时依然是browser.switch_to只不过后面跟的目标方法变了,变成default_content(),即browser.switch_to.default_content()就可以切回原内容了。另外有一个parent_frame()用来切回父表单,不过这里有一点,如果是一层表单的情况,即一层父一层子没有其他层级的表单不能调用parent_frame()会报错,应该调用default_content来切回最开始的内容。如果是多层嵌套的表单的话可以用parent_frame来一级一级向上切。
多窗口切换
当打开一个链接,形成一个新窗口时,就形成了多窗口的局面。要做到多窗口间切换做法和多表单也很类似,只不过窗口没有一个明确的id,所以指明一个窗口只能通过窗口对象。而获取一个窗口对象的方法是通过浏览器对象的属性browser.current_window_handle。另外browser.window_handles返回一个所有窗口对象组成的迭代器,用这两个对象再加上switch_to.window方法便可以实现窗口切换。比如:
currentWindow = browser.current_window_handle #通过一个变量保存下当前窗口对象 browser.execute_script('window.open("about:blank")') #通过运行JS的方式新建一个窗口并且窗口焦点自动地转移到新窗口里了 browser.switch_to.window(currentWindow) #通过切换窗口把焦点重新转移回第一个窗口。
对于要打开较多窗口,可以通过遍历不同窗口做不同事情:
for window in browser.window_handles: if handle == currentWindow: #做一些事情
■ 提示框处理
我们已经知道,alert,confirm,prompt这种提示框并不是HTML的一部分,所以不能通过常规手段来处理它。SE中对提示框的处理,和多表单多窗口一样要用到switch_to。具体做法是在出现提示框的场景里browser.switch_to.alert/confirm/prompt等.方法()。这里的方法可以是accept(用于接受提示框,相当于按下确定);dismiss(去掉提示框,相当于取消);send_keys(...)是向prompt输入文字。除了这些方法外,alert这些提示框对象还有.text属性用于查看提示的信息等。
关于有提示框的网页应用SE时需要注意几点。第一,提示框是会阻塞文档的加载的,换句话说,如果在文档最前面的<script>里面alert了一个框,那么这个框出现时其文档还是一片空。如果应用SE时忽视了提示框的存在而直接去读取分析文档会导致找不到相关的元素。第二,提示框加载也是需要时间的,但这部分时间不算在隐式等待的时间中。也就是说这个地方应该会跳出个提示框,但是因为费时间还没跳出来的情况,即使设置了SE的隐式等待,SE也不会像某个元素没加载出来那样等着。如果后面跟着的是直接执行switch_to.alert之类的语句的话,此时可能会报无tab modal之类的错,毕竟这时候还没有提示框出现。解决的办法就是在swith_to.alert之前,进行显式或休眠等待。如果选择显式等待就可以选择条件判断方法是expected_conditions.alert_is_present()的until方法。比如:
from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC WebDriverWait(browser,5).until(EC.alert_is_present()).accept() #这里设置的显式等待就是给了5秒钟让提示框出现的机会,如果出现了那么因为这个until方法返回这个提示框对象可以直接accept掉,如果5秒内没出现就抛出超时错误
■ cookie操作
browser.get_cookies() 获取当前页面所有cookie
browser.get_cookie(name) 获取指定cookie值
browser.add_cookie(cookie_dict) 把一个诸如{'name':'xxx','value':'yyy'}的字典作为一条cookie添加到当前页面上。注意,只能一条一条添加,并且要在get到页面之后再sleep几秒再添加才会成功
browser.delete_all_cookie() 删除所有cookie
■ 执行javascript
之前在一些操作中也看到过了browser.execute_script()方法,比如在打开一个新窗口的时候,SE似乎没有自带的打开新窗口的方法所以通过JS来,就是execute_script('window.open("about:blank")')。这就可以看出来这个方法的参数是一个字符串,而SE会自动从这些字符串中解析出JS脚本并执行。
再比如SE中不支持滚动页面,所以可通过JS来:execute_script("window.scrollTo(100,450)")即可。
另外值得一提的是,execute_script方法执行的JS脚本如果出错了的话应该是会反馈到python中来的,也就是说把浏览器的控制台里的错误信息导到python的stderr中来了。
execute_script方法是同步执行的,如果需要异步执行还有execute_async_script方法。
■ 窗口截图
一个看起来比较炫,但是好像没啥用的功能。。
browser.get_screenshot_as_file("保存路径")
默认格式为png或者jpg较好,保存截屏也只是保存纯HTML界面,浏览器的选项,菜单栏以及提示框等无关。但是和当前窗口的大小以及滚动条位置有关。
■ 积累
*之前我就一直在想,如果能把SE的脚本包装成一个exe,那不是很拉风,传给别人别人双击打开之后就会自动地操控浏览器干一些事情了。于是趁着今天在搞这个我去尝试了一下,事实证明,经过pyinstaller的包装之后,这个exe程序可以在没有python的环境下运行,同时当地的PATH中(比如和exe同目录下)要有一个geckodriver.exe。说明pyinstaller并不会把驱动程序也包进去。
*python的SE是个很依赖第三方的模块,这个第三方既可以指SE这个工具本身,也可以说是不同浏览器的驱动程序等等,所以有时候莫名其妙的报错,可以不急着找程序的错误而是试试看另一个浏览器或另一个驱动程序是不是可以顺利运行。如果是的话,那说明第一次的那个测试环境的浏览器和驱动是有问题了。。
*其他的一些内容比如上传下载文件等,感觉根据浏览器不同好像差很多,具体用的时候可以再探索一下,或者变通想点其他办法。