0x00 前言

最近在看类似的问题的时候找了一些资料,发现网上有一篇文章写得很详细(准确的说是分成三篇文章写的),特别是手工逆向的方式还是挺有趣的,我也照着他的方式尝试了一下,学到一点东西,下面是这三篇文章的部分内容(有删改,外加其它的一些理解),如果想看原文的话,我在本文最后会附上原文的链接,至于目前最流行的使用 chrome headless 写动态爬虫的方法,由于原作者写的也不是很仔细,所以我还要再找些资料仔细研究一下,后面再写一篇文章总结。

0X01 动态网页简介:

在我们编写爬虫时,可能会碰到以下两种问题:

1.我们所需要爬取的数据在网页源代码中并不存在;
2.点击下一页跳转页面时,网页的 URL 并没有发生变化;

造成这种问题原因是,你所正在爬取的页面采取了 js 动态加载的方式,是一个动态网页。

所谓的动态网页,是指跟静态网页相对的一种网页编程技术。静态网页,随着html代码生成,页面的内容和显示效果就不会发生变化了。而动态网页则不然,其显示的页面则是经过Javascript处理数据后生成的结果,可以发生改变。这些数据的来源有多种,可能是经过Javascript计算生成的,也可能是通过Ajax加载的。

动态网页经常使用的一种技术是Ajax请求技术。

Ajax = Asynchronous JavaScript and XML(异步的 JavaScript 和XML),其最大的优点是在不重新加载整个页面的情况下,可以与服务器交换数据并更新部分网页的内容。

目前,越来越多的网站采取的是这种动态加载网页的方式,一来是可以实现web开发的前后端分离,减少服务器直接渲染页面的压力;二来是可以作为反爬虫的一种手段。

0X02 动态网页抓取

(1)逆向回溯法

对于动态加载的网页,我们想要获取其网页数据,需要了解网页是如何加载数据的,该过程就被成为逆向回溯。

对于使用了Ajax 请求技术的网页,我们可以找到Ajax请求的具体链接,直接得到Ajax请求得到的数据。

需要注意的是,构造Ajax请求有两种方式:

1.原生的Ajax请求:会直接创建一个XMLHTTPRequest对象。
2.调用jQuery的ajax()方法:一般情况下,$.ajax()会返回其创建的XMLHTTPRequest对象;但是,如果$.ajax()的dataType参数指定了为script或jsonp类型,$.ajax()不再返回其创建的XMLHTTPRequest对象。

对于这两种方式,只要创建并返回了XMLHTTPRequest对象,就可以通过Chrome浏览器的调试工具在NetWork窗口设置过滤条件为 xhr ,直接筛选出Ajax请求的链接;如果是$.ajax()并且dataType指定了为script或jsonp(这种情况下NetWork 里面的 Type 都是 script,如果你懂得 jsonp 的原理的话就知道 jsonp 本质就是通过 script),则无法通过这种方式筛选出来(因为这两种方式是经典的跨域方法,而 XHR 是不能跨域的,所以设置 XHR 过滤)

示例:

接下来以 新浪读书——书摘 为例,介绍如何得到无法筛选出来的Ajax请求链接:

在Chrome中打开网页,右键检查,会发现首页中书摘列表包含在一个id为subShowContent1_static的div中,而查看网页源代码会发现id为subShowContent1_static的div为空。

如图所示:

此处输入图片的描述

此处输入图片的描述

并且点击更多书摘或下一页时,网页URL并没有发生变化。

这与我们最前面所说的两种情况相同,说明这个网页就是使用 JS 动态加载数据的。

F12打开调试工具,打开NetWork窗口,F5刷新,可以看到浏览器发送以及接收到的数据记录(我们可以点击上面的 XHR 或者 JS 对这些请求进行过滤):

此处输入图片的描述

可以发现目前两种类型的请求都是存在的,暂时还不能判断我们 div 中内容的动态加载使用的是哪一种方式,不过没关系,我们可以进一步进行测试。

1.根据 id 进行查找

我们知道,js 操作页面的数据一定要进行定位,最常用的方法就是使用 id 定位,因为 id 在整个页面中是唯一的,那么我们第一步就是在所有的 js 文件中找和 subShowContent1_static 这个 id 相关的文件,于是我在 network 页面使用 ctrl+f 进行全局搜索

此处输入图片的描述

最终定位到了可能性最大的文件 feedlist.js

此处输入图片的描述

进入这个文件以后我就定位到了一个匿名函数 $(),这个函数将参数传入 Listmore() 函数

此处输入图片的描述

listmore() 函数调用了 Getmorelist() 函数

此处输入图片的描述

Getmorelist() 函数 调用了 getMore() 函数

此处输入图片的描述

getmore() 函数定义了我们的请求

此处输入图片的描述

2.设置断点进行动态捕获

可以看到这里使用的是 jsonp 的形式跨域传递数据的,然后 URL 是一个对象,是运行中生成的,我们可以在运行中对这个函数添加一个断点

此处输入图片的描述

然后 f5 刷新

此处输入图片的描述

断下来以后就能看到我们想要看到的 URL 以及后面跟着的参数了,这样就可以根据jQuery的ajax()用法构造正确的Ajax 请求链接:

http://feed.mix.sina.com.cn/api/roll/get?callback=xxxxxxxx&pageid=96&lid=560&num=20&page=1

那么这个 callback 是多少呢,我们现在还看不出来,但是,既然这个是一个请求,那么肯定会在 network 中有记录,我们找找看

此处输入图片的描述

我们现在就锁定了我们想要找的链接,得到Ajax请求链接之后,可以直接得到请求的数据,一般为json格式,处理后即可使用。

注:

其实当你有了经验之后,对一些不是很复杂的网页,根本就不用进行这么复杂的逆向工程,凭URL形式可以很快的在NetWork窗口 选择-验证 出所需的Ajax请求。

(2)渲染动态网页法

1.浏览器渲染引擎:

(1)简介:

在介绍这种方式之前,我们需要首先了解一些浏览器渲染引擎的基本知识。

渲染引擎的职责就是渲染,即在浏览器窗口中显示所请求的内容。浏览器向服务器发送请求,得到服务器返回的资源文件后,需要经过渲染引擎的处理,将资源文件显示在浏览器窗口中。

目前使用较为广泛的渲染引擎有两种:

webkit——使用者有Chrome, Safari
Geoko——使用者有Firefox
(2)渲染主流程:

渲染引擎首先通过网络获得所请求文档的内容,通常以8K分块的方式完成。

下面是渲染引擎在取得内容之后的基本流程:

此处输入图片的描述

解析html来构建dom树 -> 构建render树 -> 布局render树 -> 绘制render树

  • 渲染引擎开始解析html,并将标签转化为内容树中的dom节点。如果遇到JS,那么此时会启用另外的连接进行下载(下载过程中 dom 树的构建不会停止),并且在下载完成后立即执行(执行过程中会阻塞 浏览器的其他行为,因为 js 的运行可能会改变 dom 树的结构,为了不让刚刚构建好的 dom 树又被 js 改变,聪明的浏览器停止了 dom 树的构建)。

  • 接着,它解析外部CSS文件及style标签中的样式信息。这些样式信息以及html中的可见性指令将被用来构建另一棵树——render树(其实这一步是和上一步同时进行的,为了页面显示更迅速,css 不会等到 dom 树构建完毕才开始构建 render树 )。

  • Render树由一些包含有颜色和大小等属性的矩形组成,它们将被按照正确的顺序显示到屏幕上。

  • Render树构建好了之后,将会执行布局过程,它将确定每个节点在屏幕上的确切坐标。

  • 再下一步就是绘制,即遍历render树,并使用UI后端层绘制每个节点。

补充知识:

1.浏览器会解析三个东西:

(1) HTML/SVG/XHTML,解析这三种文件会产生一个 DOM Tree。
(2) CSS,解析 CSS 会产生 CSS 规则树(CSSOM)。
(3) Javascript脚本,主要是通过 DOM API 和 CSSOM API 来操作 DOM Tree 和 CSS Rule Tree.

2.形象的HTML页面加载和解析流程:

  1. 用户输入网址(假设是个html页面,并且是第一次访问),浏览器向服务器发出请求,服务器返回html文件
  2. 浏览器开始载入html代码,发现<head>标签内有一个<link>标签引用外部CSS文件;
  3. 浏览器又发出CSS文件的请求,服务器返回这个CSS文件;
  4. 浏览器继续载入html中<body>部分的代码,并且CSS文件已经拿到手了,可以开始渲染页面了;
  5. 浏览器在代码中发现一个<img>标签引用了一张图片,向服务器发出请求。此时浏览器不会等到图片下载完,而是继续渲染后面的代码;
  6. 服务器返回图片文件,由于图片占用了一定面积,影响了后面段落的排布,因此浏览器需要回过头来重新渲染这部分代码;
  7. 浏览器发现了一个包含一行Javascript代码的<script>标签,赶快运行它;
  8. Javascript脚本执行了这条语句,它命令浏览器隐藏掉代码中的某个<div> (style.display=”none”)。突然少了这么一个元素,浏览器不得不重新渲染这部分代码;
  9. 终于等到了</html>的到来,浏览器泪流满面……
  10. 等等,还没完,用户点了一下界面中的“换肤”按钮,Javascript让浏览器换了一下<link>标签的CSS路径
  11. 浏览器召集了在座的各位<div><span><ul><li>们,“大伙儿收拾收拾行李,咱得重新来过……”,浏览器向服务器请求了新的CSS文件,重新渲染页面。

3.Javascript的加载和执行的特点:

(1)载入后马上执行;
(2)执行时会阻塞页面后续的内容(包括页面的渲染、其它资源的下载)。原因:因为浏览器需要一个稳定的DOM树结构,而JS中很有可能有代码直接改变了DOM树结构,比如使用 document.write 或appendChild,甚至是直接使用的location.href进行跳转,浏览器为了防止出现JS修改DOM树,需要重新构建DOM树的情况,所以就会阻塞其他的下载和呈现。

(3)思考:

了解了浏览器渲染引擎的基本原理,我们可以发现:

当浏览器渲染引擎完成了dom树以及render树的构建之后,树中就已经包含了我们在浏览器窗口中可以看到的所有数据。

那么我们就有了一种爬取动态网页的新思路:

在浏览器渲染引擎执行layout以及printing之前,得到dom树或者render树,从树中获取动态加载的数据。

2.渲染动态网页:

(1)有两种选择:

1.自己从头实现一个浏览器渲染引擎,在合适的时机返回构建的dom树或render树:这需要进行大量的工作,需要考虑html、js、css等不同格式文件的解析方式以及解析顺序等。

2.接下来将使用WebKit 渲染引擎,通过 PySide 这个python库可以获得该引擎的一个便捷接口。

由于相当于第一种方法来说,第二种方法稍微简单一些,于是这里以第二种为例

(2)示例:

还是以 新浪读书——书摘 为例,可以发现:页面中文章列表的部分是动态加载的。

使用PySide库进行处理的示例代码如下:

#coding=utf-8

from PySide.QtGui import *
from PySide.QtCore import *
from PySide.QtWebKit import *


if __name__ == '__main__':

    url = "http://book.sina.com.cn/excerpt/rwws/"

    app = QApplication([])  # 完成其他Qt对象之前,必须先创建该对象
    webview = QWebView()  # 该对象是Web 对象的容器

    # 调用show方法显示窗口
    # webview.show()

    # 设置循环事件, 并等待网页加载完成
    loop = QEventLoop()
    webview.loadFinished.connect(loop.quit)
    webview.load(QUrl(url))
    loop.exec_()

    frame = webview.page().mainFrame()  # QWebFrame类有很多与网页交互的有用方法

    # 得到页面渲染后的html代码
    html = frame.toHtml()

    print html

通过print语句,我们可以发现:页面的源码html中已经包含了动态加载的内容。

与网站交互:

得到动态加载的内容后,需要解决的另一个问题是翻页问题。还好PySide库的QWebKit模块还有一个名为QWebFrame的类,支持很多与网页的交互操作。

如“点击”:

#根据CSS Selector 找到所需“进行翻页”的元素

elem = frame.findFirstElement('#subShowContent1_loadMore')

# 点击:通过evaluateJavaScript()函数可以执行Js代码

elem.evaluateJavaScript('this.click()')

除了点击事件,还可以进行填充表单,滚动窗口等操作

需要注意的是,在进行了翻页、或者获取更多内容时,一个最大的难点在于如何确定页面是否完成了加载,因为我们难以估计Ajax事件或者Js准备数据的时间。

对于这个问题有两种解决思路:

(1)等待固定的一段时间,比如time.sleep(3):这种方法容易实现,但效率较低。

(2)轮询网页,等待特定内容出现:这种方法虽然会在检查是否加载完成时浪费CPU周期,但更加可靠。

以下是一个简单的实现:

elem = None
while not elem:
 app.processEvents()
 elem = frame.findAllElemnets('#pattern')

代码循环,直到出现特定元素。每次循环,调用app.processEvents()方法,用于给Qt事件循环执行任务的时间,比如响应点击事件。

但是PySide毕竟是一个为了Python的GUI 编程而开发的, 其功能对于爬虫来说实在是太过于庞大,所以我们可以把爬虫经常使用的功能进行封装,来提升编写爬虫的效率。

(3)对PySide 常用功能的封装 —— ghost.py

ghost.py 是目前一个针对爬虫且功能比较完善的PySide的封装模块,使用它可以很方便的进行数据采集。

还是以获取列表页中每篇文章详情页地址为目标,

1.示例代码:
# coding=utf-8

import re
import time

from ghost import Ghost, Session


class SinaBookSpider(object):

    # 初始化相关参数
    gh = Ghost()
    ss = Session(gh, display=True)  # 设置display为true, 方便调试

    total = 1526  # 预先计算的总数据量
    count = 0  # 已爬取的数据量

    # 记录解析以及翻页位置
    location = 0
    click_times = 0

    def run(self):
        """
        开始爬虫
        :return:
        """
        # 打开网页
        self.ss.open("http://book.sina.com.cn/excerpt/rwws/")
        # 等待数据加载完成
        self.ss.wait_for_selector('#subShowContent1_static > div:nth-child(20)')

        self.parselist()

        while self.count < self.total:
            if self.click_times is 0:
                # 点击加载更多
                self.ss.click('#subShowContent1_loadMore')
                # 每次翻页,或加载更多,要等待至加载完成
                self.ss.wait_for_selector('#subShowContent1_static > div:nth-child(21)')

                self.click_times += 1
                self.parselist()
            elif self.click_times is 1:
                self.ss.click('#subShowContent1_loadMore')
                self.ss.wait_for_selector('#subShowContent1_static > div:nth-child(41)')

                self.click_times += 1
                self.parselist()
            elif self.click_times is 2:
                self.ss.click('#subShowContent1_page .pagebox_next a')
                self.ss.sleep(2)

                self.click_times = 0
                self.location = 0
                self.parselist()

    def parselist(self):
        """
        解析列表页
        :return:
        """
        html = self.ss.content.encode('utf8')
        # print html

        pattern = re.compile(r'<div class="item"><h4><a href="(.*?)" target="_blank">', re.M)
        links = pattern.findall(html)

        for i in range(self.location, len(links)):
            print links[i]
            self.count += 1
            self.location += 1
        print self.count


if __name__ == '__main__':
    spider = SinaBookSpider()
    spider.run()
2.代码地址:

https://github.com/linbo-lin/dynamic-web-process

3.补充:

ghost.py对直接获取元素支持的不是很好,但可以借助BeautifulSoup或正则表达式来解决。

ghost.py支持与网页的简单交互,如点击,填充表单等

  • set_field_value(*args, **kwargs)
  • fill(*args, **kwargs)
  • click(*args, **kwargs)

ghost.py很好的解决了确定元素加载完成的问题,通过以下方法可以让爬虫等待,直到满足设置的条件。

  • wait_for(condition, timeout_message, timeout=None)
  • wait_for_page_loaded(timeout=None)
  • wait_for_selector(selector, timeout=None)
  • wait_for_text(text, timeout=None)
  • wait_while_selector(selector, timeout=None)

(3)模拟浏览器行为法

前面的例子中,我们使用WebKit库,可以自定义浏览器渲染引擎,这样就可以完全控制想要执行的行为。如果不需要那么高的灵活性,那么还有一个不错的替代品 Selenium 可以选择,它提供了使浏览器自动化的API 接口。

1.Selenium 简介:

Selenium 是一个用于Web应用程序测试的工具。Selenium测试直接运行在浏览器中,就像真正的用户在操作一样。支持市面上几乎所有的主流浏览器。

本来打算使用的是selenium + PhantomJS(由于内部 webkit 组件无人维护并且会出现各种各样的问题,所以作者也已经不再维护)的组合,但发现Chrome以及FireFox也相继推出无头 ( headless ) 浏览器模式,个人比较倾向Chrome。本文采用的是Selenium+Chrome的组合。

2.示例:

运用到爬虫中的思路是:

使用Selenium 渲染网页,解析渲染后的网页源码,或者直接通过Selenium 接口获取页面中的元素。
还是以 新浪读书——书摘 这个网站为例,目标是获取列表中每篇文章详情页的地址

示例代码:

# coding=utf-8

import time

from selenium import webdriver


class SinaBookSpider(object):

    # 创建可见的Chrome浏览器, 方便调试
    driver = webdriver.Chrome()

    # 创建Chrome的无头浏览器
    # opt = webdriver.ChromeOptions()
    # opt.set_headless()
    # driver = webdriver.Chrome(options=opt)

    driver.implicitly_wait(10)

    total = 1526  # 预先计算的总数据量
    count = 0  # 已爬取的数据量

    # 记录解析以及翻页位置
    location = 0
    click_times = 0

    def run(self):
        """
        开始爬虫
        :return:
        """
        # get方式打开网页
        self.driver.get("http://book.sina.com.cn/excerpt/rwws/")

        self.parselist()

        while self.count < self.total:
            if self.click_times is 2:

                self.driver.find_element_by_css_selector('#subShowContent1_page > span:nth-child(6) > a').click()

                # 等待页面加载完成
                time.sleep(5)
                self.click_times = 0
                self.location = 0
            else:
                self.driver.find_element_by_css_selector('#subShowContent1_loadMore').click()

                # 等待页面加载完成
                time.sleep(3)
                self.click_times += 1

            # 分析加载的新内容,从location开始
            self.parselist()

        self.driver.quit()

    def parselist(self):
        """
        解析列表
        :return:
        """
        divs = self.driver.find_elements_by_class_name("item")

        for i in range(self.location, len(divs)):
            link = divs[i].find_element_by_tag_name('a').get_attribute("href")
            print link

            self.location += 1
            self.count += 1
        print self.count


if __name__ == '__main__':
    spider = SinaBookSpider()
    spider.run()

代码地址:https://github.com/linbo-lin/dynamic-web-process
如果你想实际运行上述代码,请在运行之前确定:安装了与浏览器版本对应的驱动,并正确的添加到了环境变量中。

3.使用selenium时同样要特别注意的是如何确定 网页是否加载完成

有三种方式:

(1)强制等待
(2)隐形等待
(3)显性等待

有关这三种方式的讲解可以看这里:Python selenium —— 一定要会用selenium的等待,三种等待方式解读 —— 灰蓝的博客

(4)总结:

到此,我们介绍了动态页面处理的一些思路:

1.逆向回溯 : 该方法属于手工方法,不适合自动检测
2.渲染动态页面 : 使用PySide或ghost.py,但是由于太过久远已经被时代淘汰了,所以这种方法并不优雅
3.selenium 模拟浏览器: 这种方法是现代大型爬虫最常使用的模式

0X03 参考链接

https://blog.csdn.net/ha_hha/article/details/80324343
https://blog.csdn.net/ha_hha/article/details/80324582
https://blog.csdn.net/Ha_hha/article/details/80324707
https://github.com/linbo-lin/dynamic-web-process
https://docs.seleniumhq.org/
https://ghost-py.readthedocs.io/en/latest/#
http://pyside.github.io/docs/pyside/
https://huilansame.github.io/huilansame.github.io/
https://blog.csdn.net/xiaozhuxmen/article/details/52014901
http://www.cnblogs.com/lhb25/p/how-browsers-work.html#Resources
http://book.sina.com.cn/excerpt/
https://blog.csdn.net/u010378313/article/details/51435992