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页面加载和解析流程:

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

    有三种方式: