Python3网络爬虫开发实战(5)Ajax 数据爬取

这里由于 Ajax 太过于基础,直接复制文章里的内容

有时候我们在用 requests 抓取页面的时候,得到的结果可能和在浏览器中看到的不一样:在浏览器中可以看到正常显示的页面数据,但是使用 requests 得到的结果并没有。这是因为 requests 获取的都是原始的 HTML 文档,而浏览器中的页面则是经过 JavaScript 处理数据后生成的结果,这些数据的来源有多种,可能是通过 Ajax 加载的,可能是包含在 HTML 文档中的,也可能是经过 JavaScript 和特定算法计算后生成的。

对于第一种情况,数据加载是一种异步加载方式,原始的页面最初不会包含某些数据,原始页面加载完后,会再向服务器请求某个接口获取数据,然后数据才被处理从而呈现到网页上,这其实就是发送了一个 Ajax 请求。

照 Web 发展的趋势来看,这种形式的页面越来越多。网页的原始 HTML 文档不会包含任何数据,数据都是通过 Ajax 统一加载后再呈现出来的,这样在 Web 开发上可以做到前后端分离,而且降低服务器直接渲染页面带来的压力。

所以如果遇到这样的页面,直接利用 requests 等库来抓取原始页面,是无法获取到有效数据的,这时需要分析网页后台向接口发送的 Ajax 请求,如果可以用 requests 来模拟 Ajax 请求,那么就可以成功抓取了。

所以,本章我们的主要目的是了解什么是 Ajax 以及如何去分析和抓取 Ajax 请求。

一、什么是 Ajax

对于传统的网页,如果想更新其内容,那么必须要刷新整个页面,但有了 Ajax,便可以在页面不被全部刷新的情况下更新其内容。在这个过程中,页面实际上是在后台与服务器进行了数据交互,获取到数据之后,再利用 JavaScript 改变网页,这样网页内容就会更新了。

可以到 W3School 上体验几个 Demo 来感受一下:http://www.w3school.com.cn/ajax/ajax_xmlhttprequest_send.asp

1. 实例引入

浏览网页的时候,我们会发现很多网页都有下滑查看更多的选项。比如,拿微博来说,以我的主页为例:https://m.weibo.cn/u/2830678474,切换到微博页面,一直下滑,可以发现下滑几个微博之后,再向下就没有了,转而会出现一个加载的动画,不一会儿下方就继续出现了新的微博内容,这个过程其实就是 Ajax 加载的过程,如图所示。

我们注意到页面其实并没有整个刷新,也就意味着页面的链接没有变化,但是网页中却多了新内容,也就是后面刷出来的新微博。这就是通过 Ajax 获取新数据并呈现的过程。

2. 基本原理

初步了解了 Ajax 之后,我们再来详细了解它的基本原理。发送 Ajax 请求到网页更新的这个过程可以简单分为以下 3 步:

  • 发送请求
  • 解析内容
  • 渲染网页

下面我们分别来详细介绍一下这几个过程。

发送请求

我们知道 JavaScript 可以实现页面的各种交互功能,Ajax 也不例外,它也是由 JavaScript 实现的,实际上执行了如下代码:

var xmlhttp;  
if (window.XMLHttpRequest) {  
    //code for IE7+, Firefox, Chrome, Opera, Safari  
    xmlhttp=new XMLHttpRequest();} else {//code for IE6, IE5  
    xmlhttp=new ActiveXObject("Microsoft.XMLHTTP");  
}  
xmlhttp.onreadystatechange=function() {if (xmlhttp.readyState==4 && xmlhttp.status==200) {document.getElementById("myDiv").innerHTML=xmlhttp.responseText;  
    }  
}  
xmlhttp.open("POST","/ajax/",true);  
xmlhttp.send();  

这是 JavaScript 对 Ajax 最底层的实现,实际上就是新建了 XMLHttpRequest 对象,然后调用 onreadystatechange 属性设置了监听,然后调用 open() 和 send() 方法向某个链接(也就是服务器)发送了请求。前面用 Python 实现请求发送之后,可以得到响应结果,但这里请求的发送变成 JavaScript 来完成。由于设置了监听,所以当服务器返回响应时,onreadystatechange 对应的方法便会被触发,然后在这个方法里面解析响应内容即可。

解析内容

得到响应之后,onreadystatechange 属性对应的方法便会被触发,此时利用 xmlhttp 的 responseText 属性便可取到响应内容。这类似于 Python 中利用 requests 向服务器发起请求,然后得到响应的过程。那么返回内容可能是 HTML,可能是 JSON,接下来只需要在方法中用 JavaScript 进一步处理即可。比如,如果是 JSON 的话,可以进行解析和转化。

渲染网页

JavaScript 有改变网页内容的能力,解析完响应内容之后,就可以调用 JavaScript 来针对解析完的内容对网页进行下一步处理了。比如,通过 document.getElementById().innerHTML 这样的操作,便可以对某个元素内的源代码进行更改,这样网页显示的内容就改变了,这样的操作也被称作 DOM 操作,即对 Document 网页文档进行操作,如更改、删除等。

上例中,document.getElementById("myDiv").innerHTML=xmlhttp.responseText 便将 ID 为 myDiv 的节点内部的 HTML 代码更改为服务器返回的内容,这样 myDiv 元素内部便会呈现出服务器返回的新数据,网页的部分内容看上去就更新了。

我们观察到,这 3 个步骤其实都是由 JavaScript 完成的,它完成了整个请求、解析和渲染的过程。

再回想微博的下拉刷新,这其实就是 JavaScript 向服务器发送了一个 Ajax 请求,然后获取新的微博数据,将其解析,并将其渲染在网页中。

因此,我们知道,真实的数据其实都是一次次 Ajax 请求得到的,如果想要抓取这些数据,需要知道这些请求到底是怎么发送的,发往哪里,发了哪些参数。如果我们知道了这些,不就可以用 Python 模拟这个发送操作,获取到其中的结果了吗?

在下一节中,我们就来了解哪里可以看到这些后台 Ajax 操作,了解它到底是怎么发送的,发送了什么参数。

二、Ajax 分析方法

这里还以前面的微博为例,我们知道拖动刷新的内容由 Ajax 加载,而且页面的 URL 没有变化,那么应该到哪里去查看这些 Ajax 请求呢?

1. 查看请求

这里还需要借助浏览器的开发者工具,下面以 Chrome 浏览器为例来介绍。

首先,用 Chrome 浏览器打开微博的链接 https://m.weibo.cn/u/2830678474,随后在页面中点击鼠标右键,从弹出的快捷菜单中选择 “检查” 选项,此时便会弹出开发者工具,如图所示。

此时在 Elements 选项卡中便会观察到网页的源代码,右侧便是节点的样式。

不过这不是我们想要寻找的内容。切换到 Network 选项卡,随后重新刷新页面,可以发现这里出现了非常多的条目,如图所示。

前面也提到过,这里其实就是在页面加载过程中浏览器与服务器之间发送请求和接收响应的所有记录。

Ajax 其实有其特殊的请求类型,它叫作 xhr。我们可以发现一个名称以 getIndex 开头的请求,其 Type 为 xhr,这就是一个 Ajax 请求。用鼠标点击这个请求,可以查看这个请求的详细信息。

在右侧可以观察到其 Request Headers、URL 和 Response Headers 等信息。其中 Request Headers 中有一个信息为 X-Requested-With:XMLHttpRequest,这就标记了此请求是 Ajax 请求,如图所示。

随后点击一下 Preview,即可看到响应的内容,它是 JSON 格式的。这里 Chrome 为我们自动做了解析,点击箭头即可展开和收起相应内容,如图所示。

观察可以发现,这里的返回结果是我的个人信息,如昵称、简介、头像等,这也是用来渲染个人主页所使用的数据。JavaScript 接收到这些数据之后,再执行相应的渲染方法,整个页面就渲染出来了。

另外,也可以切换到 Response 选项卡,从中观察到真实的返回数据,如图所示。

接下来,切回到第一个请求,观察一下它的 Response 是什么,如图所示。

这是最原始的链接 https://m.weibo.cn/u/2830678474 返回的结果,其代码只有不到 50 行,结构也非常简单,只是执行了一些 JavaScript。

所以说,我们看到的微博页面的真实数据并不是最原始的页面返回的,而是后来执行 JavaScript 后再次向后台发送了 Ajax 请求,浏览器拿到数据后再进一步渲染出来的。

2. 过滤请求

接下来,再利用 Chrome 开发者工具的筛选功能筛选出所有的 Ajax 请求。在请求的上方有一层筛选栏,直接点击 XHR,此时在下方显示的所有请求便都是 Ajax 请求了,如图所示。

接下来,不断滑动页面,可以看到页面底部有一条条新的微博被刷出,而开发者工具下方也一个个地出现 Ajax 请求,这样我们就可以捕获到所有的 Ajax 请求了。

随意点开一个条目,都可以清楚地看到其 Request URL、Request Headers、Response Headers、Response Body 等内容,此时想要模拟请求和提取就非常简单了。

图所示的内容便是我的某一页微博的列表信息。

到现在为止,我们已经可以分析出 Ajax 请求的一些详细信息了,接下来只需要用程序模拟这些 Ajax 请求,就可以轻松提取我们所需要的信息了。

在下一节中,我们用 Python 实现 Ajax 请求的模拟,从而实现数据的抓取。

三、Ajax 结果提取

这里仍然以微博为例,接下来用 Python 来模拟这些 Ajax 请求,把我发过的微博爬取下来。

1. 分析请求

打开 Ajax 的 XHR 过滤器,然后一直滑动页面以加载新的微博内容。可以看到,会不断有 Ajax 请求发出。

选定其中一个请求,分析它的参数信息。点击该请求,进入详情页面,如图所示。

可以发现,这是一个 GET 类型的请求,请求链接为 https://m.weibo.cn/api/container/getIndex?type=uid&value=2145291155&containerid=1076032145291155&page=2,请求的参数有四个:type、value、containerid、page。

随后再看看其他请求,可以发现,它们的 type、value 和 containerid 始终如一。type 始终为 uid,value 的值就是页面链接中的数字,其实这就是用户的 id。另外,还有 containerid。可以发现,它就是 107603 加上用户 id。改变的值就是 page,很明显这个参数是用来控制分页的,page=1 代表第一页,page=2 代表第二页,以此类推。

2. 分析响应

随后,观察这个请求的响应内容,如图所示。

这个内容是 JSON 格式的,浏览器开发者工具自动做了解析以方便我们查看。可以看到,最关键的两部分信息就是 cardlistInfo 和 cards:前者包含一个比较重要的信息 total,观察后可以发现,它其实是微博的总数量,我们可以根据这个数字来估算分页数;后者则是一个列表,它包含 10 个元素,展开其中一个看一下,如图所示。

可以发现,这个元素有一个比较重要的字段 mblog。展开它,可以发现它包含的正是微博的一些信息,比如 attitudes_count(赞数目)、comments_count(评论数目)、reposts_count(转发数目)、created_at(发布时间)、text(微博正文)等,而且它们都是一些格式化的内容。

这样我们请求一个接口,就可以得到 10 条微博,而且请求时只需要改变 page 参数即可。

这样的话,我们只需要简单做一个循环,就可以获取所有微博了。

3. 实战演练

这里我们用程序模拟这些 Ajax 请求,将我的前 10 页微博全部爬取下来。

首先,定义一个方法来获取每次请求的结果。在请求时,page 是一个可变参数,所以我们将它作为方法的参数传递进来,相关代码如下:

from urllib.parse import urlencode  
import requests  
base_url = 'https://m.weibo.cn/api/container/getIndex?'  
  
headers = {  
    'Host': 'm.weibo.cn',  
    'Referer': 'https://m.weibo.cn/u/2830678474',  
    'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko)  
        Chrome/58.0.3029.110 Safari/537.36',  
    'X-Requested-With': 'XMLHttpRequest',  
}  
  
def get_page(page):  
    params = {  
        'type': 'uid',  
        'value': '2830678474',  
        'containerid': '1076032830678474',  
        'page': page  
    }  
    url = base_url + urlencode(params)  
    try:  
        response = requests.get(url, headers=headers)  
        if response.status_code == 200:  
            return response.json()  
    except requests.ConnectionError as e:  
        print('Error', e.args)  

首先,这里定义了 base_url 来表示请求的 URL 的前半部分。接下来,构造参数字典,其中 type、value 和 containerid 是固定参数,page 是可变参数。接下来,调用 urlencode 方法将参数转化为 URL 的 GET 请求参数,即类似于 type=uid&value=2145291155&containerid=1076032145291155&page=2 这样的形式。随后,base_url 与参数拼合形成一个新的 URL。接着,我们用 requests 请求这个链接,加入 headers 参数。然后判断响应的状态码,如果是 200,则直接调用 json 方法将内容解析为 JSON 返回,否则不返回任何信息。如果出现异常,则捕获并输出其异常信息。

随后,我们需要定义一个解析方法,用来从结果中提取想要的信息,比如这次想保存微博的 id、正文、赞数、评论数和转发数这几个内容,那么可以先遍历 cards,然后获取 mblog 中的各个信息,赋值为一个新的字典返回即可:

from pyquery import PyQuery as pq  
  
def parse_page(json):  
    if json:  
        items = json.get('data').get('cards')  
        for item in items:  
            item = item.get('mblog')  
            weibo = {}  
            weibo['id'] = item.get('id')  
            weibo['text'] = pq(item.get('text')).text()  
            weibo['attitudes'] = item.get('attitudes_count')  
            weibo['comments'] = item.get('comments_count')  
            weibo['reposts'] = item.get('reposts_count')  
            yield weibo  

这里我们借助 pyquery 将正文中的 HTML 标签去掉。

最后,遍历一下 page,一共 10 页,将提取到的结果打印输出即可:

if __name__ == '__main__':  
    for page in range(1, 11):  
        json = get_page(page)  
        results = parse_page(json)  
        for result in results:  
            print(result)  

另外,我们还可以加一个方法将结果保存到 MongoDB 数据库:

from pymongo import MongoClient  
  
client = MongoClient()  
db = client['weibo']  
collection = db['weibo']  
  
def save_to_mongo(result):  
    if collection.insert(result):  
        print('Saved to Mongo')  

这样所有功能就实现完成了。运行程序后,样例输出结果如下:

{'id': '4134879836735238', 'text': ' 惊不惊喜,刺不刺激,意不意外,感不感动 ', 'attitudes': 3,  
    'comments': 1, 'reposts': 0}  
Saved to Mongo  
{'id': '4143853554221385', 'text': ' 曾经梦想仗剑走天涯,后来过安检给收走了。分享单曲远走高飞 ',  
    'attitudes': 5, 'comments': 1, 'reposts': 0}  
Saved to Mongo  

查看一下 MongoDB,相应的数据也被保存到 MongoDB,如图所示。

这样,我们就顺利通过分析 Ajax 并编写爬虫爬取下来微博列表。最后,给出本节的代码地址:https://github.com/Python3WebSpider/WeiboList

本节的目的是为了演示 Ajax 的模拟请求过程,爬取的结果不是重点。该程序仍有很多可以完善的地方,如页码的动态计算、微博查看全文等,若感兴趣,可以尝试一下。

通过这个实例,我们主要学会了怎样去分析 Ajax 请求,怎样用程序来模拟抓取 Ajax 请求。了解了抓取原理之后,下一节的 Ajax 实战演练会更加得心应手。

四、分析 Ajax 爬取今日头条街拍美图

本节中,我们以今日头条为例来尝试通过分析 Ajax 请求来抓取网页数据的方法。这次要抓取的目标是今日头条的街拍美图,抓取完成之后,将每组图片分文件夹下载到本地并保存下来。

1. 抓取分析

在抓取之前,首先要分析抓取的逻辑。打开今日头条的首页 http://www.toutiao.com/,如图所示。

右上角有一个搜索入口,这里尝试抓取街拍美图,所以输入 “街拍” 二字搜索一下,结果如图所示。

这时打开开发者工具,查看所有的网络请求。首先,打开第一个网络请求,这个请求的 URL 就是当前的链接 [http://www.toutiao.com/search/?keyword = 街拍](http://www.toutiao.com/search/?keyword = 街拍),打开 Preview 选项卡查看 Response Body。如果页面中的内容是根据第一个请求得到的结果渲染出来的,那么第一个请求的源代码中必然会包含页面结果中的文字。为了验证,我们可以尝试搜索一下搜索结果的标题,比如 “路人” 二字,如图所示。

我们发现,网页源代码中并没有包含这两个字,搜索匹配结果数目为 0。因此,可以初步判断这些内容是由 Ajax 加载,然后用 JavaScript 渲染出来的。接下来,我们可以切换到 XHR 过滤选项卡,查看一下有没有 Ajax 请求。

不出所料,此处出现了一个比较常规的 Ajax 请求,看看它的结果是否包含了页面中的相关数据。

点击 data 字段展开,发现这里有许多条数据。点击第一条展开,可以发现有一个 title 字段,它的值正好就是页面中第一条数据的标题。再检查一下其他数据,也正好是一一对应的,如图所示。

这就确定了这些数据确实是由 Ajax 加载的。

我们的目的是要抓取其中的美图,这里一组图就对应前面 data 字段中的一条数据。每条数据还有一个 image_detail 字段,它是列表形式,这其中就包含了组图的所有图片列表,如图所示。

因此,我们只需要将列表中的 url 字段提取出来并下载下来就好了。每一组图都建立一个文件夹,文件夹的名称就为组图的标题。

接下来,就可以直接用 Python 来模拟这个 Ajax 请求,然后提取出相关美图链接并下载。但是在这之前,我们还需要分析一下 URL 的规律。

切换回 Headers 选项卡,观察一下它的请求 URL 和 Headers 信息,如图所示。

可以看到,这是一个 GET 请求,请求 URL 的参数有 offset、format、keyword、autoload、count 和 cur_tab。我们需要找出这些参数的规律,因为这样才可以方便地用程序构造出来。

接下来,可以滑动页面,多加载一些新结果。在加载的同时可以发现,Network 中又出现了许多 Ajax 请求,如图所示。

这里观察一下后续链接的参数,发现变化的参数只有 offset,其他参数都没有变化,而且第二次请求的 offset 值为 20,第三次为 40,第四次为 60,所以可以发现规律,这个 offset 值就是偏移量,进而可以推断出 count 参数就是一次性获取的数据条数。因此,我们可以用 offset 参数来控制数据分页。这样一来,我们就可以通过接口批量获取数据了,然后将数据解析,将图片下载下来即可。

2. 实战演练

我们刚才已经分析了一下 Ajax 请求的逻辑,下面就用程序来实现美图下载吧。

首先,实现方法 get_page 来加载单个 Ajax 请求的结果。其中唯一变化的参数就是 offset,所以我们将它当作参数传递,实现如下:

import requests  
from urllib.parse import urlencode  
  
def get_page(offset):  
    params = {  
        'offset': offset,  
        'format': 'json',  
        'keyword': ' 街拍 ',  
        'autoload': 'true',  
        'count': '20',  
        'cur_tab': '3',  
    }  
    url = 'http://www.toutiao.com/search_content/?' + urlencode(params)  
    try:  
        response = requests.get(url)  
        if response.status_code == 200:  
            return response.json()  
    except requests.ConnectionError:  
        return None  

这里我们用 urlencode 方法构造请求的 GET 参数,然后用 requests 请求这个链接,如果返回状态码为 200,则调用 response 的 json 方法将结果转为 JSON 格式,然后返回。

接下来,再实现一个解析方法:提取每条数据的 image_detail 字段中的每一张图片链接,将图片链接和图片所属的标题一并返回,此时可以构造一个生成器。实现代码如下:

def get_images(json):  
    if json.get('data'):  
        for item in json.get('data'):  
            title = item.get('title')  
            images = item.get('image_detail')  
            for image in images:  
                yield {'image': image.get('url'),  
                    'title': title  
                }  

接下来,实现一个保存图片的方法 save_image,其中 item 就是前面 get_images 方法返回的一个字典。在该方法中,首先根据 item 的 title 来创建文件夹,然后请求这个图片链接,获取图片的二进制数据,以二进制的形式写入文件。图片的名称可以使用其内容的 MD5 值,这样可以去除重复。相关代码如下:

import os  
from hashlib import md5  
  
def save_image(item):  
    if not os.path.exists(item.get('title')):  
        os.mkdir(item.get('title'))  
    try:  
        response = requests.get(item.get('image'))  
        if response.status_code == 200:  
            file_path = '{0}/{1}.{2}'.format(item.get('title'), md5(response.content).hexdigest(), 'jpg')  
            if not os.path.exists(file_path):  
                with open(file_path, 'wb') as f:  
                    f.write(response.content)  
            else:  
                print('Already Downloaded', file_path)  
    except requests.ConnectionError:  
        print('Failed to Save Image')  

最后,只需要构造一个 offset 数组,遍历 offset,提取图片链接,并将其下载即可:

from multiprocessing.pool import Pool  
  
def main(offset):  
    json = get_page(offset)  
    for item in get_images(json):  
        print(item)  
        save_image(item)  
  
  
GROUP_START = 1  
GROUP_END = 20  
  
if __name__ == '__main__':  
    pool = Pool()  
    groups = ([x * 20 for x in range(GROUP_START, GROUP_END + 1)])  
    pool.map(main, groups)  
    pool.close()  
    pool.join()  

这里定义了分页的起始页数和终止页数,分别为 GROUP_START 和 GROUP_END,还利用了多进程的进程池,调用其 map 方法实现多进程下载。

这样整个程序就完成了,运行之后可以发现街拍美图都分文件夹保存下来了,如图所示。

最后,我们给出本节的代码地址:https://github.com/Python3WebSpider/Jiepai

通过本节,我们了解了 Ajax 分析的流程、Ajax 分页的模拟以及图片的下载过程。

本节的内容需要熟练掌握,在后面的实战中我们还会用到很多次这样的分析和抓取。

  • 6
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值