我们抓取,我们采集,我们分析,我们挖掘

JavaScript 动态渲染

JavaScript 动态渲染的页面不止 Ajax 这一种。

比如中国青年网(详见 http://news.youth.cn/gn/),它的分页部分是由 JavaScript 生成的,并非原始 HTML 代码,这其中并不包含 Ajax 请求。

比如 ECharts 的官方实例(详见 http://echarts.baidu.com/demo.html),其图形都是经过 JavaScript 计算之后生成的。

再有淘宝这种页面,它即使是 Ajax 获取的数据,但是其 Ajax 接口含有很多加密参数,我们难以直接找出其规律,也很难直接分析 Ajax 来抓取。

为了解决这些问题,我们可以直接使用模拟浏览器运行的方式来实现,这样就可以做到在浏览器中看到是什么样,抓取的源码就是什么样,也就是可见即可爬。这样我们就不用再去管网页内部的 JavaScript 用了什么算法渲染页面,不用管网页后台的 Ajax 接口到底有哪些参数。

Python 提供了许多模拟浏览器运行的库,如 Selenium、Splash、Pyppetter、Playwright、PyV8、Ghost 等。本章中,我们就来介绍一下 Selenium 和 Splash 的用法。有了它们,就不用再为动态渲染的页面发愁了。

为什么需要模拟浏览器运行的库

很多情况下Ajax请求的接口包含加密参数,比如token,sign等。

示例:https://spa2.scrape.center/,该站点请求的Ajax接口就包含了一个token参数

image-20230305090534521

由于该Ajax接口请求时必须加上token参数,因此如果不深入分析并找到token参数的构造逻辑,是难以直接模拟Ajax请求的。

方法有2种:

  • 1、深挖其中的逻辑,把token参数的构造逻辑完全找出来,再用python代码复现,构造Ajax请求
  • 2、直接模拟浏览器的运行,绕过这个过程,因为在浏览器里是可以看到这个数据的,所以如果能把看到的数据直接爬取下来,当然就能获取对应的信息了。

第一种比较麻烦,我们先看第二种

Selenium的使用(经典)

Selenium 是一个自动化测试工具,利用它可以驱动浏览器执行特定的动作,如点击、下拉等操作,同时还可以获取浏览器当前呈现的页面的源代码,做到可见即可爬。对于一些 JavaScript 动态渲染的页面来说,此种抓取方式非常有效

相关链接

安装

安装ChromeDriver

Selenium 库是一个自动化测试工具,需要浏览器来配合它使用,我们需要安装一个 ChromeDriver 才能驱动 Chrome 浏览器完成相应的操作

下载的版本要和自己chrome浏览器的版本(chrome://version/)对应上

下载完成后将 ChromeDriver 的可执行文件配置到环境变量下。

在 Windows 下,建议直接将 chromedriver.exe 文件拖到 Python 的 Scripts 目录下

image-20230305091919265

验证安装

配置完成之后,就可以在命令行下直接执行 chromedriver 命令了。

命令行下输入:

1
chromedriver

输入控制台有类似输出,如图所示:

image-20230305092047144

pip 安装

推荐直接使用 pip3 安装,执行如下命令即可:

1
pip3 install selenium

wheel 安装

除了 pip 安装,也可以到 PyPi 下载对应的 wheel 文件进行安装,https://pypi.python.org/pypi/selenium/#downloads, 如假设最新版本为 3.4.3,则下载 selenium-3.4.3-py2.py3-none-any.whl。

然后进入 wheel 文件目录,使用 pip 安装。

1
pip3 install selenium-3.4.3-py2.py3-none-any.whl

验证安装

进入 Python 命令行交互模式,导入一下 Selenium 包,如果没有报错,则证明安装成功。

1
2
$ python3
>>> import selenium

当然也可以运行一个脚本:

1
2
3
4
5
6
7
from selenium import webdriver
from time import sleep

browser = webdriver.Chrome()
browser.get('https://www.baidu.com') # get阻塞的方法,请求结构后才开始计算2s
sleep(2)
browser.close()

如果运行完毕之后弹出来了一个 Chrome 浏览器并加载了百度页面,2 秒之后就关闭了,那就证明没问题了。

接下来我们就可以利用 Chrome 来做网页抓取了。

基本使用

准备工作做好之后,首先来大体看一下 Selenium 有一些怎样的功能。示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.wait import WebDriverWait

browser = webdriver.Chrome()
try:
browser.get('https://www.baidu.com')
#input = browser.find_element_by_id('kw') # selenium 3
input = browser.find_element(By.ID,'kw') # selenium 4
input.send_keys('Python')
input.send_keys(Keys.ENTER)
wait = WebDriverWait(browser, 10)
wait.until(EC.presence_of_element_located((By.ID, 'content_left')))
print(browser.current_url)
print(browser.get_cookies())
print(browser.page_source)
finally:
browser.close()

运行代码后发现,会自动弹出一个 Chrome 浏览器。浏览器首先会跳转到百度,然后在搜索框中输入 Python,接着跳转到搜索结果页

image-20230305092835827

控制台我们可以获取到当前 URL、Cookies 和源代码,都是浏览器中的真实内容。

所以说,如果用 Selenium 来驱动浏览器加载网页的话,就可以直接拿到 JavaScript 渲染的结果了,不用担心使用的是什么加密系统。

初始化浏览器对象

Selenium 支持非常多的浏览器,如 Chrome、Firefox、Edge 等,还有 Android、BlackBerry 等手机端的浏览器。另外,也支持无界面浏览器 PhantomJS。

此外,我们可以用如下方式初始化:

1
2
3
4
5
6
7
from selenium import webdriver

browser = webdriver.Chrome()
browser = webdriver.Firefox()
browser = webdriver.Edge()
browser = webdriver.PhantomJS()
browser = webdriver.Safari()

这样就完成了浏览器对象的初始化并将其赋值为 browser 对象。接下来,我们要做的就是调用 browser 对象,让其执行各个动作以模拟浏览器操作。

访问页面

我们可以用 get() 方法来请求网页,参数传入链接 URL 即可。比如,这里用 get() 方法访问淘宝,然后打印出源代码,代码如下:

1
2
3
4
5
6
from selenium import webdriver

browser = webdriver.Chrome()
browser.get('https://www.taobao.com')
print(browser.page_source)
browser.close()

运行后发现,弹出了 Chrome 浏览器并且自动访问了淘宝,然后控制台输出了淘宝页面的源代码,随后浏览器关闭。

image-20230305093729918

通过这几行简单的代码,我们可以实现浏览器的驱动并获取网页源码,非常便捷。

查找节点

Selenium 可以驱动浏览器完成各种操作,比如填充表单、模拟点击等。比如,我们想要完成向某个输入框输入文字的操作,总需要知道这个输入框在哪里吧?而 Selenium 提供了一系列查找节点的方法,我们可以用这些方法来获取想要的节点,以便下一步执行一些动作或者提取信息。

单个节点

比如,想要从淘宝页面中提取搜索框这个节点,首先要观察它的源代码,如图 7-2 所示。

image-20230305093129058

可以发现,它的 id 是 q,name 也是 q。此外,还有许多其他属性,此时我们就可以用多种方式获取它了。比如,find_element_by_name() 是根据 name 值获取,find_element_by_id() 是根据 id 获取。另外,还有根据 XPath、CSS 选择器等获取的方式。

我们用代码实现一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from selenium import webdriver

browser = webdriver.Chrome()
browser.get('https://www.taobao.com')
# selenium3
#input_first = browser.find_element_by_id('q')
#input_second = browser.find_element_by_css_selector('#q')
#input_third = browser.find_element_by_xpath('//*[@id="q"]')
# selenium4
input_first = browser.find_element(By.ID,'q')
input_second = browser.find_element(By.CSS_SELECTOR,'#q')
input_third = browser.find_element(By.XPATH,'//*[@id="q"]')
print(input_first, input_second, input_third)
browser.close()

这里我们使用 3 种方式获取输入框,分别是根据 ID、CSS 选择器和 XPath 获取,它们返回的结果完全一致。运行结果如下:

1
2
3
<selenium.webdriver.remote.webelement.WebElement (session="5e53d9e1c8646e44c14c1c2880d424af", element="0.5649563096161541-1")> 
<selenium.webdriver.remote.webelement.WebElement (session="5e53d9e1c8646e44c14c1c2880d424af", element="0.5649563096161541-1")>
<selenium.webdriver.remote.webelement.WebElement (session="5e53d9e1c8646e44c14c1c2880d424af", element="0.5649563096161541-1")>

可以看到,这 3 个节点都是 WebElement 类型,是完全一致的。

这里列出所有获取单个节点的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# selenium 3
find_element_by_id
find_element_by_name
find_element_by_xpath
find_element_by_link_text
find_element_by_partial_link_text
find_element_by_tag_name
find_element_by_class_name
find_element_by_css_selector
# selenium 4
find_element(By.ID)
find_element(By.NAME)
find_element(By.XPATH)
find_element(By.LINK_TEXT)
find_element(By.PARTIAL_LINK_TEXT)
find_element(By.TAG_NAME)
find_element(By.CLASS_NAME)
find_element(By.CSS_SELECTOR)

另外,Selenium 还提供了通用方法 find_element(),它需要传入两个参数:查找方式 By 和值。实际上,它就是 find_element_by_id() 这种方法的通用函数版本,比如 find_element_by_id(id) 就等价于 find_element(By.ID, id),二者得到的结果完全一致,后者参数更加灵活。

多个节点

如果查找的目标在网页中只有一个,那么完全可以用 find_element() 方法。但如果有多个节点,再用 find_element() 方法查找,就只能得到第一个节点了。如果要查找所有满足条件的节点,需要用 find_elements() 这样的方法。注意,在这个方法的名称中,element 多了一个 s,注意区分。

比如,要查找淘宝左侧导航条的所有条目,如图 7-3 所示。

就可以这样来实现:

1
2
3
4
5
6
7
8
from selenium import webdriver
from selenium.webdriver.common.by import By
browser = webdriver.Chrome()
browser.get('https://www.taobao.com')
# lis = browser.find_elements_by_css_selector('.service-bd li') # selenium 3
lis = browser.find_elements(By.CSS_SELECTOR,'.service-bd li') # selenium 4
print(lis)
browser.close()

运行结果如下:

1
[<selenium.webdriver.remote.webelement.WebElement (session="c26290835d4457ebf7d96bfab3740d19", element="0.09221044033125603-1")>, <selenium.webdriver.remote.webelement.WebElement (session="c26290835d4457ebf7d96bfab3740d19", element="0.09221044033125603-2")>, <selenium.webdriver.remote.webelement.WebElement (session="c26290835d4457ebf7d96bfab3740d19", element="0.09221044033125603-3")>...<selenium.webdriver.remote.webelement.WebElement (session="c26290835d4457ebf7d96bfab3740d19", element="0.09221044033125603-16")>]

这里简化了输出结果,中间部分省略。

可以看到,得到的内容变成了列表类型,列表中的每个节点都是 WebElement 类型。

也就是说,如果我们用 find_element() 方法,只能获取匹配的第一个节点,结果是 WebElement 类型。如果用 find_elements() 方法,则结果是列表类型,列表中的每个节点是 WebElement 类型。

这里列出所有获取多个节点的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# selenium 3
find_elements_by_id
find_elements_by_name
find_elements_by_xpath
find_elements_by_link_text
find_elements_by_partial_link_text
find_elements_by_tag_name
find_elements_by_class_name
find_elements_by_css_selector
# selenium 4
find_elements(By.ID)
find_elements(By.NAME)
find_elements(By.XPATH)
find_elements(By.LINK_TEXT)
find_elements(By.PARTIAL_LINK_TEXT)
find_elements(By.TAG_NAME)
find_elements(By.CLASS_NAME)
find_elements(By.CSS_SELECTOR)

当然,我们也可以直接用 find_elements() 方法来选择,这时可以这样写:

1
lis = browser.find_elements(By.CSS_SELECTOR, '.service-bd li')

结果是完全一致的。

节点交互

Selenium 可以驱动浏览器来执行一些操作,也就是说可以让浏览器模拟执行一些动作。比较常见的用法有:输入文字时用 send_keys 方法,清空文字时用 clear 方法,点击按钮时用 click 方法。示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from selenium import webdriver
from selenium.webdriver.common.by import By
import time

browser = webdriver.Chrome()
browser.get('https://www.taobao.com')
#input = browser.find_element_by_id('q') # selenium3
input = browser.find_element(By.ID,'q') # selenium4
input.send_keys('iPhone')
time.sleep(1)
input.clear()
input.send_keys('iPad')
#button = browser.find_element_by_class_name('btn-search') # selenium3
button = browser.find_element(By.CLASS_NAME,'btn-search') # selenium4
button.click()

这里首先驱动浏览器打开淘宝,然后用 find_element_by_id() 方法获取输入框,然后用 send_keys() 方法输入 iPhone 文字,等待一秒后用 clear() 方法清空输入框,再次调用 send_keys() 方法输入 iPad 文字,之后再用 find_element_by_class_name() 方法获取搜索按钮,最后调用 click() 方法完成搜索动作。

通过上面的方法,我们就完成了一些常见节点的动作操作,

更多的操作可以参见官方文档的交互动作介绍 :http://selenium-python.readthedocs.io/api.html#module-selenium.webdriver.remote.webelement

动作链

在上面的实例中,一些交互动作都是针对某个节点执行的。比如,对于输入框,我们就调用它的输入文字和清空文字方法;对于按钮,就调用它的点击方法。其实,还有另外一些操作,它们没有特定的执行对象,比如鼠标拖曳、键盘按键等,这些动作用另一种方式来执行,那就是动作链。

比如,现在实现一个节点的拖曳操作,将某个节点从一处拖曳到另外一处,可以这样实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver import ActionChains

browser = webdriver.Chrome()
url = 'http://www.runoob.com/try/try.php?filename=jqueryui-api-droppable'
browser.get(url)
browser.switch_to.frame('iframeResult')
# selenium3
#source = browser.find_element_by_css_selector('#draggable')
#target = browser.find_element_by_css_selector('#droppable')
# selenium4
source = browser.find_element(By.CSS_SELECTOR,'#draggable')
target = browser.find_element(By.CSS_SELECTOR,'#droppable')
actions = ActionChains(browser)
actions.drag_and_drop(source, target)
actions.perform()

首先,打开网页中的一个拖曳实例,然后依次选中要拖曳的节点和拖曳到的目标节点,接着声明 ActionChains 对象并将其赋值为 actions 变量,然后通过调用 actions 变量的 drag_and_drop() 方法,再调用 perform() 方法执行动作,此时就完成了拖曳操作

image-20230305095405431

更多的动作链操作可以参考官方文档的动作链介绍: https://selenium-python.readthedocs.io/api.html#module-selenium.webdriver.common.action_chains

执行 JavaScript

对于某些操作,Selenium API 并没有提供。比如,下拉进度条,它可以直接模拟运行 JavaScript,此时使用 execute_script() 方法即可实现,代码如下:

1
2
3
4
5
6
7
8
from selenium import webdriver

browser = webdriver.Chrome()
browser.get('https://www.zhihu.com/explore')
browser.execute_script('window.scrollTo(0, document.body.scrollHeight)')
browser.execute_script('alert("To Bottom")')
sleep(5)
print('浏览器下滑成功')

这里就利用 execute_script() 方法将进度条下拉到最底部,然后弹出 alert 提示框。

所以说有了这个方法,基本上 API 没有提供的所有功能都可以用执行 JavaScript 的方式来实现了。

获取节点信息

前面说过,通过 page_source 属性可以获取网页的源代码,接着就可以使用解析库(如正则表达式、Beautiful Soup、pyquery 等)来提取信息了。

不过,既然 Selenium 已经提供了选择节点的方法,返回的是 WebElement 类型,那么它也有相关的方法和属性来直接提取节点信息,如属性、文本等。这样的话,我们就可以不用通过解析源代码来提取信息了,非常方便。

接下来,就看看通过怎样的方式来获取节点信息吧。

获取属性

我们可以使用 get_attribute() 方法来获取节点的属性,但是其前提是先选中这个节点,示例如下:

1
2
3
4
5
6
7
8
9
10
11
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver import ActionChains

browser = webdriver.Chrome()
url = 'https://www.baidu.com/'
browser.get(url)
#logo = browser.find_element_by_id('lg') # selenium3
logo = browser.find_element(By.ID,'lg') # selenium4
print(logo)
print(logo.get_attribute('class'))

运行之后,程序便会驱动浏览器打开百度页面,然后获取百度的 logo 节点,最后打印出它的 class。

控制台的输出结果如下:

image-20230305100728795

通过 get_attribute() 方法,然后传入想要获取的属性名,就可以得到它的值了。

获取文本值

每个 WebElement 节点都有 text 属性,直接调用这个属性就可以得到节点内部的文本信息,这相当于 Beautiful Soup 的 get_text() 方法、pyquery 的 text() 方法,示例如下:

1
2
3
4
5
6
7
from selenium import webdriver

browser = webdriver.Chrome()
url = 'https://www.baidu.com/'
browser.get(url)
input = browser.find_element_by_class_name('s_btn_wr')
print(input.text)

这里依然先打开百度页面,然后获取 “提问” 按钮这个节点,再将其文本值打印出来。

控制台的输出结果如下:

1
提问

获取 ID、位置、标签名、大小

另外,WebElement 节点还有一些其他属性,比如 id 属性可以获取节点 id,location 属性可以获取该节点在页面中的相对位置,tag_name 属性可以获取标签名称,size 属性可以获取节点的大小,也就是宽高,这些属性有时候还是很有用的。示例如下:

1
2
3
4
5
6
7
8
9
10
11
from selenium.webdriver.common.by import By
from selenium import webdriver

browser = webdriver.Chrome()
url = 'https://www.baidu.com/'
browser.get(url)
input = browser.find_element(By.ID,'su')
print(input.id)
print(input.location)
print(input.tag_name)
print(input.size)

这里首先获得 “提问” 按钮这个节点,然后调用其 id、location、tag_name、size 属性来获取对应的属性值。

image-20230305101525776

切换 Frame

我们知道网页中有一种节点叫作 iframe,也就是子 Frame,相当于页面的子页面,它的结构和外部网页的结构完全一致。Selenium 打开页面后,它默认是在父级 Frame 里面操作,而此时如果页面中还有子 Frame,它是不能获取到子 Frame 里面的节点的。这时就需要使用 switch_to.frame() 方法来切换 Frame。示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import time
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.common.exceptions import NoSuchElementException

browser = webdriver.Chrome()
url = 'http://www.runoob.com/try/try.php?filename=jqueryui-api-droppable'
browser.get(url)
browser.switch_to.frame('iframeResult')
try:
logo = browser.find_element(By.CLASS_NAME,'logo')
except NoSuchElementException:
print('NO LOGO')
browser.switch_to.parent_frame()
logo = browser.find_element(By.CLASS_NAME,'logo')
print(logo)
print(logo.text)

控制台输出:

image-20230305101816147

这里还是以前面演示动作链操作的网页为实例,首先通过 switch_to.frame() 方法切换到子 Frame 里面,然后尝试获取子 Frame 里的 logo 节点(这是不能找到的),如果找不到的话,就会抛出 NoSuchElementException 异常,异常被捕捉之后,就会输出 NO LOGO。接下来,重新切换回父级 Frame,然后再次重新获取节点,发现此时可以成功获取了。

所以,当页面中包含子 Frame 时,如果想获取子 Frame 中的节点,需要先调用 switch_to.frame() 方法切换到对应的 Frame,然后再进行操作。

延时等待

在 Selenium 中,get() 方法会在网页框架加载结束后结束执行,此时如果获取 page_source,可能并不是浏览器完全加载完成的页面,如果某些页面有额外的 Ajax 请求,我们在网页源代码中也不一定能成功获取到。所以,这里需要延时等待一定时间,确保节点已经加载出来。

这里等待的方式有两种:一种是隐式等待,一种是显式等待。

隐式等待

当使用隐式等待执行测试的时候,如果 Selenium 没有在 DOM 中找到节点,将继续等待,超出设定时间后,则抛出找不到节点的异常。换句话说,当查找节点而节点并没有立即出现的时候,隐式等待将等待一段时间再查找 DOM,默认的时间是 0。示例如下:

1
2
3
4
5
6
7
from selenium import webdriver

browser = webdriver.Chrome()
browser.implicitly_wait(10)
browser.get('https://www.zhihu.com/explore')
input = browser.find_element_by_class_name('zu-top-add-question')
print(input)

在这里我们用 implicitly_wait() 方法实现了隐式等待。

显式等待

隐式等待的效果其实并没有那么好,因为我们只规定了一个固定时间,而页面的加载时间会受到网络条件的影响

这里还有一种更合适的显式等待方法,它指定要查找的节点,然后指定一个最长等待时间。如果在规定时间内加载出来了这个节点,就返回查找的节点;如果到了规定时间依然没有加载出该节点,则抛出超时异常。示例如下:

1
2
3
4
5
6
7
8
9
10
11
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC

browser = webdriver.Chrome()
browser.get('https://www.taobao.com/')
wait = WebDriverWait(browser, 10)
input = wait.until(EC.presence_of_element_located((By.ID, 'q')))
button = wait.until(EC.element_to_be_clickable((By.CSS_SELECTOR, '.btn-search')))
print(input, button)

这里首先引入 WebDriverWait 这个对象,指定最长等待时间,然后调用它的 until() 方法,传入要等待条件 expected_conditions。比如,这里传入了 presence_of_element_located 这个条件,代表节点出现的意思,其参数是节点的定位元组,也就是 ID 为 q 的节点搜索框。

这样可以做到的效果就是,在 10 秒内如果 ID 为 q 的节点(即搜索框)成功加载出来,就返回该节点;如果超过 10 秒还没有加载出来,就抛出异常。

对于按钮,可以更改一下等待条件,比如改为 element_to_be_clickable,也就是可点击,所以查找按钮时查找 CSS 选择器为.btn-search 的按钮,如果 10 秒内它是可点击的,也就是成功加载出来了,就返回这个按钮节点;如果超过 10 秒还不可点击,也就是没有加载出来,就抛出异常。

运行代码,在网速较佳的情况下是可以成功加载出来的。

控制台的输出如下:

1
2
<selenium.webdriver.remote.webelement.WebElement (session="07dd2fbc2d5b1ce40e82b9754aba8fa8", element="0.5642646294074107-1")>
<selenium.webdriver.remote.webelement.WebElement (session="07dd2fbc2d5b1ce40e82b9754aba8fa8", element="0.5642646294074107-2")>

可以看到,控制台成功输出了两个节点,它们都是 WebElement 类型。

如果网络有问题,10 秒内没有成功加载,那就抛出 TimeoutException 异常,此时控制台的输出如下:

1
2
3
4
5
TimeoutException Traceback (most recent call last)
<ipython-input-4-f3d73973b223> in <module>()
7 browser.get('https://www.taobao.com/')
8 wait = WebDriverWait(browser, 10)
----> 9 input = wait.until(EC.presence_of_element_located((By.ID, 'q')))

关于等待条件,其实还有很多,比如判断标题内容,判断某个节点内是否出现了某文字等。表 7-1 列出了所有的等待条件。

表 7-1 等待条件及其含义

等待条件 含义
title_is 标题是某内容
title_contains 标题包含某内容
presence_of_element_located 节点加载出,传入定位元组,如 (By.ID, ‘p’)
visibility_of_element_located 节点可见,传入定位元组
visibility_of 可见,传入节点对象
presence_of_all_elements_located 所有节点加载出
text_to_be_present_in_element 某个节点文本包含某文字
text_to_be_present_in_element_value 某个节点值包含某文字
frame_to_be_available_and_switch_to_it frame 加载并切换
invisibility_of_element_located 节点不可见
element_to_be_clickable 节点可点击
staleness_of 判断一个节点是否仍在 DOM,可判断页面是否已经刷新
element_to_be_selected 节点可选择,传节点对象
element_located_to_be_selected 节点可选择,传入定位元组
element_selection_state_to_be 传入节点对象以及状态,相等返回 True,否则返回 False
element_located_selection_state_to_be 传入定位元组以及状态,相等返回 True,否则返回 False
alert_is_present 是否出现 Alert

更多详细的等待条件的参数及用法介绍可以参考官方文档:https://selenium-python.readthedocs.io/api.html#module-selenium.webdriver.support.expected_conditions

前进后退

平常使用浏览器时都有前进和后退功能,Selenium 也可以完成这个操作,它使用 back() 方法后退,使用 forward() 方法前进。示例如下:

1
2
3
4
5
6
7
8
9
10
11
import time
from selenium import webdriver

browser = webdriver.Chrome()
browser.get('https://www.baidu.com/')
browser.get('https://www.taobao.com/')
browser.get('https://www.python.org/')
browser.back()
time.sleep(1)
browser.forward()
browser.close()

这里我们连续访问 3 个页面,然后调用 back() 方法回到第二个页面,接下来再调用 forward() 方法又可以前进到第三个页面。

Cookies

使用 Selenium,还可以方便地对 Cookies 进行操作,例如获取、添加、删除 Cookies 等。示例如下:

1
2
3
4
5
6
7
8
9
from selenium import webdriver

browser = webdriver.Chrome()
browser.get('https://www.zhihu.com/explore')
print(browser.get_cookies())
browser.add_cookie({'name': 'name', 'domain': 'www.zhihu.com', 'value': 'germey'})
print(browser.get_cookies())
browser.delete_all_cookies()
print(browser.get_cookies())

首先,我们访问了知乎。加载完成后,浏览器实际上已经生成 Cookies 了。接着,调用 get_cookies() 方法获取所有的 Cookies。然后,我们添加一个 Cookie,这里传入一个字典,有 name、domain 和 value 等内容。接下来,再次获取所有的 Cookies。可以发现,结果就多了这一项新加的 Cookie。最后,调用 delete_all_cookies() 方法删除所有的 Cookies。再重新获取,发现结果就为空了。

控制台的输出如下:

image-20230305102543449

通过以上方法来操作 Cookies 还是非常方便的。

选项卡窗口切换

在访问网页的时候,会开启一个个选项卡。在 Selenium 中,我们也可以对选项卡进行操作。示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import time
from selenium import webdriver

browser = webdriver.Chrome()
browser.get('https://www.baidu.com')
browser.execute_script('window.open()')
print(browser.window_handles)
# browser.switch_to_window(browser.window_handles[1]) #selenium3
browser.switch_to.window(browser.window_handles[1]) #selenium4
browser.get('https://www.taobao.com')
time.sleep(1)
# browser.switch_to_window(browser.window_handles[0])#selenium3
browser.switch_to.window(browser.window_handles[0]) #selenium4
browser.get('https://python.org')

控制台输出如下:

image-20230305103106509

首先访问了百度,然后调用了 execute_script() 方法,这里传入 window.open() 这个 JavaScript 语句新开启一个选项卡。接下来,我们想切换到该选项卡。

这里调用 window_handles 属性获取当前开启的所有选项卡,返回的是选项卡的代号列表。要想切换选项卡,只需要调用 switch_to_window() 方法即可,其中参数是选项卡的代号。这里我们将第二个选项卡代号传入,即跳转到第二个选项卡,

接下来在第二个选项卡下打开一个新页面,然后切换回第一个选项卡重新调用 switch_to_window() 方法,再执行其他操作即可。

异常处理

在使用 Selenium 的过程中,难免会遇到一些异常,例如超时、节点未找到等错误,一旦出现此类错误,程序便不会继续运行了。这里我们可以使用 try except 语句来捕获各种异常。

首先,演示一下节点未找到的异常,示例如下:

1
2
3
4
5
from selenium import webdriver

browser = webdriver.Chrome()
browser.get('https://www.baidu.com')
browser.find_element_by_id('hello')

这里首先打开百度页面,然后尝试选择一个并不存在的节点,此时就会遇到异常。

运行之后控制台的输出如下:

image-20230305103307080

可以看到,这里抛出了 NoSuchElementException 异常,这通常是节点未找到的异常。为了防止程序遇到异常而中断,我们需要捕获这些异常,示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from selenium import webdriver
from selenium.webdriver.common.by import By

from selenium import webdriver
from selenium.common.exceptions import TimeoutException, NoSuchElementException

browser = webdriver.Chrome()
try:
browser.get('https://www.baidu.com')
except TimeoutException:
print('Time Out')
try:
browser.find_element(By.ID,'hello')
except NoSuchElementException:
print('No Element')
finally:
browser.close()

这里我们使用 try except 来捕获各类异常。比如,我们对 find_element_by_id() 查找节点的方法捕获 NoSuchElementException 异常,这样一旦出现这样的错误,就进行异常处理,程序也不会中断了。

控制台的输出如下:

image-20230305103404108

关于更多的异常类,可以参考官方文档:https://selenium-python.readthedocs.io/api.html#module-selenium.common.exceptions

现在,我们基本对 Selenium 的常规用法有了大体的了解。使用 Selenium,处理 JavaScript 不再是难事

反屏蔽

现在很多网站增加了对selenium的检测,防止一些爬虫的恶意爬取,如果检测到有人使用selenium打开浏览器,直接屏蔽。

在大多数情况下,检测的基本原理就是检测当前浏览器窗口口的window.navigator对象中是否包含webdriver属性。

正常使用浏览器该属性是undefined,用selenium时会给该属性赋值。

示例网站:https://antispider1.scrape.center/,该站点就有检测是否存在webdriver属性

image-20230305104851987

1
Object.defineProperty(navigator,"webdriver",{get:()=>undefined})

这里用JavaScript执行代码将webdriver置为空的思路是不行的,因为execute_script方法是在页面加载完毕之后才调用的,太晚了。

在selenium中,可以使用CDP(chrome devtools protocol,chrome开发工具协议)解决这个问题,利用它可以实现在每个页面刚加载时就执行JavaScript语句,将webdriver属性置为空。

这里执行的CDP方法叫做Page.addScriptToEvaluateOnNewDocument,将上面的js语句传入即可,另外,还可以加入一些选项来隐藏wendriver提示条和自动化扩展信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver import ChromeOptions

option = ChromeOptions()
option.add_experimental_option('excludeSwitches',['enable-automation'])
option.add_experimental_option('useAutomationExtension',False)
browser = webdriver.Chrome(options=option)
browser.execute_cdp_cmd('Page.addScriptToEvaluateOnNewDocument',{
'source': 'Object.defineProperty(navigator,"webdriver",{get:()=>undefined})'
})
browser.get('https://antispider1.scrape.center/')
print(browser.page_source)

image-20230305104923567

无头模式

如果不设置,总会弹出一个浏览器窗口,虽然有助于观察页面的爬取情况,但是窗口弹来弹去有时也会造成一些干扰。

chrome浏览器从60版本起,已经开启了对无头模式的支持,即headless。无头模式下,在网站运行的时候不会弹出窗口,从而减少了干扰。同时还减少了一些资源(图片)的加载,所以无头模式也在一定程度上节省了资源加载的时间和网络带宽。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver import ChromeOptions
# 方式1:
# chrome_op = Options()
# chrome_op.add_argument('--headless')
# browser = webdriver.Chrome(chrome_options=chrome_op)
# 方式2:(推荐)
option = ChromeOptions()
option.add_argument('--headless')
browser = webdriver.Chrome(options=option)
browser.set_window_size(1366,768)
browser.get('https://www.baidu.com')
browser.get_screenshot_as_file('preview.png')
sleep(5)

无头模式下最好设置一下窗口的大小,这里将页面截图保存到图片了。

可以发现没窗口弹出,代码依旧正常运行

Selenium 爬取实战

selenium也是可以做到无页面爬取的

目标:https://spa2.scrape.center/

首页:

image-20230305154711802

详情页:

image-20230305154855788

观察:接口参数有一个token字段,每次请求的token都不同,看着像是BASE64编码的,而且还有时效性,直接复制下来没有用,过段时间访问就401了。

之前我们用requests构造Ajax请求,但是Ajax请求中有token,而且还是可变的。我们不清楚token的生成逻辑,就没有办法构造请求来爬取数据。怎么办?

造一个token比较困难,此时就可以用selenium绕过这个阶段,直接获取JavaScript渲染完的页面源代码,再解析数据。

  • 通过selenium遍历列表页,获取每部电影的详情页URL
  • 通过selenium根据上一步获取的详情页URL爬取每部电影的详情页。
  • 从详情页中提取每部电影的名称、类别、分数、简介、封面等内容

爬取列表页:https://spa2.scrape.center/api/movies/page/1

怎么判断列表页加载成功?当页面出现了想要的内容就代表加载成功了,可以使用selenium隐式判断条件。

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
#!/usr/bin/env python
# -*- coding:utf-8 -*- 
import asyncio
import json
import logging
from os import makedirs
from os.path import exists
from urllib.parse import urljoin

import aiohttp
import requests
import pymongo
from selenium import webdriver
from selenium.common import TimeoutException
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.wait import WebDriverWait

from selenium.webdriver import ChromeOptions

logging.basicConfig(level=logging.INFO,
format='%(asctime)s - %(levelname)s: %(message)s')

INDEX_URL = 'https://spa2.scrape.center/page/{page}'
TIMEOUT = 10
TOTAL_PAGE = 10
RESULTS_DIR = 'results'

exists(RESULTS_DIR) or makedirs(RESULTS_DIR)

options = webdriver.ChromeOptions()
options.add_argument('--headless')
options.add_experimental_option('excludeSwitches', ['enable-automation'])
options.add_experimental_option('useAutomationExtension', False)

browser = webdriver.Chrome(options=options)
wait = WebDriverWait(browser, TIMEOUT)


def scrape_page(url, condition, locator):
logging.info('scraping %s', url)
try:
browser.get(url)
wait.until(condition(locator))
except TimeoutException:
logging.error('error occurred while scraping %s', url, exc_info=True)


def scrape_index(page):
url = INDEX_URL.format(page=page)
scrape_page(url, condition=EC.visibility_of_all_elements_located,
locator=(By.CSS_SELECTOR, '#index .item'))


def parse_index():
elements = browser.find_elements(By.CSS_SELECTOR,'#index .item .name')
for element in elements:
href = element.get_attribute('href')
yield urljoin(INDEX_URL, href)


def scrape_detail(url):
scrape_page(url, condition=EC.visibility_of_element_located,
locator=(By.TAG_NAME, 'h2'))


def parse_detail():
url = browser.current_url
name = browser.find_element(By.TAG_NAME,'h2').text
categories = [element.text for element in browser.find_elements(By.CSS_SELECTOR,'.categories button span')]
cover = browser.find_element(By.CSS_SELECTOR,'.cover').get_attribute('src')
score = browser.find_element(By.CLASS_NAME,'score').text
drama = browser.find_element(By.CSS_SELECTOR,'.drama p').text
return {
'url': url,
'name': name,
'categories': categories,
'cover': cover,
'score': score,
'drama': drama
}


def save_data(data):
name = data.get('name')
data_path = f'{RESULTS_DIR}/{name}.json'
json.dump(data, open(data_path, 'w', encoding='utf-8'), ensure_ascii=False, indent=2)


def main():
try:
for page in range(1, TOTAL_PAGE + 1):
scrape_index(page)
detail_urls = parse_index()
for detail_url in list(detail_urls):
logging.info('get detail url %s', detail_url)
scrape_detail(detail_url)
detail_data = parse_detail()
logging.info('detail data %s', detail_data)
save_data(detail_data)
finally:
browser.close()


if __name__ == '__main__':
main()

Splash的使用

Splash 是一个 JavaScript 渲染服务,是一个含有HTTPapi的轻量级服浏览器,它还对接了python中的Twisted库和QT库。利用它,同样可以爬取动态渲染的页面。

Splash 更轻量级,但缺点是功能没有Selenium丰富。(所以 Selenium 才称得上是自动化测试框架,Splash更多的算一种网页渲染服务)

功能介绍

利用splash,可以实现如下功能:

  • 异步处理多个网页的渲染过程
  • 获取渲染后页面的源代码或截图
  • 通过关闭图片渲染或者使用adblock规则的方式加快页面渲染的速度
  • 执行特定的JavaScript脚本
  • 通过La脚本控制页面的渲染过程
  • 获取页面渲染的详细过程并以HAR(HTTP Archive)的格式呈现出来

准备工作

Splash 建议的安装方式是通过 Docker,安装是通过 Docker 安装,在这之前请确保已经正确安装好了 Docker,可以参考:https://setup.scrape.center/docker。

安装

有了 Docker,只需要一键启动 Splash 即可,命令如下:

1
docker run -p 8050:8050 scrapinghub/splash

安装完成之后会有类似的输出结果:

image-20231003072706288

这样就证明 Splash 已经在 8050 端口上运行了。

这时我们打开:http://localhost:8050 即可看到 Splash 的主页,如图所示:

image-20230305110857718

当然 Splash 也可以直接安装在远程服务器上,我们在服务器上运行以守护态运行 Splash 即可,命令如下:

1
docker run -d -p 8050:8050 scrapinghub/splash

在这里多了一个 -d 参数,它代表将 Docker 容器以守护态运行,这样在中断远程服务器连接后不会终止 Splash 服务的运行。

实例引入

在右侧呈现的是一个渲染示例,我们可以看到在上方有一个输入框,默认是 http://google.com,我们在这里换成百度测试一下,将内容更改为:https://www.baidu.com,然后点击按钮,开始渲染,结果如图 7-7 所示:

image-20230305111559228

可以看到,网页的返回结果呈现了渲染截图、HAR 加载统计数据、网页的源代码。

通过 HAR 的结果可以看到,Splash 执行了整个网页的渲染过程,包括 CSS、JavaScript 的加载等过程,呈现的页面和我们在浏览器中得到的结果完全一致。

那么,这个过程由什么来控制呢?重新返回首页,可以看到实际上是有一段脚本,内容如下

1
2
3
4
5
6
7
function main(splash, args)
assert(splash:go(args.url))
assert(splash:wait(0.5))
return {html = splash:html(),
png = splash:png(),
har = splash:har(),}
end

这个脚本实际上是用 Lua 语言写的脚本。即使不懂这个语言的语法,但从脚本的表面意思,我们也可以大致了解到它首先调用 go 方法去加载页面,然后调用 wait 方法等待了一定时间,最后返回了页面的源码、截图和 HAR 信息。

到这里,我们大体了解了 Splash 是通过 Lua 脚本来控制了页面的加载过程的,加载过程完全模拟浏览器,最后可返回各种格式的结果,如网页源码和截图等。

接下来,我们就来了解 Lua 脚本的写法以及相关 API 的用法。

Splash API 调用


1
http://localhost:8050/render.html?url=https://www.baidu.comhttp://localhost:8050/render.png?url=https://www.baidu.comhttp://localhost:8050/render.jpeg?url=https://www.baidu.comhttp://localhost:8050/render.har?url=https://www.baidu.comhttp://localhost:8050/render.json?url=https://www.baidu.com

除了上面指定的最简单的url、render类型,还可以通过 Lua 脚本执行更复杂的渲染操作和交互逻辑(即用 execute)。

我们用 python 代码为例:

1
2
3
4
5
6
7
8
9
import requests
from urllib.parse import quotelua = """
function main(splash)
return 'hello'
end
"""
url = 'http://localhost:8050/execute?lua_source=' + quote(lua)
response = requests.get(url)
print(response.text)

下面我们对 Lua 脚本的写法做更多的介绍。

Splash 的 Lua 脚本

Splash 可以通过 Lua 脚本执行一系列渲染操作,这样我们就可以用 Splash 来模拟类似 Chrome、PhantomJS 的操作了。

首先,我们来了解一下 Splash Lua 脚本的入口和执行方式。

入口 main() 函数

1
2
3
function main(splash)
return {hello="world!"}
end

这样即返回了一个字典形式的内容。

1
2
3
function main(splash)
return 'hello'
end

这样即返回了一个字符串形式的内容,同样是可以的。

main 函数就是 splash 默认要调用的函数,所以这里保持固定写法就好。

main 的返回值,既可以是字典形式,也可以是字符串形式,最后都会转化为 HTTP Response。

1
2
3
4
5
6
function main(splash, args)
splash:go("http://www.baidu.com")
splash:wait(0.5)
local title = splash:evaljs("document.title")
return {title=title}
end

我们将代码粘贴到刚才我们所打开的:http://localhost:8050/ 的代码编辑区域,然后点击 Render me! 按钮来测试一下。

我们看到它返回了网页的标题,

image-20230305111825188

这里我们通过 evaljs 方法传入 JavaScript 脚本,而 document.title 的执行结果就是返回网页标题,执行完毕后将其赋值给一个 title 变量,随后将其返回。

异步处理

Splash 支持异步处理,但是这里并没有显式指明回调方法,其回调的跳转是在 Splash 内部完成的。示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
function main(splash, args)
local example_urls = {"www.baidu.com", "www.taobao.com", "www.zhihu.com"}
local urls = args.urls or example_urls
local results = {}
for index, url in ipairs(urls) do
local ok, reason = splash:go("http://" .. url)
if ok then
splash:wait(2)
results[url] = splash:png()
end
end
return results
end

运行后的返回结果是 3 个站点的截图

image-20230305112036238

在脚本内调用的 wait 方法类似于 Python 中的 sleep 方法,其参数为等待的秒数。当 Splash 执行到此方法时,它会转而去处理其他任务,然后在指定的时间过后再回来继续处理。

这里值得注意的是,Lua 脚本中的字符串拼接和 Python 不同,它使用的是.. 操作符,而不是 +。如果有必要,可以简单了解一下 Lua 脚本的语法,详见 http://www.runoob.com/lua/lua-basic-syntax.html。

另外,这里做了加载时的异常检测。go 方法会返回加载页面的结果状态,如果页面出现 4xx 或 5xx 状态码,ok 变量就为空,就不会返回加载后的图片。

Splash 对象属性

我们注意到,前面例子中 main 方法的第一个参数是 splash,这个对象非常重要,它类似于 Selenium 中的 WebDriver 对象,我们可以调用它的一些属性和方法来控制加载过程。接下来,先看下它的属性。

args

该属性可以获取加载时配置的参数,比如 URL,如果为 GET 请求,它还可以获取 GET 请求参数;如果为 POST 请求,它可以获取表单提交的数据。Splash 也支持使用第二个参数直接作为 args,例如:

1
2
3
function main(splash, args)
local url = args.url
end

这里第二个参数 args 就相当于 splash.args 属性,以上代码等价于:

1
2
3
function main(splash)
local url = splash.args.url
end

js_enabled

这个属性是 Splash 的 JavaScript 执行开关,可以将其配置为 true 或 false 来控制是否执行 JavaScript 代码,默认为 true。例如,这里禁止执行 JavaScript 代码:

1
2
3
4
5
6
function main(splash, args)
splash:go("https://www.baidu.com")
splash.js_enabled = false
local title = splash:evaljs("document.title")
return {title=title}
end

接着我们重新调用了 evaljs 方法执行 JavaScript 代码,此时运行结果就会抛出异常:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"error": 400,
"type": "ScriptError",
"info": {
"type": "JS_ERROR",
"js_error_message": null,
"source": "[string \"function main(splash, args)\r...\"]",
"message": "[string \"function main(splash, args)\r...\"]:4: unknown JS error: None",
"line_number": 4,
"error": "unknown JS error: None",
"splash_method": "evaljs"
},
"description": "Error happened while executing Lua script"
}

不过一般来说我们不用设置此属性开关,默认开启即可。

resource_timeout

此属性可以设置加载的超时时间,单位是秒。如果设置为 0 或 nil(类似 Python 中的 None),代表不检测超时。示例如下

1
2
3
4
5
function main(splash)
splash.resource_timeout = 0.1
assert(splash:go('https://www.taobao.com'))
return splash:png()
end

例如,这里将超时时间设置为 0.1 秒。如果在 0.1 秒之内没有得到响应,就会抛出异常,错误如下

1
2
3
4
5
6
7
8
9
10
11
12
{
"error": 400,
"type": "ScriptError",
"info": {
"error": "network5",
"type": "LUA_ERROR",
"line_number": 3,
"source": "[string \"function main(splash)\r...\"]",
"message": "Lua error: [string \"function main(splash)\r...\"]:3: network5"
},
"description": "Error happened while executing Lua script"
}

此属性适合在网页加载速度较慢的情况下设置。如果超过了某个时间无响应,则直接抛出异常并忽略即可。

images_enabled

此属性可以设置图片是否加载,默认情况下是加载的。禁用该属性后,可以节省网络流量并提高网页加载速度。但是需要注意的是,禁用图片加载可能会影响 JavaScript 渲染。因为禁用图片之后,它的外层 DOM 节点的高度会受影响,进而影响 DOM 节点的位置。因此,如果 JavaScript 对图片节点有操作的话,其执行就会受到影响。

另外值得注意的是,Splash 使用了缓存。如果一开始加载出来了网页图片,然后禁用了图片加载,再重新加载页面,之前加载好的图片可能还会显示出来,这时直接重启 Splash 即可。

禁用图片加载的示例如下:

1
2
3
4
5
function main(splash, args)
splash.images_enabled = false
assert(splash:go('https://www.jd.com'))
return {png=splash:png()}
end

这样返回的页面截图就不会带有任何图片,加载速度也会快很多。

plugins_enabled

此属性可以控制浏览器插件(如 Flash 插件)是否开启。默认情况下,此属性是 false,表示不开启。可以使用如下代码控制其开启和关闭:

1
splash.plugins_enabled = true/false

scroll_position

通过设置此属性,我们可以控制页面上下或左右滚动。这是一个比较常用的属性,示例如下:

1
2
3
4
5
function main(splash, args)
assert(splash:go('https://www.taobao.com'))
splash.scroll_position = {y=400}
return {png=splash:png()}
end

这样我们就可以控制页面向下滚动 400 像素值,

image-20230305112437600

如果要让页面左右滚动,可以传入 x 参数,代码如下

1
splash.scroll_position = {x=100, y=200}

Splash 对象方法

除了前面介绍的属性外,Splash 对象还有如下方法。

go

该方法用来请求某个链接,而且它可以模拟 GET 和 POST 请求,同时支持传入请求头、表单等数据,其用法如下:

1
ok, reason = splash:go{url, baseurl=nil, headers=nil, http_method="GET", body=nil, formdata=nil}

参数说明如下:

  • url,即请求的 URL。
  • baseurl,可选参数,默认为空,资源加载相对路径。
  • headers,可选参数,默认为空,请求的 Headers。
  • http_method,可选参数,默认为 GET,同时支持 POST。
  • body,可选参数,默认为空,POST 的时候的表单数据,使用的 Content-type 为 application/json。
  • formdata,可选参数,默认为空,POST 的时候表单数据,使用的 Content-type 为 application/x-www-form-urlencoded。

该方法的返回结果是结果 ok 和原因 reason 的组合,如果 ok 为空,代表网页加载出现了错误,此时 reason 变量中包含了错误的原因,否则证明页面加载成功。示例如下:

1
2
3
4
5
6
function main(splash, args)
local ok, reason = splash:go{"http://httpbin.org/post", http_method="POST", body="name=Germey"}
if ok then
return splash:html()
end
end

这里我们模拟了一个 POST 请求,并传入了 POST 的表单数据,如果成功,则返回页面的源代码。

运行结果如下:

1
2
3
4
<html><head></head><body><pre style="word-wrap: break-word; white-space: pre-wrap;">{"args": {}, 
"data": "","files": {},"form": {"name":"Germey"},"headers": {"Accept":"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8","Accept-Encoding":"gzip, deflate","Accept-Language":"en,*","Connection":"close","Content-Length":"11","Content-Type":"application/x-www-form-urlencoded","Host":"httpbin.org","Origin":"null","User-Agent":"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/602.1 (KHTML, like Gecko) splash Version/9.0 Safari/602.1"},"json": null,"origin":"60.207.237.85","url":"http://httpbin.org/post"
}
</pre></body></html>

可以看到,我们成功实现了 POST 请求并发送了表单数据。

wait

此方法可以控制页面等待时间,使用方法如下:

1
ok, reason = splash:wait{time, cancel_on_redirect=false, cancel_on_error=true}

参数说明如下:

  • time,等待的秒数。
  • cancel_on_redirect,可选参数,默认 False,如果发生了重定向就停止等待,并返回重定向结果。
  • cancel_on_error,可选参数,默认 False,如果发生了加载错误就停止等待。

返回结果同样是结果 ok 和原因 reason 的组合。

我们用一个实例感受一下:

1
2
3
4
5
function main(splash)
splash:go("https://www.taobao.com")
splash:wait(2)
return {html=splash:html()}
end

如上代码可以实现访问淘宝并等待 2 秒,随后返回页面源代码的功能。

jsfunc

此方法可以直接调用 JavaScript 定义的方法,但是所调用的方法需要用双中括号包围,这相当于实现了 JavaScript 方法到 Lua 脚本的转换。示例如下:

1
2
3
4
5
6
7
8
9
10
function main(splash, args)
local get_div_count = splash:jsfunc([[function () {
var body = document.body;
var divs = body.getElementsByTagName('div');
return divs.length;
}
]])
splash:go("https://www.baidu.com")
return ("There are % s DIVs"):format(get_div_count())
end

运行结果:

1
There are 21 DIVs

首先,我们声明了一个 JavaScript 定义的方法,然后在页面加载成功后调用了此方法计算出了页面中 div 节点的个数。

关于 JavaScript 到 Lua 脚本的更多转换细节,可以参考官方文档:https://splash.readthedocs.io/en/stable/scripting-ref.html#splash-jsfunc。

evaljs

此方法可以执行 JavaScript 代码并返回最后一条 JavaScript 语句的返回结果,使用方法如下:

1
result = splash:evaljs(js)

比如,可以用下面的代码来获取页面标题:

1
local title = splash:evaljs("document.title")

runjs

此方法可以执行 JavaScript 代码,它与 evaljs 方法的功能类似,但是更偏向于执行某些动作或声明某些方法。例如:

1
2
3
4
5
6
function main(splash, args)
splash:go("https://www.baidu.com")
splash:runjs("foo = function() {return 'bar'}")
local result = splash:evaljs("foo()")
return result
end

这里我们用 runjs 方法先声明了一个 JavaScript 定义的方法,然后通过 evaljs 方法来调用得到的结果。

运行结果如下:

1
bar

autoload

此方法可以设置每个页面访问时自动加载的对象,使用方法如下:

1
ok, reason = splash:autoload{source_or_url, source=nil, url=nil}

参数说明如下:

  • source_or_url,JavaScript 代码或者 JavaScript 库链接。
  • source,JavaScript 代码。
  • url,JavaScript 库链接

但是此方法只负责加载 JavaScript 代码或库,不执行任何操作。如果要执行操作,可以调用 evaljs 或 runjs 方法。示例如下:

1
2
3
4
5
6
function main(splash, args)
splash:autoload([[function get_document_title(){return document.title;}
]])
splash:go("https://www.baidu.com")
return splash:evaljs("get_document_title()")
end

这里我们调用 autoload 方法声明了一个 JavaScript 方法,然后通过 evaljs 方法来执行此 JavaScript 方法。

运行结果如下

1
百度一下,你就知道

另外,我们也可以使用 autoload 方法加载某些方法库,如 jQuery,示例如下:

1
2
3
4
5
6
function main(splash, args)
assert(splash:autoload("https://code.jquery.com/jquery-2.1.3.min.js"))
assert(splash:go("https://www.taobao.com"))
local version = splash:evaljs("$.fn.jquery")
return 'JQuery version: ' .. version
end

运行结果如下:

1
JQuery version: 2.1.3

call_later

此方法可以通过设置定时任务和延迟时间来实现任务延时执行,并且可以在执行前通过 cancel 方法重新执行定时任务。示例如下

1
2
3
4
5
6
7
8
9
10
11
function main(splash, args)
local snapshots = {}
local timer = splash:call_later(function()
snapshots["a"] = splash:png()
splash:wait(1.0)
snapshots["b"] = splash:png()
end, 0.2)
splash:go("https://www.taobao.com")
splash:wait(3.0)
return snapshots
end

这里我们设置了一个定时任务,0.2 秒的时候获取网页截图,然后等待 1 秒,1.2 秒时再次获取网页截图,访问的页面是淘宝,最后将截图结果返回。

image-20230305113336559

可以发现,第一次截图时网页还没有加载出来,截图为空,第二次网页便加载成功了。

http_get

此方法可以模拟发送 HTTP 的 GET 请求,使用方法如下:

1
response = splash:http_get{url, headers=nil, follow_redirects=true}

参数说明如下:

  • url,请求 URL。
  • headers,可选参数,默认为空,请求的 Headers。
  • follow_redirects,可选参数,默认为 True,是否启动自动重定向。

示例如下:

1
2
3
4
5
6
7
8
function main(splash, args)
local treat = require("treat")
local response = splash:http_get("http://httpbin.org/get")
return {html=treat.as_string(response.body),
url=response.url,
status=response.status
}
end

运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Splash Response: Object
html: String (length 355)
{"args": {},
"headers": {
"Accept-Encoding": "gzip, deflate",
"Accept-Language": "en,*",
"Connection": "close",
"Host": "httpbin.org",
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/602.1 (KHTML, like Gecko) splash Version/9.0 Safari/602.1"
},
"origin": "60.207.237.85",
"url": "http://httpbin.org/get"
}
status: 200
url: "http://httpbin.org/get"

http_post

和 http_get 方法类似,此方法是模拟发送一个 POST 请求,不过多了一个参数 body,使用方法如下:

1
response = splash:http_post{url, headers=nil, follow_redirects=true, body=nil}

参数说明如下:

  • url,请求 URL。
  • headers,可选参数,默认为空,请求的 Headers。
  • follow_redirects,可选参数,默认为 True,是否启动自动重定向。
  • body,可选参数,默认为空,即表单数据。

示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
function main(splash, args)
local treat = require("treat")
local json = require("json")
local response = splash:http_post{"http://httpbin.org/post",
body=json.encode({name="Germey"}),
headers={["content-type"]="application/json"}
}
return {html=treat.as_string(response.body),
url=response.url,
status=response.status
}
end

运行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Splash Response: Object
html: String (length 533)
{"args": {},
"data": "{\"name\": \"Germey\"}",
"files": {},
"form": {},
"headers": {
"Accept-Encoding": "gzip, deflate",
"Accept-Language": "en,*",
"Connection": "close",
"Content-Length": "18",
"Content-Type": "application/json",
"Host": "httpbin.org",
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/602.1 (KHTML, like Gecko) splash Version/9.0 Safari/602.1"
},
"json": {"name": "Germey"},
"origin": "60.207.237.85",
"url": "http://httpbin.org/post"
}
status: 200
url: "http://httpbin.org/post"

可以看到在这里我们成功模拟提交了 POST 请求并发送了表单数据。

set_content

此方法可以用来设置页面的内容,示例如下:

1
2
3
4
function main(splash)
assert(splash:set_content("<html><body><h1>hello</h1></body></html>"))
return splash:png()
end

运行结果

image-20230305113556957

html

此方法可以用来获取网页的源代码,它是非常简单又常用的方法,示例如下:

1
2
3
4
function main(splash, args)
splash:go("https://httpbin.org/get")
return splash:html()
end

运行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
<html><head></head><body><pre style="word-wrap: break-word; white-space: pre-wrap;">{"args": {}, 
"headers": {
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Encoding": "gzip, deflate",
"Accept-Language": "en,*",
"Connection": "close",
"Host": "httpbin.org",
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/602.1 (KHTML, like Gecko) splash Version/9.0 Safari/602.1"
},
"origin": "60.207.237.85",
"url": "https://httpbin.org/get"
}
</pre></body></html>

png

此方法可以用来获取 PNG 格式的网页截图,示例如下:

1
2
3
4
function main(splash, args)
splash:go("https://www.taobao.com")
return splash:png()
end

jpeg

此方法可以用来获取 JPEG 格式的网页截图,示例如下:

1
2
3
4
function main(splash, args)
splash:go("https://www.taobao.com")
return splash:jpeg()
end

har

此方法可以用来获取页面加载过程描述,示例如下:

1
2
3
4
function main(splash, args)
splash:go("https://www.baidu.com")
return splash:har()
end

运行结果

image-20230305113726139

在这里显示了页面加载过程中的每个请求记录详情。

url

此方法可以获取当前正在访问的 URL,示例如下

1
2
3
4
function main(splash, args)
splash:go("https://www.baidu.com")
return splash:url()
end

运行结果

1
https://www.baidu.com/

get_cookies

此方法可以获取当前页面的 Cookies,示例如下:

1
2
3
4
function main(splash, args)
splash:go("https://www.baidu.com")
return splash:get_cookies()
end

运行结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Splash Response: Array[2]
0: Object
domain: ".baidu.com"
expires: "2085-08-21T20:13:23Z"
httpOnly: false
name: "BAIDUID"
path: "/"
secure: false
value: "C1263A470B02DEF45593B062451C9722:FG=1"
1: Object
domain: ".baidu.com"
expires: "2085-08-21T20:13:23Z"
httpOnly: false
name: "BIDUPSID"
path: "/"
secure: false
value: "C1263A470B02DEF45593B062451C9722"

此方法可以为当前页面添加 Cookies,用法如下:

1
cookies = splash:add_cookie{name, value, path=nil, domain=nil, expires=nil, httpOnly=nil, secure=nil}

方法的各个参数代表了 Cookie 的各个属性。

示例如下

1
2
3
4
5
function main(splash)
splash:add_cookie{"sessionid", "237465ghgfsd", "/", domain="http://example.com"}
splash:go("http://example.com/")
return splash:html()
end

clear_cookies

此方法可以清除所有的 Cookies,示例如下:

1
2
3
4
5
function main(splash)
splash:go("https://www.baidu.com/")
splash:clear_cookies()
return splash:get_cookies()
end

在这里我们清除了所有的 Cookies,然后再调用 get_cookies() 并将结果返回。

运行结果:

1
Splash Response: Array[0]

可以看到 Cookies 被全部清空,没有任何结果。

get_viewport_size

此方法可以获取当前浏览器页面的大小,即宽高,示例如下:

1
2
3
4
function main(splash)
splash:go("https://www.baidu.com/")
return splash:get_viewport_size()
end

运行结果:

1
2
3
Splash Response: Array[2]
0: 1024
1: 768

set_viewport_size

此方法可以设置当前浏览器页面的大小,即宽高,用法如下:

1
splash:set_viewport_size(width, height)

例如这里我们访问一个宽度自适应的页面,示例如下:

1
2
3
4
5
function main(splash)
splash:set_viewport_size(400, 700)
assert(splash:go("http://cuiqingcai.com"))
return splash:png()
end

运行结果

image-20230305113931856

set_viewport_full

此方法可以设置浏览器全屏显示,示例如下:

1
2
3
4
5
function main(splash)
splash:set_viewport_full()
assert(splash:go("http://cuiqingcai.com"))
return splash:png()
end

set_user_agent

此方法可以设置浏览器的 User-Agent,示例如下:

1
2
3
4
5
function main(splash)
splash:set_user_agent('Splash')
splash:go("http://httpbin.org/get")
return splash:html()
end

在这里我们将浏览器的 User-Agent 设置为 Splash,运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
<html><head></head><body><pre style="word-wrap: break-word; white-space: pre-wrap;">{"args": {}, 
"headers": {
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Encoding": "gzip, deflate",
"Accept-Language": "en,*",
"Connection": "close",
"Host": "httpbin.org",
"User-Agent": "Splash"
},
"origin": "60.207.237.85",
"url": "http://httpbin.org/get"
}
</pre></body></html>

可以看到此处 User-Agent 被成功设置。

set_custom_headers()

此方法可以设置请求的 Headers,示例如下:

1
2
3
4
5
6
7
function main(splash)
splash:set_custom_headers({["User-Agent"] = "Splash",
["Site"] = "Splash",
})
splash:go("http://httpbin.org/get")
return splash:html()
end

在这里我们设置了 Headers 中的 User-Agent 和 Site 属性,运行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<html><head></head><body><pre style="word-wrap: break-word; white-space: pre-wrap;">{"args": {}, 
"headers": {
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Encoding": "gzip, deflate",
"Accept-Language": "en,*",
"Connection": "close",
"Host": "httpbin.org",
"Site": "Splash",
"User-Agent": "Splash"
},
"origin": "60.207.237.85",
"url": "http://httpbin.org/get"
}
</pre></body></html>

可以看到结果的 Headers 中两个字段被成功设置。

select

该方法可以选中符合条件的第一个节点,如果有多个节点符合条件,则只会返回一个,其参数是 CSS 选择器。示例如下:

1
2
3
4
5
6
7
function main(splash)
splash:go("https://www.baidu.com/")
input = splash:select("#kw")
input:send_text('Splash')
splash:wait(3)
return splash:png()
end

这里我们首先访问了百度,然后选中了搜索框,随后调用了 send_text() 方法填写了文本,然后返回网页截图。

结果如图 7-15 所示,可以看到,我们成功填写了输入框

image-20230305114059695

可以看到我们成功填写了输入框。

select_all()

此方法可以选中所有的符合条件的节点,其参数是 CSS 选择器。示例如下:

1
2
3
4
5
6
7
8
9
10
11
function main(splash)
local treat = require('treat')
assert(splash:go("http://quotes.toscrape.com/"))
assert(splash:wait(0.5))
local texts = splash:select_all('.quote .text')
local results = {}
for index, text in ipairs(texts) do
results[index] = text.node.innerHTML
end
return treat.as_array(results)
end

这里我们通过 CSS 选择器选中了节点的正文内容,随后遍历了所有节点,将其中的文本获取下来。

运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
Splash Response: Array[10]
0: "“The world as we have created it is a process of our thinking. It cannot be changed without changing our thinking.”"
1: "“It is our choices, Harry, that show what we truly are, far more than our abilities.”"
2: “There are only two ways to live your life. One is as though nothing is a miracle. The other is as though everything is a miracle.”
3: "“The person, be it gentleman or lady, who has not pleasure in a good novel, must be intolerably stupid.”"
4: "“Imperfection is beauty, madness is genius and it's better to be absolutely ridiculous than absolutely boring.”"
5: "“Try not to become a man of success. Rather become a man of value.”"
6: "“It is better to be hated for what you are than to be loved for what you are not.”"
7: "“I have not failed. I've just found 10,000 ways that won't work.”"
8: "“A woman is like a tea bag; you never know how strong it is until it's in hot water.”"
9: "“A day without sunshine is like, you know, night.”"

可以发现我们成功将 10 个节点的正文内容获取了下来。

mouse_click

此方法可以模拟鼠标点击操作,传入的参数为坐标值 x、y,也可以直接选中某个节点直接调用此方法,示例如下:

1
2
3
4
5
6
7
8
9
function main(splash)
splash:go("https://www.baidu.com/")
input = splash:select("#kw")
input:send_text('Splash')
submit = splash:select('#su')
submit:mouse_click()
splash:wait(3)
return splash:png()
end

在这里我们首先选中了页面的输入框,输入了文本,然后选中了提交按钮,调用了 mouse_click() 方法提交查询,然后页面等待三秒,返回截图,结果如图 7-16 所示

image-20230305114152904

可以看到在这里我们成功获取了查询后的页面内容,模拟了百度搜索操作。

以上我们介绍了 Splash 的常用 API 操作,还有一些 API 在这不再一一介绍,更加详细和权威的说明可以参见官方文档:https://splash.readthedocs.io/en/stable/scripting-ref.html,此页面介绍了 splash 对象的所有 API 操作,另外还有针对于页面元素的 API 操作,链接为:https://splash.readthedocs.io/en/stable/scripting-element-object.html

Splash API 调用

在上文中我们说明了 Splash Lua 脚本的用法,但这些脚本是在 Splash 页面里面测试运行的,我们如何才能利用 Splash 来渲染页面呢?怎样才能和 Python 程序结合使用并抓取 JavaScript 渲染的页面呢?

其实 Splash 给我们提供了一些 HTTP API 接口,我们只需要请求这些接口并传递相应的参数即可获取页面渲染后的结果,下面我们对这些接口进行介绍:

render.html

此接口用于获取 JavaScript 渲染的页面的 HTML 代码,接口地址就是 Splash 的运行地址加此接口名称,例如:http://localhost:8050/render.html,我们可以用 curl 来测试一下:

1
curl http://localhost:8050/render.html?url=https://www.baidu.com

我们给此接口传递了一个 url 参数指定渲染的 URL,返回结果即页面渲染后的源代码。

如果用 Python 实现的话,代码如下:

1
2
3
4
import requests
url = 'http://localhost:8050/render.html?url=https://www.baidu.com'
response = requests.get(url)
print(response.text)

这样就可以成功输出百度页面渲染后的源代码了。

另外,此接口还可以指定其他参数,比如通过 wait 指定等待秒数。如果要确保页面完全加载出来,可以增加等待时间,例如:

1
2
3
4
import requests  
url = 'http://localhost:8050/render.html?url=https://www.taobao.com&amp;wait=5'
response = requests.get(url)
print(response.text)

如果增加了此等待时间后,得到响应的时间就会相应变长,如在这里我们会等待大约 5 秒多钟即可获取 JavaScript 渲染后的淘宝页面源代码。

另外此接口还支持代理设置、图片加载设置、Headers 设置、请求方法设置,具体的用法可以参见官方文档:https://splash.readthedocs.io/en/stable/api.html#render-html。

render.png

此接口可以获取网页截图,其参数比 render.html 多了几个,比如通过 width 和 height 来控制宽高,它返回的是 PNG 格式的图片二进制数据。示例如下:

1
curl http://localhost:8050/render.png?url=https://www.taobao.com&wait=5&width=1000&height=700

在这里我们还传入了 width 和 height 来放缩页面大小为 1000x700 像素。

如果用 Python 实现,我们可以将返回的二进制数据保存为 PNG 格式的图片,实现如下

1
2
3
4
5
6
import requests

url = 'http://localhost:8050/render.png?url=https://www.jd.com&wait=5&width=1000&height=700'
response = requests.get(url)
with open('taobao.png', 'wb') as f:
f.write(response.content)

得到的图片

image-20230305114409125

这样我们就成功获取了京东首页渲染完成后的页面截图,详细的参数设置可以参考官网文档 https://splash.readthedocs.io/en/stable/api.html#render-png。

render.jpeg

此接口和 render.png 类似,不过它返回的是 JPEG 格式的图片二进制数据。

另外此接口相比 render.png 还多了一个参数 quality,可以用来设置图片质量。

render.har

此接口用于获取页面加载的 HAR 数据,示例如下:

1
curl http://localhost:8050/render.har?url=https://www.jd.com&wait=5

返回结果非常多,是一个 Json 格式的数据,里面包含了页面加载过程中的 HAR 数据。

结果如图

image-20230305114439096

render.json

此接口包含了前面接口的所有功能,返回结果是 Json 格式,示例如下:

1
curl http://localhost:8050/render.json?url=https://httpbin.org

结果:

1
{"title": "httpbin(1): HTTP Client Testing Service", "url": "https://httpbin.org/", "requestedUrl": "https://httpbin.org/", "geometry": [0, 0, 1024, 768]}

可以看到,这里以 JSON 形式返回了相应的请求数据。

我们可以通过传入不同参数控制其返回结果。比如,传入 html=1,返回结果即会增加源代码数据;传入 png=1,返回结果即会增加页面 PNG 截图数据;传入 har=1,则会获得页面 HAR 数据。例如:

1
curl http://localhost:8050/render.json?url=https://httpbin.org&html=1&har=1

这样返回的 Json 结果便会包含网页源代码和 HAR 数据。

还有更多参数设置可以参考官方文档:https://splash.readthedocs.io/en/stable/api.html#render-json。

execute

此接口才是最为强大的接口。前面说了很多 Splash Lua 脚本的操作,用此接口便可实现与 Lua 脚本的对接。

前面的 render.html 和 render.png 等接口对于一般的 JavaScript 渲染页面是足够了,但是如果要实现一些交互操作的话,它们还是无能为力,这里就需要使用 execute 接口了。

我们先实现一个最简单的脚本,直接返回数据:

1
2
3
function main(splash)
return 'hello'
end

然后将此脚本转化为 URL 编码后的字符串,拼接到 execute 接口后面,示例如下:

1
curl http://localhost:8050/execute?lua_source=function+main%28splash%29%0D%0A++return+%27hello%27%0D%0Aend

运行结果:

1
hello

这里我们通过 lua_source 参数传递了转码后的 Lua 脚本,通过 execute 接口获取了最终脚本的执行结果。

这里我们更加关心的肯定是如何用 Python 来实现,上例用 Python 实现的话,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
import requests
from urllib.parse import quote

lua = '''
function main(splash)
return 'hello'
end
'''

url = 'http://localhost:8050/execute?lua_source=' + quote(lua)
response = requests.get(url)
print(response.text)

运行结果:

1
hello

这里我们用 Python 中的三引号将 Lua 脚本包括起来,然后用 urllib.parse 模块里的 quote() 方法将脚本进行 URL 转码,随后构造了 Splash 请求 URL,将其作为 lua_source 参数传递,这样运行结果就会显示 Lua 脚本执行后的结果。

我们再通过实例看一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import requests
from urllib.parse import quote

lua = '''
function main(splash, args)
local treat = require("treat")
local response = splash:http_get("http://httpbin.org/get")
return {html=treat.as_string(response.body),
url=response.url,
status=response.status
}
end
'''

url = 'http://localhost:8050/execute?lua_source=' + quote(lua)
response = requests.get(url)
print(response.text)

运行结果:

1
{"url": "http://httpbin.org/get", "status": 200, "html": "{\n  \"args\": {}, \n  \"headers\": {\n    \"Accept-Encoding\": \"gzip, deflate\", \n    \"Accept-Language\": \"en,*\", \n    \"Connection\": \"close\", \n    \"Host\": \"httpbin.org\", \n    \"User-Agent\": \"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/602.1 (KHTML, like Gecko) splash Version/9.0 Safari/602.1\"\n  }, \n  \"origin\": \"60.207.237.85\", \n  \"url\": \"http://httpbin.org/get\"\n}\n"}

可以看到,返回结果是 JSON 形式,我们成功获取了请求的 URL、状态码和网页源代码。

如此一来,我们之前所说的 Lua 脚本均可以用此方式与 Python 进行对接,所有网页的动态渲染、模拟点击、表单提交、页面滑动、延时等待后的一些结果均可以自由控制,获取页面源码和截图也都不在话下。

到现在为止,我们可以用 Python 和 Splash 实现 JavaScript 渲染的页面的抓取了。除了 Selenium,本节所说的 Splash 同样可以做到非常强大的渲染功能,同时它也不需要浏览器即可渲染,使用非常方便。

Splash负载均衡配置

用 Splash 做页面抓取时,如果爬取的量非常大,任务非常多,用一个 Splash 服务来处理的话,未免压力太大了,此时可以考虑搭建一个负载均衡器来把压力分散到各个服务器上。这相当于多台机器多个服务共同参与任务的处理,可以减小单个 Splash 服务的压力。

配置 Splash 服务

要搭建 Splash 负载均衡,首先要有多个 Splash 服务。假如这里在 4 台远程主机的 8050 端口上都开启了 Splash 服务,它们的服务地址分别为

1
2
3
4
41.159.27.223:8050
41.159.27.221:8050
41.159.27.9:8050
41.159.117.119:8050

这 4 个服务完全一致,都是通过 Docker 的 Splash 镜像开启的。访问其中任何一个服务时,都可以使用 Splash 服务。

配置负载均衡

接下来,可以选用任意一台带有公网 IP 的主机来配置负载均衡。首先,在这台主机上装好 Nginx,然后修改 Nginx 的配置文件 nginx.conf,添加如下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
http {
upstream splash {
least_conn;
server 41.159.27.223:8050;
server 41.159.27.221:8050;
server 41.159.27.9:8050;
server 41.159.117.119:8050;
}
server {
listen 8050;
location / {proxy_pass http://splash;}
}
}

这样我们通过 upstream 字段定义了一个名字叫作 splash 的服务集群配置。其中 least_conn 代表最少链接负载均衡,它适合处理请求处理时间长短不一造成服务器过载的情况。

当然,我们也可以不指定配置,具体如下:

1
2
3
4
5
6
upstream splash {
server 41.159.27.223:8050;
server 41.159.27.221:8050;
server 41.159.27.9:8050;
server 41.159.117.119:8050;
}

这样默认以轮询策略实现负载均衡,每个服务器的压力相同。此策略适合服务器配置相当、无状态且短平快的服务使用。

另外,我们还可以指定权重,配置如下:

1
2
3
4
5
6
upstream splash {
server 41.159.27.223:8050 weight=4;
server 41.159.27.221:8050 weight=2;
server 41.159.27.9:8050 weight=2;
server 41.159.117.119:8050 weight=1;
}

这里 weight 参数指定各个服务的权重,权重越高,分配到处理的请求越多。假如不同的服务器配置差别比较大的话,可以使用此种配置。

最后,还有一种 IP 散列负载均衡,配置如下:

1
2
3
4
5
6
7
upstream splash {
ip_hash;
server 41.159.27.223:8050;
server 41.159.27.221:8050;
server 41.159.27.9:8050;
server 41.159.117.119:8050;
}

服务器根据请求客户端的 IP 地址进行散列计算,确保使用同一个服务器响应请求,这种策略适合有状态的服务,比如用户登录后访问某个页面的情形。对于 Splash 来说,不需要应用此设置。

我们可以根据不同的情形选用不同的配置,配置完成后重启一下 Nginx 服务:

1
sudo nginx -s reload

这样直接访问 Nginx 所在服务器的 8050 端口,即可实现负载均衡了。

配置认证

现在 Splash 是可以公开访问的,如果不想让其公开访问,还可以配置认证,这仍然借助于 Nginx。可以在 server 的 location 字段中添加 auth_basic 和 auth_basic_user_file 字段,具体配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
http {
upstream splash {
least_conn;
server 41.159.27.223:8050;
server 41.159.27.221:8050;
server 41.159.27.9:8050;
server 41.159.117.119:8050;
}
server {
listen 8050;
location / {
proxy_pass http://splash;
auth_basic "Restricted";
auth_basic_user_file /etc/nginx/conf.d/.htpasswd;
}
}
}

这里使用的用户名和密码配置放置在 /etc/nginx/conf.d 目录下,我们需要使用 htpasswd 命令创建。例如,创建一个用户名为 admin 的文件,相关命令如下:

1
htpasswd -c .htpasswd admin

接下来就会提示我们输入密码,输入两次之后,就会生成密码文件,其内容如下:

1
2
cat .htpasswd 
admin:5ZBxQr0rCqwbc

配置完成之后我们重启一下 Nginx 服务,运行如下命令:sudo nginx -s reload

这样访问认证就成功配置好了。

测试

最后,我们可以用代码来测试一下负载均衡的配置,看看到底是不是每次请求会切换 IP。利用 http://httpbin.org/get 测试即可,代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import requests
from urllib.parse import quote
import re

lua = '''
function main(splash, args)
local treat = require("treat")
local response = splash:http_get("http://httpbin.org/get")
return treat.as_string(response.body)
end
'''

url = 'http://splash:8050/execute?lua_source=' + quote(lua)
response = requests.get(url, auth=('admin', 'admin'))
ip = re.search('(\d+\.\d+\.\d+\.\d+)', response.text).group(1)
print(ip)

这里 URL 中的 splash 字符串请自行替换成自己的 Nginx 服务器 IP。这里我修改了 Hosts,设置了 splash 为 Nginx 服务器 IP。

多次运行代码之后,可以发现每次请求的 IP 都会变化,比如第一次的结果:

1
41.159.27.223

第二次的结果:

1
41.159.27.9

这就说明负载均衡已经成功实现了。

本节中,我们成功实现了负载均衡的配置。配置负载均衡后,可以多个 Splash 服务共同合作,减轻单个服务的负载,这还是比较有用的。

pyppeteer的使用

如果大家对 Python 爬虫有所了解的话,想必你应该听说过 Selenium 这个库,这实际上是一个自动化测试工具,现在已经被广泛用于网络爬虫中来应对 JavaScript 渲染的页面的抓取。

但 Selenium 用的时候有个麻烦事,就是环境的相关配置,得安装好相关浏览器,比如 Chrome、Firefox 等等,然后还要到官方网站去下载对应的驱动,最重要的还需要安装对应的 Python Selenium 库,确实是不是很方便,另外如果要做大规模部署的话,环境配置的一些问题也是个头疼的事情。

那么本节就介绍另一个类似的替代品,叫做 Pyppeteer。注意,是叫做 Pyppeteer,不是 Puppeteer。

Puppeteer 是 Google 基于 Node.js 开发的一个工具,有了它我们可以通过 JavaScript 来控制 Chrome 浏览器的一些操作,当然也可以用作网络爬虫上,其 API 极其完善,功能非常强大。

而 Pyppeteer 又是什么呢?它实际上是 Puppeteer 的 Python 版本的实现,但他不是 Google 开发的,是一位来自于日本的工程师依据 Puppeteer 的一些功能开发出来的非官方版本。 在 Pyppetter 中,实际上它背后也是有一个类似 Chrome 浏览器的 Chromium 浏览器在执行一些动作进行网页渲染,首先说下 Chrome 浏览器和 Chromium 浏览器的渊源。

Chromium 是谷歌为了研发 Chrome 而启动的项目,是完全开源的。二者基于相同的源代码构建,Chrome 所有的新功能都会先在 Chromium 上实现,待验证稳定后才会移植,因此 Chromium 的版本更新频率更高,也会包含很多新的功能,但作为一款独立的浏览器,Chromium 的用户群体要小众得多。两款浏览器 “同根同源”,它们有着同样的 Logo,但配色不同,Chrome 由蓝红绿黄四种颜色组成,而 Chromium 由不同深度的蓝色构成。

image-20231003072747613总的来说,两款浏览器的内核是一样的,实现方式也是一样的,可以认为是开发版和正式版的区别,功能上基本是没有太大区别的。 Pyppeteer 就是依赖于 Chromium 这个浏览器来运行的。那么有了 Pyppeteer 之后,我们就可以免去那些繁琐的环境配置等问题。如果第一次运行的时候,Chromium 浏览器没有安装,那么程序会帮我们自动安装和配置,就免去了繁琐的环境配置等工作。另外 Pyppeteer 是基于 Python 的新特性 async 实现的,所以它的一些执行也支持异步操作,效率相对于 Selenium 来说也提高了。 那么下面就让我们来一起了解下 Pyppeteer 的相关用法吧。

安装

首先就是安装问题了,由于 Pyppeteer 采用了 Python 的 async 机制,所以其运行要求的 Python 版本为 3.5 及以上。 安装方式非常简单:

1
pip3 install pyppeteer

命令执行完毕之后即可完成安装。

运行测试脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import asyncio
from pyppeteer import launch

async def main():
browser = await launch()
page = await browser.newPage()
await page.goto('https://www.baidu.com')
await page.screenshot({'path': 'baidu.png'})
await browser.close()

# asyncio.get_event_loop().run_until_complete(main())
# python >= 3.10
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.run_until_complete(main())

运行之后,如果之前没有运行过 pyppeteer-install 命令的话,Pyppeteer 会进行一些初始化配置,运行完毕之后,就会启动浏览器,然后访问百度,生成截图。

快速上手

接下来我们测试下基本的页面渲染操作,这里我们选用的网址为:http://quotes.toscrape.com/js/,这个页面是 JavaScript 渲染而成的,用基本的 requests 库请求得到的 HTML 结果里面是不包含页面中所见的条目内容的。 为了证明 requests 无法完成正常的抓取,我们可以先用如下代码来测试一下:

1
2
3
4
5
6
7
import requests
from pyquery import PyQuery as pq # pip3 install pyquery

url = 'http://quotes.toscrape.com/js/'
response = requests.get(url)
doc = pq(response.text)
print('Quotes:', doc('.quote').length)

这里首先使用 requests 来请求网页内容,然后使用 pyquery 来解析页面中的每一个条目。观察源码之后我们发现每个条目的 class 名为 quote,所以这里选用了 .quote 这个 CSS 选择器来选择,最后输出条目数量。 运行结果:

image-20230305145622190

结果是 0,这就证明使用 requests 是无法正常抓取到相关数据的。因为什么?因为这个页面是 JavaScript 渲染而成的,我们所看到的内容都是网页加载后又执行了 JavaScript 之后才呈现出来的,因此这些条目数据并不存在于原始 HTML 代码中,而 requests 仅仅抓取的是原始 HTML 代码。 好的,所以遇到这种类型的网站我们应该怎么办呢? 其实答案有很多:

  • 分析网页源代码数据,如果数据是隐藏在 HTML 中的其他地方,以 JavaScript 变量的形式存在,直接提取就好了。
  • 分析 Ajax,很多数据可能是经过 Ajax 请求时候获取的,所以可以分析其接口。
  • 模拟 JavaScript 渲染过程,直接抓取渲染后的结果。

而 Pyppeteer 和 Selenium 就是用的第三种方法,下面我们再用 Pyppeteer 来试试,如果用 Pyppeteer 实现如上页面的抓取的话,代码就可以写为如下形式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import asyncio
from pyppeteer import launch
from pyquery import PyQuery as pq

async def main():
browser = await launch()
page = await browser.newPage()
await page.goto('http://quotes.toscrape.com/js/')
doc = pq(await page.content())
print('Quotes:', doc('.quote').length)
await browser.close()

# asyncio.get_event_loop().run_until_complete(main())
# python >= 3.10
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.run_until_complete(main())

运行结果:

image-20230305145800423

看运行结果,这说明我们就成功匹配出来了 class 为 quote 的条目,总数为 10 条,具体的内容可以进一步使用 pyquery 解析查看。 那么这里面的过程发生了什么?

实际上,Pyppeteer 整个流程就完成了浏览器的开启、新建页面、页面加载等操作。另外 Pyppeteer 里面进行了异步操作,所以需要配合 async/await 关键词来实现。

首先, launch 方法会新建一个 Browser 对象,然后赋值给 browser,

然后调用 newPage 方法相当于浏览器中新建了一个选项卡,同时新建了一个 Page 对象。

然后 Page 对象调用了 goto 方法就相当于在浏览器中输入了这个 URL,浏览器跳转到了对应的页面进行加载,加载完成之后再调用 content 方法,返回当前浏览器页面的源代码。

然后进一步地,我们用 pyquery 进行同样地解析,就可以得到 JavaScript 渲染的结果了。

另外其他的一些方法如调用 asyncio 的 get_event_loop 等方法的相关操作则属于 Python 异步 async 相关的内容了,大家如果不熟悉可以了解下 Python 的 async/await 的相关知识。

好,通过上面的代码,我们就可以完成 JavaScript 渲染页面的爬取了。 在这个过程中,我们没有配置 Chrome 浏览器,没有配置浏览器驱动,免去了一些繁琐的步骤,同样达到了 Selenium 的效果,还实现了异步抓取,爽歪歪

接下来我们再看看另外一个例子,这个例子可以模拟网页截图,保存 PDF,另外还可以执行自定义的 JavaScript 获得特定的内容,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import asyncio
from pyppeteer import launch

async def main():
browser = await launch()
page = await browser.newPage()
await page.goto('http://quotes.toscrape.com/js/')
await page.screenshot(path='example.png')
await page.pdf(path='example.pdf')
dimensions = await page.evaluate('''() => {
return {
width: document.documentElement.clientWidth,
height: document.documentElement.clientHeight,
deviceScaleFactor: window.devicePixelRatio,
}
}''')

print(dimensions)
# >>> {'width': 800, 'height': 600, 'deviceScaleFactor': 1}
await browser.close()

# asyncio.get_event_loop().run_until_complete(main())
# python >= 3.10
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.run_until_complete(main())

这里我们又用到了几个新的 API,完成了网页截图保存、网页导出 PDF 保存、执行 JavaScript 并返回对应数据。 首先 screenshot 方法可以传入保存的图片路径,另外还可以指定保存格式 type、清晰度 quality、是否全屏 fullPage、裁切 clip 等各个参数实现截图。 截图的样例如下:

image-20231003072757501可以看到它返回的就是 JavaScript 渲染后的页面。 pdf 方法也是类似的,只不过页面保存格式不一样,最后得到一个多页的 pdf 文件,样例如下:

image-20231003072803382可见其内容也是 JavaScript 渲染后的内容,另外这个方法还可以指定放缩大小 scale、页码范围 pageRanges、宽高 width 和 height、方向 landscape 等等参数,导出定制化的 pdf 用这个方法就十分方便。 最后我们又调用了 evaluate 方法执行了一些 JavaScript,JavaScript 传入的是一个函数,使用 return 方法返回了网页的宽高、像素大小比率三个值,最后得到的是一个 JSON 格式的对象,内容如下:

1
{'width': 800, 'height': 600, 'deviceScaleFactor': 1}

OK,实例就先感受到这里,还有太多太多的功能还没提及。 总之利用 Pyppeteer 我们可以控制浏览器执行几乎所有动作,想要的操作和功能基本都可以实现,用它来自由地控制爬虫当然就不在话下了。

详细用法

了解了基本的实例之后,我们再来梳理一下 Pyppeteer 的一些基本和常用操作。Pyppeteer 的几乎所有功能都能在其官方文档的 API Reference 里面找到

链接为:https://miyakogi.github.io/pyppeteer/reference.html

用到哪个方法就来这里查询就好了,参数不必死记硬背,即用即查就好。

开启浏览器

使用 Pyppeteer 的第一步便是启动浏览器,首先我们看下怎样启动一个浏览器,其实就相当于我们点击桌面上的浏览器图标一样,把它开起来。用 Pyppeteer 完成同样的操作,只需要调用 launch 方法即可。 我们先看下 launch 方法的 API,链接为:https://miyakogi.github.io/pyppeteer/reference.html#pyppeteer.launcher.launch,其方法定义如下:

1
pyppeteer.launcher.launch(options: dict = None, **kwargs) → pyppeteer.browser.Browser

可以看到它处于 launcher 模块中,参数没有在声明中特别指定,返回类型是 browser 模块中的 Browser 对象,另外观察源码发现这是一个 async 修饰的方法,所以调用它的时候需要使用 await。 接下来看看它的参数:

  • ignoreHTTPSErrors (bool): 是否要忽略 HTTPS 的错误,默认是 False。
  • headless (bool): 是否启用 Headless 模式,即无界面模式,如果 devtools 这个参数是 True 的话,那么该参数就会被设置为 False,否则为 True,即默认是开启无界面模式的。
  • executablePath (str): 可执行文件的路径,如果指定之后就不需要使用默认的 Chromium 了,可以指定为已有的 Chrome 或 Chromium。
  • slowMo (int|float): 通过传入指定的时间,可以减缓 Pyppeteer 的一些模拟操作。
  • args (List [str]): 在执行过程中可以传入的额外参数。
  • ignoreDefaultArgs (bool): 不使用 Pyppeteer 的默认参数,如果使用了这个参数,那么最好通过 args 参数来设定一些参数,否则可能会出现一些意想不到的问题。这个参数相对比较危险,慎用。
  • handleSIGINT (bool): 是否响应 SIGINT 信号,也就是可以使用 Ctrl + C 来终止浏览器程序,默认是 True。
  • handleSIGTERM (bool): 是否响应 SIGTERM 信号,一般是 kill 命令,默认是 True。
  • handleSIGHUP (bool): 是否响应 SIGHUP 信号,即挂起信号,比如终端退出操作,默认是 True。
  • dumpio (bool): 是否将 Pyppeteer 的输出内容传给 process.stdout 和 process.stderr 对象,默认是 False。
  • userDataDir (str): 即用户数据文件夹,即可以保留一些个性化配置和操作记录。
  • env (dict): 环境变量,可以通过字典形式传入。
  • devtools (bool): 是否为每一个页面自动开启调试工具,默认是 False。如果这个参数设置为 True,那么 headless 参数就会无效,会被强制设置为 False。
  • logLevel (int|str): 日志级别,默认和 root logger 对象的级别相同。
  • autoClose (bool): 当一些命令执行完之后,是否自动关闭浏览器,默认是 True。
  • loop (asyncio.AbstractEventLoop): 时间循环对象。

好了,知道这些参数之后,我们可以先试试看。 首先可以试用下最常用的参数 headless,如果我们将它设置为 True 或者默认不设置它,在启动的时候我们是看不到任何界面的,如果把它设置为 False,那么在启动的时候就可以看到界面了,一般我们在调试的时候会把它设置为 False,在生产环境上就可以设置为 True,我们先尝试一下关闭 headless 模式:

1
2
3
4
5
6
7
8
import asyncio
from pyppeteer import launch

async def main():
await launch(headless=False)
await asyncio.sleep(100)

asyncio.get_event_loop().run_until_complete(main())

运行之后看不到任何控制台输出,但是这时候就会出现一个空白的 Chromium 界面了:

image-20231003072809634但是可以看到这就是一个光秃秃的浏览器而已,看一下相关信息:

image-20231003072816664

看到了,这就是 Chromium,上面还写了开发者内部版本,可以认为是开发版的 Chrome 浏览器就好。 另外我们还可以开启调试模式,比如在写爬虫的时候会经常需要分析网页结构还有网络请求,所以开启调试工具还是很有必要的,我们可以将 devtools 参数设置为 True,这样每开启一个界面就会弹出一个调试窗口,非常方便,示例如下:

1
2
3
4
5
6
7
8
9
10
import asyncio
from pyppeteer import launch

async def main():
browser = await launch(devtools=True)
page = await browser.newPage()
await page.goto('https://www.baidu.com')
await asyncio.sleep(100)

asyncio.get_event_loop().run_until_complete(main())

刚才说过 devtools 这个参数如果设置为了 True,那么 headless 就会被关闭了,界面始终会显现出来。在这里我们新建了一个页面,打开了百度,界面运行效果如下:image-20231003072823309这时候我们可以看到上面的一条提示:”Chrome 正受到自动测试软件的控制”,这个提示条有点烦,那咋关闭呢?这时候就需要用到 args 参数了,禁用操作如下:

1
browser = await launch(headless=False, args=['--disable-infobars'])

这里就不再写完整代码了,就是在 launch 方法中,args 参数通过 list 形式传入即可,这里使用的是 —disable-infobars 的参数。 另外有人就说了,这里你只是把提示关闭了,有些网站还是会检测到是 webdriver 吧,比如淘宝检测到是 webdriver 就会禁止登录了,我们可以试试:

1
2
3
4
5
6
7
8
9
10
import asyncio
from pyppeteer import launch

async def main():
browser = await launch(headless=False)
page = await browser.newPage()
await page.goto('https://www.taobao.com')
await asyncio.sleep(100)

asyncio.get_event_loop().run_until_complete(main())

运行时候进行一下登录,然后就会弹出滑块,自己手动拖动一下,然后就报错了,界面如下:image-20231003072831779爬虫的时候看到这界面是很让人崩溃的吧,而且这时候我们还发现了页面的 bug,整个浏览器窗口比显示的内容窗口要大,这个是某些页面会出现的情况,让人看起来很不爽。 我们可以先解决一下这个显示的 bug,需要设置下 window-size 还有 viewport,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import asyncio
from pyppeteer import launch

width, height = 1366, 768

async def main():
browser = await launch(headless=False,
args=[f'--window-size={width},{height}'])
page = await browser.newPage()
await page.setViewport({'width': width, 'height': height})
await page.goto('https://www.taobao.com')
await asyncio.sleep(100)

asyncio.get_event_loop().run_until_complete(main())

这样整个界面就正常了:image-20231003072837321OK,那刚才所说的 webdriver 检测问题怎样来解决呢?其实淘宝主要通过 window.navigator.webdriver 来对 webdriver 进行检测,所以我们只需要使用 JavaScript 将它设置为 false 即可,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
import asyncio
from pyppeteer import launch

async def main():
browser = await launch(headless=False, args=['--disable-infobars'])
page = await browser.newPage()
await page.goto('https://login.taobao.com/member/login.jhtml?redirectURL=https://www.taobao.com/')
await page.evaluate(
'''() =>{ Object.defineProperties(navigator,{ webdriver:{ get: () => false } }) }''')
await asyncio.sleep(100)

asyncio.get_event_loop().run_until_complete(main())

这里没加输入用户名密码的代码,当然后面可以自行添加,下面打开之后,我们点击输入用户名密码,然后这时候会出现一个滑动条,这里滑动的话,就可以通过了,如图所示:image-20231003072841373OK,这样的话我们就成功规避了 webdriver 的检测,使用鼠标拖动模拟就可以完成淘宝的登录了。 还有另一种方法可以进一步免去淘宝登录的烦恼,那就是设置用户目录。平时我们已经注意到,当我们登录淘宝之后,如果下次再次打开浏览器发现还是登录的状态。这是因为淘宝的一些关键 Cookies 已经保存到本地了,下次登录的时候可以直接读取并保持登录状态。 那么这些信息保存在哪里了呢?其实就是保存在用户目录下了,里面不仅包含了浏览器的基本配置信息,还有一些 Cache、Cookies 等各种信息都在里面,如果我们能在浏览器启动的时候读取这些信息,那么启动的时候就可以恢复一些历史记录甚至一些登录状态信息了。 这也就解决了一个问题:很多朋友在每次启动 Selenium 或 Pyppeteer 的时候总是是一个全新的浏览器,那就是没有设置用户目录,如果设置了它,每次打开就不再是一个全新的浏览器了,它可以恢复之前的历史记录,也可以恢复很多网站的登录信息。 那么这个怎么来做呢?很简单,在启动的时候设置 userDataDir 就好了,示例如下:

1
2
3
4
5
6
7
8
9
10
import asyncio
from pyppeteer import launch

async def main():
browser = await launch(headless=False, userDataDir='./userdata', args=['--disable-infobars'])
page = await browser.newPage()
await page.goto('https://www.taobao.com')
await asyncio.sleep(100)

asyncio.get_event_loop().run_until_complete(main())

好,这里就是加了一个 userDataDir 的属性,值为 userdata,即当前目录的 userdata 文件夹。我们可以首先运行一下,然后登录一次淘宝,这时候我们同时可以观察到在当前运行目录下又多了一个 userdata 的文件夹,里面的结构是这样子的:

image-20231003072846243

具体的介绍可以看官方的一些说明,如:https://chromium.googlesource.com/chromium/src/+/master/docs/user_data_dir.md,这里面介绍了 userdatadir 的相关内容。 再次运行上面的代码,这时候可以发现现在就已经是登录状态了,不需要再次登录了,这样就成功跳过了登录的流程。当然可能时间太久了,Cookies 都过期了,那还是需要登录的。 好了,本想把 Pyppeteer 的用法详细介绍完的,结果只 launch 的方法就介绍这么多了,后面的内容放到其他文章来介绍了,其他的内容后续文章会陆续放出,谢谢。

pyppetter实战

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
#!/usr/bin/env python
# -*- coding:utf-8 -*- 
import asyncio
import json
import logging
from os import makedirs
from os.path import exists
from urllib.parse import urljoin

import aiohttp
import requests
import pymongo
from pyppeteer import launch
from selenium import webdriver
from selenium.common import TimeoutException
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.wait import WebDriverWait

from selenium.webdriver import ChromeOptions

logging.basicConfig(level=logging.INFO,
format='%(asctime)s - %(levelname)s: %(message)s')

INDEX_URL = 'https://spa2.scrape.center/page/{page}'
TIMEOUT = 10
TOTAL_PAGE = 10
RESULTS_DIR = 'results'

WINDOW_WIDTH,WINDOW_HEIGHT = 1366,768
HEADLESS = False

exists(RESULTS_DIR) or makedirs(RESULTS_DIR)

async def init():
global browser,tab
browser = await launch(headless=HEADLESS,
args=['--disable-infobars',f'--window-size={WINDOW_WIDTH},{WINDOW_HEIGHT}'])
tab = await browser.newPage()
await tab.setViewport({'width': WINDOW_WIDTH,'height':WINDOW_HEIGHT})
page = await browser.newPage()

async def scrape_page(url,selector):
logging.info('scraping %s',url)
try:
await tab.goto(url)
await tab.waitForSelector(selector,options={'timeout':TIMEOUT * 1000})
except TimeoutError:
logging.error('error occurred while scraping %s',url,exc_info=True)

async def scrape_index(page):
url = INDEX_URL.format(page=page)
await scrape_page(url,'.item .name')

async def parse_index():
return await tab.querySelectorAllEval('.item .name','nodes.map(node=>node.href)')

async def main():
await init()
try:
for page in range(1,TOTAL_PAGE+1):
await scrape_index(page)
detail_urls = await parse_index()
logging.info('detail_urls %s',detail_urls)
finally:
await browser.close()

if __name__ == '__main__':
# asyncio.get_event_loop().run_until_complete(main())
# python >= 3.10
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.run_until_complete(main())

Playwright 的使用(新兴)

Playwright 是微软在 2020 年初开源的新一代自动化测试工具,它的功能类似于 Selenium、Pyppeteer 等,都可以驱动浏览器进行各种自动化操作。它的功能也非常强大,对市面上的主流浏览器都提供了支持,API 功能简洁又强大。虽然诞生比较晚,但是现在发展得非常火热。

1. Playwright 的特点

  • Playwright 支持当前所有主流浏览器,包括 Chrome 和 Edge(基于 Chromium)、Firefox、Safari(基于 WebKit) ,提供完善的自动化控制的 API。
  • Playwright 支持移动端页面测试,使用设备模拟技术可以使我们在移动 Web 浏览器中测试响应式 Web 应用程序。
  • Playwright 支持所有浏览器的 Headless 模式和非 Headless 模式的测试。
  • Playwright 的安装和配置非常简单,安装过程中会自动安装对应的浏览器和驱动,不需要额外配置 WebDriver 等。
  • Playwright 提供了自动等待相关的 API,当页面加载的时候会自动等待对应的节点加载,大大简化了 API 编写复杂度。

本节我们就来了解下 Playwright 的使用方法。

2. 安装

要使用 Playwright,需要 Python 3.7 版本及以上,请确保 Python 的版本符合要求。

要安装 Playwright,可以直接使用 pip3,命令如下:

1
pip3 install playwright

安装完成之后需要进行一些初始化操作:

1
playwright install

这时候 Playwrigth 会安装 Chromium, Firefox and WebKit 浏览器并配置一些驱动,我们不必关心中间配置的过程,Playwright 会为我们配置好。

具体的安装说明可以参考:https://setup.scrape.center/playwright。

安装完成之后,我们便可以使用 Playwright 启动 Chromium 或 Firefox 或 WebKit 浏览器来进行自动化操作了。

3. 基本使用

Playwright 支持两种编写模式,一种是类似 Pyppetter 一样的异步模式,另一种是像 Selenium 一样的同步模式,我们可以根据实际需要选择使用不同的模式。

我们先来看一个基本同步模式的例子:

1
2
3
4
5
6
7
8
9
10
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
for browser_type in [p.chromium, p.firefox, p.webkit]:
browser = browser_type.launch(headless=False)
page = browser.new_page()
page.goto('https://www.baidu.com')
page.screenshot(path=f'screenshot-{browser_type.name}.png')
print(page.title())
browser.close()

首先我们导入了 sync_playwright 方法,然后直接调用了这个方法,该方法返回的是一个 PlaywrightContextManager 对象,可以理解是一个浏览器上下文管理器,我们将其赋值为变量 p。

接着我们调用了 PlaywrightContextManager 对象的 chromium、firefox、webkit 属性依次创建了一个 Chromium、Firefox 以及 Webkit 浏览器实例,接着用一个 for 循环依次执行了它们的 launch 方法,同时设置了 headless 参数为 False。

注意:如果不设置为 False,默认是无头模式启动浏览器,我们看不到任何窗口。

launch 方法返回的是一个 Browser 对象,我们将其赋值为 browser 变量。然后调用 browser 的 new_page 方法,相当于新建了一个选项卡,返回的是一个 Page 对象,将其赋值为 page,这整个过程其实和 Pyppeteer 非常类似。接着我们就可以调用 page 的一系列 API 来进行各种自动化操作了,比如调用 goto,就是加载某个页面,这里我们访问的是百度的首页。接着我们调用了 page 的 screenshot 方法,参数传一个文件名称,这样截图就会自动保存为该图片名称,这里名称中我们加入了 browser_type 的 name 属性,代表浏览器的类型,结果分别就是 chromium, firefox, webkit。另外我们还调用了 title 方法,该方法会返回页面的标题,即 HTML 中 title 节点中的文字,也就是选项卡上的文字,我们将该结果打印输出到控制台。最后操作完毕,调用 browser 的 close 方法关闭整个浏览器,运行结束。

运行一下,这时候我们可以看到有三个浏览器依次启动并加载了百度这个页面,分别是 Chromium、Firefox 和 Webkit 三个浏览器,页面加载完成之后,生成截图、控制台打印结果就退出了。

这时候当前目录便会生成三个截图文件,都是百度的首页,文件名中都带有了浏览器的名称,如图所示:

image-20231003072853081

控制台运行结果如下:

1
2
3
百度一下,你就知道
百度一下,你就知道
百度一下,你就知道

通过运行结果我们可以发现,我们非常方便地启动了三种浏览器并完成了自动化操作,并通过几个 API 就完成了截图和数据的获取,整个运行速度是非常快的,者就是 Playwright 最最基本的用法。

当然除了同步模式,Playwright 还提供异步的 API,如果我们项目里面使用了 asyncio,那就应该使用异步模式,写法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import asyncio
from playwright.async_api import async_playwright

async def main():
async with async_playwright() as p:
for browser_type in [p.chromium, p.firefox, p.webkit]:
browser = await browser_type.launch()
page = await browser.new_page()
await page.goto('https://www.baidu.com')
await page.screenshot(path=f'screenshot-{browser_type.name}.png')
print(await page.title())
await browser.close()

asyncio.run(main())

可以看到整个写法和同步模式基本类似,导入的时候使用的是 async_playwright 方法,而不再是 sync_playwright 方法。写法上添加了 async/await 关键字的使用,最后的运行效果是一样的。

另外我们注意到,这例子中使用了 with as 语句,with 用于上下文对象的管理,它可以返回一个上下文管理器,也就对应一个 PlaywrightContextManager 对象,无论运行期间是否抛出异常,它能够帮助我们自动分配并且释放 Playwright 的资源。

4. 代码生成

Playwright 还有一个强大的功能,那就是可以录制我们在浏览器中的操作并将代码自动生成出来,有了这个功能,我们甚至都不用写任何一行代码,这个功能可以通过 playwright 命令行调用 codegen 来实现,我们先来看看 codegen 命令都有什么参数,输入如下命令:

1
playwright codegen --help

结果类似如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
Usage: npx playwright codegen [options] [url]

open page and generate code for user actions

Options:
-o, --output <file name> saves the generated script to a file
--target <language> language to use, one of javascript, python, python-async, csharp (default: "python")
-b, --browser <browserType> browser to use, one of cr, chromium, ff, firefox, wk, webkit (default: "chromium")
--channel <channel> Chromium distribution channel, "chrome", "chrome-beta", "msedge-dev", etc
--color-scheme <scheme> emulate preferred color scheme, "light" or "dark"
--device <deviceName> emulate device, for example "iPhone 11"
--geolocation <coordinates> specify geolocation coordinates, for example "37.819722,-122.478611"
--load-storage <filename> load context storage state from the file, previously saved with --save-storage
--lang <language> specify language / locale, for example "en-GB"
--proxy-server <proxy> specify proxy server, for example "http://myproxy:3128" or "socks5://myproxy:8080"
--save-storage <filename> save context storage state at the end, for later use with --load-storage
--timezone <time zone> time zone to emulate, for example "Europe/Rome"
--timeout <timeout> timeout for Playwright actions in milliseconds (default: "10000")
--user-agent <ua string> specify user agent string
--viewport-size <size> specify browser viewport size in pixels, for example "1280, 720"
-h, --help display help for command

Examples:

$ codegen
$ codegen --target=python
$ codegen -b webkit https://example.com

可以看到这里有几个选项,比如 -o 代表输出的代码文件的名称;—target 代表使用的语言,默认是 python,即会生成同步模式的操作代码,如果传入 python-async 就会生成异步模式的代码;-b 代表的是使用的浏览器,默认是 Chromium,其他还有很多设置,比如 —device 可以模拟使用手机浏览器,比如 iPhone 11,—lang 代表设置浏览器的语言,—timeout 可以设置页面加载超时时间。

好,了解了这些用法,那我们就来尝试启动一个 Firefox 浏览器,然后将操作结果输出到 script.py 文件,命令如下:

1
playwright codegen -o script.py -b firefox

这时候就弹出了一个 Firefox 浏览器,同时右侧会输出一个脚本窗口,实时显示当前操作对应的代码。

我们可以在浏览器中做任何操作,比如打开百度,然后点击输入框并输入 nba,然后再点击搜索按钮,浏览器窗口如下:

image-20231003072858631

可以看见浏览器中还会高亮显示我们正在操作的页面节点,同时还显示了对应的选择器字符串 input[name="wd"],右侧的窗口如图所示:

image-20231003072902556

在操作过程中,该窗口中的代码就实时变化,可以看到这里生成了我们一系列操作的对应代码,比如在搜索框中输入 nba,就对应如下代码:

1
page.fill("input[name=\"wd\"]", "nba")

操作完毕之后,关闭浏览器,Playwright 会生成一个 script.py 文件,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
from playwright.sync_api import sync_playwright

def run(playwright):
browser = playwright.firefox.launch(headless=False)
context = browser.new_context()

# Open new page
page = context.new_page()

# Go to https://www.baidu.com/
page.goto("https://www.baidu.com/")

# Click input[name="wd"]
page.click("input[name=\"wd\"]")

# Fill input[name="wd"]
page.fill("input[name=\"wd\"]", "nba")

# Click text=百度一下
with page.expect_navigation():
page.click("text=百度一下")

context.close()
browser.close()

with sync_playwright() as playwright:
run(playwright)

可以看到这里生成的代码和我们之前写的示例代码几乎差不多,而且也是完全可以运行的,运行之后就可以看到它又可以复现我们刚才所做的操作了。

所以,有了这个功能,我们甚至都不用编写任何代码,只通过简单的可视化点击就能把代码生成出来,可谓是非常方便了!

另外这里有一个值得注意的点,仔细观察下生成的代码,和前面的例子不同的是,这里 new_page 方法并不是直接通过 browser 调用的,而是通过 context 变量调用的,这个 context 又是由 browser 通过调用 new_context 方法生成的。有读者可能就会问了,这个 context 究竟是做什么的呢?

其实这个 context 变量对应的是一个 BrowserContext 对象,BrowserContext 是一个类似隐身模式的独立上下文环境,其运行资源是单独隔离的,在做一些自动化测试过程中,每个测试用例我们都可以单独创建一个 BrowserContext 对象,这样可以保证每个测试用例之间互不干扰,具体的 API 可以参考 https://playwright.dev/python/docs/api/class-browsercontext。

5. 移动端浏览器支持

Playwright 另外一个特色功能就是可以支持移动端浏览器的模拟,比如模拟打开 iPhone 12 Pro Max 上的 Safari 浏览器,然后手动设置定位,并打开百度地图并截图。首先我们可以选定一个经纬度,比如故宫的经纬度是 39.913904, 116.39014,我们可以通过 geolocation 参数传递给 Webkit 浏览器并初始化。

示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
iphone_12_pro_max = p.devices['iPhone 12 Pro Max']
browser = p.webkit.launch(headless=False)
context = browser.new_context(
**iphone_12_pro_max,
locale='zh-CN',
geolocation={'longitude': 116.39014, 'latitude': 39.913904},
permissions=['geolocation']
)
page = context.new_page()
page.goto('https://amap.com')
page.wait_for_load_state(state='networkidle')
page.screenshot(path='location-iphone.png')
browser.close()

这里我们先用 PlaywrightContextManager 对象的 devices 属性指定了一台移动设备,这里传入的是手机的型号,比如 iPhone 12 Pro Max,当然也可以传其他名称,比如 iPhone 8,Pixel 2 等。

前面我们已经了解了 BrowserContext 对象,BrowserContext 对象也可以用来模拟移动端浏览器,初始化一些移动设备信息、语言、权限、位置等信息,这里我们就用它来创建了一个移动端 BrowserContext 对象,通过 geolocation 参数传入了经纬度信息,通过 permissions 参数传入了赋予的权限信息,最后将得到的 BrowserContext 对象赋值为 context 变量。

接着我们就可以用 BrowserContext 对象来新建一个页面,还是调用 new_page 方法创建一个新的选项卡,然后跳转到高德地图,并调用了 wait_for_load_state 方法等待页面某个状态完成,这里我们传入的 state 是 networkidle,也就是网络空闲状态。因为在页面初始化和加载过程中,肯定是伴随有网络请求的,所以加载过程中肯定不算 networkidle 状态,所以这里我们传入 networkidle 就可以标识当前页面和数据加载完成的状态。加载完成之后,我们再调用 screenshot 方法获取当前页面截图,最后关闭浏览器。

运行下代码,可以发现这里就弹出了一个移动版浏览器,然后加载了高德地图,并定位到了故宫的位置,如图所示:

image-20231003072909009

输出的截图也是浏览器中显示的结果。

所以这样我们就成功实现了移动端浏览器的模拟和一些设置,其操作 API 和 PC 版浏览器是完全一样的。

6. 选择器

前面我们注意到 click 和 fill 等方法都传入了一个字符串,这些字符串有的符合 CSS 选择器的语法,有的又是 text= 开头的,感觉似乎没太有规律的样子,它到底支持怎样的匹配规则呢?下面我们来了解下。

传入的这个字符串,我们可以称之为 Element Selector,它不仅仅支持 CSS 选择器、XPath,Playwright 还扩展了一些方便好用的规则,比如直接根据文本内容筛选,根据节点层级结构筛选等等。

文本选择

文本选择支持直接使用 text= 这样的语法进行筛选,示例如下:

1
page.click("text=Log in")

这就代表选择文本是 Log in 的节点,并点击。

CSS 选择器

CSS 选择器之前也介绍过了,比如根据 id 或者 class 筛选:

1
2
page.click("button")
page.click("#nav-bar .contact-us-item")

根据特定的节点属性筛选:

1
2
page.click("[data-test=login-button]")
page.click("[aria-label='Sign in']")

CSS 选择器 + 文本

我们还可以使用 CSS 选择器结合文本值进行海选,比较常用的就是 has-text 和 text,前者代表包含指定的字符串,后者代表字符串完全匹配,示例如下:

1
2
page.click("article:has-text('Playwright')")
page.click("#nav-bar :text('Contact us')")

第一个就是选择文本中包含 Playwright 的 article 节点,第二个就是选择 id 为 nav-bar 节点中文本值等于 Contact us 的节点。

CSS 选择器 + 节点关系

还可以结合节点关系来筛选节点,比如使用 has 来指定另外一个选择器,示例如下:

1
page.click(".item-description:has(.item-promo-banner)")

比如这里选择的就是选择 class 为 item-description 的节点,且该节点还要包含 class 为 item-promo-banner 的子节点。

另外还有一些相对位置关系,比如 right-of 可以指定位于某个节点右侧的节点,示例如下:

1
page.click("input:right-of(:text('Username'))")

这里选择的就是一个 input 节点,并且该 input 节点要位于文本值为 Username 的节点的右侧。

XPath

当然 XPath 也是支持的,不过 xpath 这个关键字需要我们自行制定,示例如下:

1
page.click("xpath=//button")

这里需要在开头指定 xpath= 字符串,代表后面是一个 XPath 表达式。

关于更多选择器的用法和最佳实践,可以参考官方文档:https://playwright.dev/python/docs/selectors。

7. 常用操作方法

上面我们了解了浏览器的一些初始化设置和基本的操作实例,下面我们再对一些常用的操作 API 进行说明。

常见的一些 API 如点击 click,输入 fill 等操作,这些方法都是属于 Page 对象的,所以所有的方法都从 Page 对象的 API 文档查找就好了,文档地址:https://playwright.dev/python/docs/api/class-page。

下面介绍几个常见的 API 用法。

事件监听

Page 对象提供了一个 on 方法,它可以用来监听页面中发生的各个事件,比如 close、console、load、request、response 等等。

比如这里我们可以监听 response 事件,response 事件可以在每次网络请求得到响应的时候触发,我们可以设置对应的回调方法获取到对应 Response 的全部信息,示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
from playwright.sync_api import sync_playwright

def on_response(response):
print(f'Statue {response.status}: {response.url}')

with sync_playwright() as p:
browser = p.chromium.launch(headless=False)
page = browser.new_page()
page.on('response', on_response)
page.goto('https://spa6.scrape.center/')
page.wait_for_load_state('networkidle')
browser.close()

这里我们在创建 Page 对象之后,就开始监听 response 事件,同时将回调方法设置为 on_response,on_response 对象接收一个参数,然后把 Response 的状态码和链接都输出出来了。

运行之后,可以看到控制台输出结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Statue 200: https://spa6.scrape.center/
Statue 200: https://spa6.scrape.center/css/app.ea9d802a.css
Statue 200: https://spa6.scrape.center/js/app.5ef0d454.js
Statue 200: https://spa6.scrape.center/js/chunk-vendors.77daf991.js
Statue 200: https://spa6.scrape.center/css/chunk-19c920f8.2a6496e0.css
...
Statue 200: https://spa6.scrape.center/css/chunk-19c920f8.2a6496e0.css
Statue 200: https://spa6.scrape.center/js/chunk-19c920f8.c3a1129d.js
Statue 200: https://spa6.scrape.center/img/logo.a508a8f0.png
Statue 200: https://spa6.scrape.center/fonts/element-icons.535877f5.woff
Statue 301: https://spa6.scrape.center/api/movie?limit=10&offset=0&token=NGMwMzFhNGEzMTFiMzJkOGE0ZTQ1YjUzMTc2OWNiYTI1Yzk0ZDM3MSwxNjIyOTE4NTE5
Statue 200: https://spa6.scrape.center/api/movie/?limit=10&offset=0&token=NGMwMzFhNGEzMTFiMzJkOGE0ZTQ1YjUzMTc2OWNiYTI1Yzk0ZDM3MSwxNjIyOTE4NTE5
Statue 200: https://p0.meituan.net/movie/da64660f82b98cdc1b8a3804e69609e041108.jpg@464w_644h_1e_1c
Statue 200: https://p0.meituan.net/movie/283292171619cdfd5b240c8fd093f1eb255670.jpg@464w_644h_1e_1c
....
Statue 200: https://p1.meituan.net/movie/b607fba7513e7f15eab170aac1e1400d878112.jpg@464w_644h_1e_1c

注意:这里省略了部分重复的内容。

可以看到,这里的输出结果其实正好对应浏览器 Network 面板中所有的请求和响应内容,和下图是一一对应的:

image-20231003072916701

这个网站我们之前分析过,其真实的数据都是 Ajax 加载的,同时 Ajax 请求中还带有加密参数,不好轻易获取。

但有了这个方法,这里如果我们想要截获 Ajax 请求,岂不是就非常容易了?

改写一下判定条件,输出对应的 JSON 结果,改写如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
from playwright.sync_api import sync_playwright

def on_response(response):
if '/api/movie/' in response.url and response.status == 200:
print(response.json())

with sync_playwright() as p:
browser = p.chromium.launch(headless=False)
page = browser.new_page()
page.on('response', on_response)
page.goto('https://spa6.scrape.center/')
page.wait_for_load_state('networkidle')
browser.close()

控制台输入如下:

1
2
3
{'count': 100, 'results': [{'id': 1, 'name': '霸王别姬', 'alias': 'Farewell My Concubine', 'cover': 'https://p0.meituan.net/movie/ce4da3e03e655b5b88ed31b5cd7896cf62472.jpg@464w_644h_1e_1c', 'categories': ['剧情', '爱情'], 'published_at': '1993-07-26', 'minute': 171, 'score': 9.5, 'regions': ['中国大陆', '中国香港']},
...
'published_at': None, 'minute': 103, 'score': 9.0, 'regions': ['美国']}, {'id': 10, 'name': '狮子王', 'alias': 'The Lion King', 'cover': 'https://p0.meituan.net/movie/27b76fe6cf3903f3d74963f70786001e1438406.jpg@464w_644h_1e_1c', 'categories': ['动画', '歌舞', '冒险'], 'published_at': '1995-07-15', 'minute': 89, 'score': 9.0, 'regions': ['美国']}]}

简直是得来全不费工夫,我们直接通过这个方法拦截了 Ajax 请求,直接把响应结果拿到了,即使这个 Ajax 请求有加密参数,我们也不用关心,因为我们直接截获了 Ajax 最后响应的结果,这对数据爬取来说实在是太方便了。

另外还有很多其他的事件监听,这里不再一一介绍了,可以查阅官方文档,参考类似的写法实现。

获取页面源码

要获取页面的 HTML 代码其实很简单,我们直接通过 content 方法获取即可,用法如下:

1
2
3
4
5
6
7
8
9
10
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
browser = p.chromium.launch(headless=False)
page = browser.new_page()
page.goto('https://spa6.scrape.center/')
page.wait_for_load_state('networkidle')
html = page.content()
print(html)
browser.close()

运行结果就是页面的 HTML 代码。获取了 HTML 代码之后,我们通过一些解析工具就可以提取想要的信息了。

页面点击

刚才我们通过示例也了解了页面点击的方法,那就是 click,这里详细说一下其使用方法。

页面点击的 API 定义如下:

1
page.click(selector, **kwargs)

这里可以看到必传的参数是 selector,其他的参数都是可选的。第一个 selector 就代表选择器,可以用来匹配想要点击的节点,如果传入的选择器匹配了多个节点,那么只会用第一个节点。

这个方法的内部执行逻辑如下:

  • 根据 selector 找到匹配的节点,如果没有找到,那就一直等待直到超时,超时时间可以由额外的 timeout 参数设置,默认是 30 秒。
  • 等待对该节点的可操作性检查的结果,比如说如果某个按钮设置了不可点击,那它会等待该按钮变成了可点击的时候才去点击,除非通过 force 参数设置跳过可操作性检查步骤强制点击。
  • 如果需要的话,就滚动下页面,将需要被点击的节点呈现出来。
  • 调用 page 对象的 mouse 方法,点击节点中心的位置,如果指定了 position 参数,那就点击指定的位置。

click 方法的一些比较重要的参数如下:

  • click_count:点击次数,默认为 1。
  • timeout:等待要点击的节点的超时时间,默认是 30 秒。
  • position:需要传入一个字典,带有 x 和 y 属性,代表点击位置相对节点左上角的偏移位置。
  • force:即使不可点击,那也强制点击。默认是 False。

具体的 API 设置参数可以参考官方文档:https://playwright.dev/python/docs/api/class-page/#pageclickselector-kwargs。

文本输入

文本输入对应的方法是 fill,API 定义如下:

1
page.fill(selector, value, **kwargs)

这个方法有两个必传参数,第一个参数也是 selector,第二个参数是 value,代表输入的内容,另外还可以通过 timeout 参数指定对应节点的最长等待时间。

获取节点属性

除了对节点进行操作,我们还可以获取节点的属性,方法就是 get_attribute,API 定义如下:

1
page.get_attribute(selector, name, **kwargs)

这个方法有两个必传参数,第一个参数也是 selector,第二个参数是 name,代表要获取的属性名称,另外还可以通过 timeout 参数指定对应节点的最长等待时间。

示例如下:

1
2
3
4
5
6
7
8
9
10
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
browser = p.chromium.launch(headless=False)
page = browser.new_page()
page.goto('https://spa6.scrape.center/')
page.wait_for_load_state('networkidle')
href = page.get_attribute('a.name', 'href')
print(href)
browser.close()

这里我们调用了 get_attribute 方法,传入的 selector 是 a.name,选定了 class 为 name 的 a 节点,然后第二个参数传入了 href,获取超链接的内容,输出结果如下:

1
/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIx

可以看到对应 href 属性就获取出来了,但这里只有一条结果,因为这里有个条件,那就是如果传入的选择器匹配了多个节点,那么只会用第一个节点。

那怎么获取所有的节点呢?

获取多个节点

获取所有节点可以使用 query_selector_all 方法,它可以返回节点列表,通过遍历获取到单个节点之后,我们可以接着调用单个节点的方法来进行一些操作和属性获取,示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
browser = p.chromium.launch(headless=False)
page = browser.new_page()
page.goto('https://spa6.scrape.center/')
page.wait_for_load_state('networkidle')
elements = page.query_selector_all('a.name')
for element in elements:
print(element.get_attribute('href'))
print(element.text_content())
browser.close()

这里我们通过 query_selector_all 方法获取了所有匹配到的节点,每个节点对应的是一个 ElementHandle 对象,然后 ElementHandle 对象也有 get_attribute 方法来获取节点属性,另外还可以通过 text_content 方法获取节点文本。

运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIx
霸王别姬 - Farewell My Concubine
/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIy
这个杀手不太冷 - Léon
/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIz
肖申克的救赎 - The Shawshank Redemption
/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWI0
泰坦尼克号 - Titanic
/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWI1
罗马假日 - Roman Holiday
/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWI2
唐伯虎点秋香 - Flirting Scholar
/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWI3
乱世佳人 - Gone with the Wind
/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWI4
喜剧之王 - The King of Comedy
/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWI5
楚门的世界 - The Truman Show
/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIxMA==
狮子王 - The Lion King

获取单个节点

获取单个节点也有特定的方法,就是 query_selector,如果传入的选择器匹配到多个节点,那它只会返回第一个节点,示例如下:

1
2
3
4
5
6
7
8
9
10
11
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
browser = p.chromium.launch(headless=False)
page = browser.new_page()
page.goto('https://spa6.scrape.center/')
page.wait_for_load_state('networkidle')
element = page.query_selector('a.name')
print(element.get_attribute('href'))
print(element.text_content())
browser.close()

运行结果如下:

1
2
/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIx
霸王别姬 - Farewell My Concubine

可以看到这里只输出了第一个匹配节点的信息。

网络劫持

最后再介绍一个实用的方法 route,利用 route 方法,我们可以实现一些网络劫持和修改操作,比如修改 request 的属性,修改 response 响应结果等。

看一个实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from playwright.sync_api import sync_playwright
import re

with sync_playwright() as p:
browser = p.chromium.launch(headless=False)
page = browser.new_page()

def cancel_request(route, request):
route.abort()

page.route(re.compile(r"(\.png)|(\.jpg)"), cancel_request)
page.goto("https://spa6.scrape.center/")
page.wait_for_load_state('networkidle')
page.screenshot(path='no_picture.png')
browser.close()

这里我们调用了 route 方法,第一个参数通过正则表达式传入了匹配的 URL 路径,这里代表的是任何包含 .png.jpg 的链接,遇到这样的请求,会回调 cancel_request 方法处理,cancel_request 方法可以接收两个参数,一个是 route,代表一个 CallableRoute 对象,另外一个是 request,代表 Request 对象。这里我们直接调用了 route 的 abort 方法,取消了这次请求,所以最终导致的结果就是图片的加载全部取消了。

观察下运行结果,如图所示:

image-20231003072924156

可以看到图片全都加载失败了。

这个设置有什么用呢?其实是有用的,因为图片资源都是二进制文件,而我们在做爬取过程中可能并不想关心其具体的二进制文件的内容,可能只关心图片的 URL 是什么,所以在浏览器中是否把图片加载出来就不重要了。所以如此设置之后,我们可以提高整个页面的加载速度,提高爬取效率。

另外,利用这个功能,我们还可以将一些响应内容进行修改,比如直接修改 Response 的结果为自定义的文本文件内容。

首先这里定义一个 HTML 文本文件,命名为 custom_response.html,内容如下:

1
2
3
4
5
6
7
8
9
<!DOCTYPE html>
<html>
<head>
<title>Hack Response</title>
</head>
<body>
<h1>Hack Response</h1>
</body>
</html>

代码编写如下:

1
2
3
4
5
6
7
8
9
10
11
12
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
browser = p.chromium.launch(headless=False)
page = browser.new_page()

def modify_response(route, request):
route.fulfill(path="./custom_response.html")

page.route('/', modify_response)
page.goto("https://spa6.scrape.center/")
browser.close()

这里我们使用 route 的 fulfill 方法指定了一个本地文件,就是刚才我们定义的 HTML 文件,运行结果如下:

image-20231003072929842

可以看到,Response 的运行结果就被我们修改了,URL 还是不变的,但是结果已经成了我们修改的 HTML 代码。

所以通过 route 方法,我们可以灵活地控制请求和响应的内容,从而在某些场景下达成某些目的。

8. 总结

本节介绍了 Playwright 的基本用法,其 API 强大又易于使用,同时具备很多 Selenium、Pyppeteer 不具备的更好用的 API,是新一代 JavaScript 渲染页面的爬取利器。

css位置偏移反爬

有了selenium,pyppetter,它们很强大,但是依旧有不容易爬取的数据存在,

比如网页利用css控制文件的偏移位置,或者通过一些特殊的方法隐藏关键信息,都有可能对数据爬取造成干扰。

image-20230306124557268

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#!/usr/bin/env python
# -*- coding:utf-8 -*- 
import re

from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.wait import WebDriverWait
from pyquery import PyQuery as pq

def parse(name_html):
chars = name_html('.char')
items = []
for char in chars.items():
items.append({
'text': char.text().strip(),
'left': int(re.search('(\d+)px',char.attr('style')).group(1))
})
items = sorted(items,key=lambda x:x['left'],reverse=False)
return ''.join([item.get('text') for item in items])

browser = webdriver.Chrome()
browser.get('https://antispider3.scrape.center/')
WebDriverWait(browser,10).until(EC.presence_of_all_elements_located((By.CSS_SELECTOR, '.item')))
html = browser.page_source
doc = pq(html)
names = doc('.item .name')
for name_html in names.items():
name = parse(name_html)
print(name)
browser.close()

用selenium爬取的内容并不一定和亲眼所见的完全符合

字体反爬

有的网站将真实的数据隐藏到字体文件里,使我们即时获取了页面源码,也没有办法直接提取数据的真实性。

目标网站:https://antispider4.scrape.center/

难点:评分是字体得来的,通过源码拿不到

image-20230306130722763

点击右边的xx.css,进入源码,可以看到字体9的样式。所以,我们要提取该css,解析并匹配,得到score。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#!/usr/bin/env python
# -*- coding:utf-8 -*- 
import re

import requests
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.wait import WebDriverWait
from pyquery import PyQuery as pq

url = 'https://antispider4.scrape.center/css/app.654ba59e.css'
response = requests.get(url)
pattern = re.compile('.icon-(.*?):before\{content:"(.*?)"\}') # css是在一行的
results = re.findall(pattern,response.text)
# print("results", results)
icon_map = {item[0]:item[1] for item in results}

def parse_score(item):
# print('解析分数score:。。。')
elements = item('.icon')
# print('elements',elements)
icon_values = []
for element in elements.items():
# print('进入循环:。。。')
class_name = (element.attr('class'))
icon_key = re.search('icon-(\d+)',class_name).group(1)
icon_value = icon_map.get(icon_key)
# print("icon_value",icon_value)
icon_values.append(icon_value)
return ''.join(icon_values)


browser = webdriver.Chrome()
browser.get('https://antispider4.scrape.center/')
WebDriverWait(browser,10).until(EC.presence_of_all_elements_located((By.CSS_SELECTOR, '.item')))
html = browser.page_source
doc = pq(html)
names = doc('.item')
for item in names.items():
name = item('.name').text()
categories = [o.text() for o in item('.categories button').items()]
score = parse_score(item)
print(f'name:{name} categories:{categories} score:{score}')
browser.close()

即使获取了关键的源码,有些信息还是提取不到,需要咨询观察,提取规律。