第 3 章 基本库的使用

第 3 章 基本库的使用

学习爬虫,最初的操作便是模拟浏览器向服务器发出请求,那么我们需要从哪个地方做起呢?请求需要我们自己来构造吗?需要关心请求这个数据结构的实现吗?需要了解HTTP、TCP、IP层的网络传输通信吗?需要知道服务器的响应和应答原理吗?

可能你无从下手,不过不用担心,Python的强大之处就是提供了功能齐全的类库来帮助我们完成这些请求。最基础的HTTP库有urllib、httplib2、requests、treq等。

拿urllib这个库来说,有了它,我们只需要关心请求的链接是什么,需要传的参数是什么,以及如何设置可选的请求头就好了,不用深入到底层去了解它到底是怎样传输和通信的。有了它,两行代码就可以完成一个请求和响应的处理过程,得到网页内容,是不是感觉方便极了?

接下来,就让我们从最基础的部分开始了解这些库的使用方法吧。

3.1 使用urllib

在Python 2中,有urllib和urllib2两个库来实现请求的发送。而在Python 3中,已经不存在urllib2这个库了,统一为urllib,其官方文档链接为:https://docs.python.org/3/library/urllib.html

首先,了解一下urllib库,它是Python内置的HTTP请求库,也就是说不需要额外安装即可使用。它包含如下4个模块。

  • request:它是最基本的HTTP请求模块,可以用来模拟发送请求。就像在浏览器里输入网址然后回车一样,只需要给库方法传入URL以及额外的参数,就可以模拟实现这个过程了。
  • error:异常处理模块,如果出现请求错误,我们可以捕获这些异常,然后进行重试或其他操作以保证程序不会意外终止。
  • parse:一个工具模块,提供了许多URL处理方法,比如拆分、解析、合并等。
  • robotparser:主要是用来识别网站的robots.txt文件,然后判断哪些网站可以爬,哪些网站不可以爬,它其实用得比较少。

这里重点讲解一下前3个模块。

3.1.1 发送请求

使用urllib的request模块,我们可以方便地实现请求的发送并得到响应。本节就来看下它的具体用法。

  1. urlopen()

    urllib.request模块提供了最基本的构造HTTP请求的方法,利用它可以模拟浏览器的一个请求发起过程,同时它还带有处理授权验证(authentication)、重定向(redirection)、浏览器Cookies以及其他内容。

    下面我们来看一下它的强大之处。这里以Python官网为例,我们来把这个网页抓下来:

    import urllib.request
    
    response = urllib.request.urlopen('https://www.python.org')
    print(response.read().decode('utf-8'))

    运行结果如图3-1所示。

    图3-1 运行结果

    这里我们只用了两行代码,便完成了Python官网的抓取,输出了网页的源代码。得到源代码之后呢?我们想要的链接、图片地址、文本信息不就都可以提取出来了吗?

    接下来,看看它返回的到底是什么。利用type()方法输出响应的类型:

    import urllib.request
    
    response = urllib.request.urlopen('https://www.python.org')
    print(type(response))

    输出结果如下:

    <class 'http.client.HTTPResponse'>

    可以发现,它是一个HTTPResposne类型的对象,主要包含read()readinto()getheader(name)getheaders()fileno()等方法,以及msgversionstatusreasondebuglevelclosed等属性。

    得到这个对象之后,我们把它赋值为response变量,然后就可以调用这些方法和属性,得到返回结果的一系列信息了。

    例如,调用read()方法可以得到返回的网页内容,调用status属性可以得到返回结果的状态码,如200代表请求成功,404代表网页未找到等。

    下面再通过一个实例来看看:

    import urllib.request
    
    response = urllib.request.urlopen('https://www.python.org')
    print(response.status)
    print(response.getheaders())
    print(response.getheader('Server'))

    运行结果如下:

    200
    [('Server', 'nginx'), ('Content-Type', 'text/html; charset=utf-8'), ('X-Frame-Options', 'SAMEORIGIN'),
        ('X-Clacks-Overhead', 'GNU Terry Pratchett'), ('Content-Length', '47397'), ('Accept-Ranges', 'bytes'),
        ('Date', 'Mon, 01 Aug 2016 09:57:31 GMT'), ('Via', '1.1 varnish'), ('Age', '2473'), ('Connection', 'close'),
        ('X-Served-By', 'cache-lcy1125-LCY'), ('X-Cache', 'HIT'), ('X-Cache-Hits', '23'), ('Vary', 'Cookie'),
        ('Strict-Transport-Security', 'max-age=63072000; includeSubDomains')]
    nginx

    可见,前两个输出分别输出了响应的状态码和响应的头信息,最后一个输出通过调用getheader()方法并传递一个参数Server获取了响应头中的Server值,结果是nginx,意思是服务器是用Nginx搭建的。

    利用最基本的urlopen()方法,可以完成最基本的简单网页的GET请求抓取。

    如果想给链接传递一些参数,该怎么实现呢?首先看一下urlopen()函数的API:

    urllib.request.urlopen(url, data=None, [timeout, ]*, cafile=None, capath=None, cadefault=False, context=None)

    可以发现,除了第一个参数可以传递URL之外,我们还可以传递其他内容,比如data(附加数据)、timeout(超时时间)等。

    下面我们详细说明下这几个参数的用法。

    • data参数

      data参数是可选的。如果要添加该参数,需要使用bytes()方法将参数转化为字节流编码格式的内容,即bytes类型。另外,如果传递了这个参数,则它的请求方式就不再是GET方式,而是POST方式。

      下面用实例来看一下:

      import urllib.parse
      import urllib.request
      
      data = bytes(urllib.parse.urlencode({'word': 'hello'}), encoding='utf8')
      response = urllib.request.urlopen('http://httpbin.org/post', data=data)
      print(response.read())

      这里我们传递了一个参数word,值是hello。它需要被转码成bytes(字节流)类型。其中转字节流采用了bytes()方法,该方法的第一个参数需要是str(字符串)类型,需要用urllib.parse模块里的urlencode()方法来将参数字典转化为字符串;第二个参数指定编码格式,这里指定为utf8

      这里请求的站点是httpbin.org,它可以提供HTTP请求测试。本次我们请求的URL为http://httpbin.org/post,这个链接可以用来测试POST请求,它可以输出请求的一些信息,其中包含我们传递的data参数。

      运行结果如下:

      {
           "args": {},
           "data": "",
           "files": {},
           "form": {
               "word": "hello"
           },
           "headers": {
               "Accept-Encoding": "identity",
               "Content-Length": "10",
               "Content-Type": "application/x-www-form-urlencoded",
               "Host": "httpbin.org",
               "User-Agent": "Python-urllib/3.5"
           },
           "json": null,
           "origin": "123.124.23.253",
           "url": "http://httpbin.org/post"
      }

      我们传递的参数出现在了form字段中,这表明是模拟了表单提交的方式,以POST方式传输数据。

    • timeout参数

      timeout参数用于设置超时时间,单位为秒,意思就是如果请求超出了设置的这个时间,还没有得到响应,就会抛出异常。如果不指定该参数,就会使用全局默认时间。它支持HTTP、HTTPS、FTP请求。

      下面用实例来看一下:

      import urllib.request
      
      response = urllib.request.urlopen('http://httpbin.org/get', timeout=1)
      print(response.read())

      运行结果如下:

      During handling of the above exception, another exception occurred:
      
      Traceback (most recent call last): File "/var/py/python/urllibtest.py", line 4, in <module> response =
          urllib.request.urlopen('http://httpbin.org/get', timeout=1)
      ...
      urllib.error.URLError: <urlopen error timed out>

      这里我们设置超时时间是1秒。程序运行1秒过后,服务器依然没有响应,于是抛出了URLError异常。该异常属于urllib.error模块,错误原因是超时。

      因此,可以通过设置这个超时时间来控制一个网页在长时间未响应时,就跳过它的抓取。这可以利用try except语句来实现,相关代码如下:

      import socket
      import urllib.request
      import urllib.error
      
      try:
          response = urllib.request.urlopen('http://httpbin.org/get', timeout=0.1)
      except urllib.error.URLError as e:
          if isinstance(e.reason, socket.timeout):
              print('TIME OUT')

      这里我们请求了http://httpbin.org/get测试链接,设置超时时间是0.1秒,然后捕获了URLError异常,接着判断异常是socket.timeout类型(意思就是超时异常),从而得出它确实是因为超时而报错,打印输出了TIME OUT

      运行结果如下:

      TIME OUT

      按照常理来说,0.1秒内基本不可能得到服务器响应,因此输出了TIME OUT的提示。

      通过设置timeout这个参数来实现超时处理,有时还是很有用的。

    • 其他参数

      除了data参数和timeout参数外,还有context参数,它必须是ssl.SSLContext类型,用来指定SSL设置。

      此外,cafilecapath这两个参数分别指定CA证书和它的路径,这个在请求HTTPS链接时会有用。

      cadefault参数现在已经弃用了,其默认值为False

      前面讲解了urlopen()方法的用法,通过这个最基本的方法,我们可以完成简单的请求和网页抓取。若需更加详细的信息,可以参见官方文档:https://docs.python.org/3/library/urllib.request.html

  2. Request

    我们知道利用urlopen()方法可以实现最基本请求的发起,但这几个简单的参数并不足以构建一个完整的请求。如果请求中需要加入Headers等信息,就可以利用更强大的Request类来构建。

    首先,我们用实例来感受一下Request的用法:

    import urllib.request
    
    request = urllib.request.Request('https://python.org')
    response = urllib.request.urlopen(request)
    print(response.read().decode('utf-8'))

    可以发现,我们依然是用urlopen()方法来发送这个请求,只不过这次该方法的参数不再是URL,而是一个Request类型的对象。通过构造这个数据结构,一方面我们可以将请求独立成一个对象,另一方面可更加丰富和灵活地配置参数。

    下面我们看一下Request可以通过怎样的参数来构造,它的构造方法如下:

    class urllib.request.Request(url, data=None, headers={}, origin_req_host=None, unverifiable=False, method=None)
    • 第一个参数url用于请求URL,这是必传参数,其他都是可选参数。
    • 如果要传第二个参数data,必须传bytes(字节流)类型的。如果它是字典,可以先用urllib.parse模块里的urlencode()编码。
    • 第三个参数headers是一个字典,它就是请求头,我们可以在构造请求时通过headers参数直接构造,也可以通过调用请求实例的add_header()方法添加。

      添加请求头最常用的用法就是通过修改User-Agent来伪装浏览器,默认的User-Agent是Python-urllib,我们可以通过修改它来伪装浏览器。比如要伪装火狐浏览器,你可以把它设置为:

      Mozilla/5.0 (X11; U; Linux i686) Gecko/20071127 Firefox/2.0.0.11
    • 第四个参数origin_req_host指的是请求方的host名称或者IP地址。

    • 第五个参数unverifiable表示这个请求是否是无法验证的,默认是False,意思就是说用户没有足够权限来选择接收这个请求的结果。例如,我们请求一个HTML文档中的图片,但是我们没有自动抓取图像的权限,这时unverifiable的值就是True
    • 第六个参数method是一个字符串,用来指示请求使用的方法,比如GET、POST和PUT等。

    下面我们传入多个参数构建请求来看一下:

    from urllib import request, parse
    
    url = 'http://httpbin.org/post'
    headers = {
        'User-Agent': 'Mozilla/4.0 (compatible; MSIE 5.5; Windows NT)',
        'Host': 'httpbin.org'
    }
    dict = {
        'name': 'Germey'
    }
    data = bytes(parse.urlencode(dict), encoding='utf8')
    req = request.Request(url=url, data=data, headers=headers, method='POST')
    response = request.urlopen(req)
    print(response.read().decode('utf-8'))

    这里我们通过4个参数构造了一个请求,其中url即请求URL,headers中指定了User-AgentHost,参数dataurlencode()bytes()方法转成字节流。另外,指定了请求方式为POST。

    运行结果如下:

    {
      "args": {},
      "data": "",
      "files": {},
      "form": {
        "name": "Germey"
      },
      "headers": {
        "Accept-Encoding": "identity",
        "Content-Length": "11",
        "Content-Type": "application/x-www-form-urlencoded",
        "Host": "httpbin.org",
        "User-Agent": "Mozilla/4.0 (compatible; MSIE 5.5; Windows NT)"
      },
      "json": null,
      "origin": "219.224.169.11",
      "url": "http://httpbin.org/post"
    }

    观察结果可以发现,我们成功设置了dataheadersmethod

    另外,headers也可以用add_header()方法来添加:

    req = request.Request(url=url, data=data, method='POST')
    req.add_header('User-Agent', 'Mozilla/4.0 (compatible; MSIE 5.5; Windows NT)')

    如此一来,我们就可以更加方便地构造请求,实现请求的发送啦。

  3. 高级用法

    在上面的过程中,我们虽然可以构造请求,但是对于一些更高级的操作(比如Cookies处理、代理设置等),我们该怎么办呢?

    接下来,就需要更强大的工具Handler登场了。简而言之,我们可以把它理解为各种处理器,有专门处理登录验证的,有处理Cookies的,有处理代理设置的。利用它们,我们几乎可以做到HTTP请求中所有的事情。

    首先,介绍一下urllib.request模块里的BaseHandler类,它是所有其他Handler的父类,它提供了最基本的方法,例如default_open()protocol_request()等。

    接下来,就有各种Handler子类继承这个BaseHandler类,举例如下。

    • HTTPDefaultErrorHandler:用于处理HTTP响应错误,错误都会抛出HTTPError类型的异常。
    • HTTPRedirectHandler:用于处理重定向。
    • HTTPCookieProcessor:用于处理Cookies。
    • ProxyHandler:用于设置代理,默认代理为空。
    • HTTPPasswordMgr:用于管理密码,它维护了用户名和密码的表。
    • HTTPBasicAuthHandler:用于管理认证,如果一个链接打开时需要认证,那么可以用它来解决认证问题。

    另外,还有其他的Handler类,这里就不一一列举了,详情可以参考官方文档:https://docs.python.org/3/library/urllib.request.html#urllib.request.BaseHandler

    关于怎么使用它们,现在先不用着急,后面会有实例演示。

    另一个比较重要的类就是OpenerDirector,我们可以称为Opener。我们之前用过urlopen()这个方法,实际上它就是urllib为我们提供的一个Opener

    那么,为什么要引入Opener呢?因为需要实现更高级的功能。之前使用的Requesturlopen()相当于类库为你封装好了极其常用的请求方法,利用它们可以完成基本的请求,但是现在不一样了,我们需要实现更高级的功能,所以需要深入一层进行配置,使用更底层的实例来完成操作,所以这里就用到了Opener

    Opener可以使用open()方法,返回的类型和urlopen()如出一辙。那么,它和Handler有什么关系呢?简而言之,就是利用Handler来构建Opener

    下面用几个实例来看看它们的用法。

    • 验证

      有些网站在打开时就会弹出提示框,直接提示你输入用户名和密码,验证成功后才能查看页面,如图3-2所示。

      图3-2 验证页面

      那么,如果要请求这样的页面,该怎么办呢?借助HTTPBasicAuthHandler就可以完成,相关代码如下:

      from urllib.request import HTTPPasswordMgrWithDefaultRealm, HTTPBasicAuthHandler, build_opener
      from urllib.error import URLError
      
      username = 'username'
      password = 'password'
      url = 'http://localhost:5000/'
      
      p = HTTPPasswordMgrWithDefaultRealm()
      p.add_password(None, url, username, password)
      auth_handler = HTTPBasicAuthHandler(p)
      opener = build_opener(auth_handler)
      
      try:
          result = opener.open(url)
          html = result.read().decode('utf-8')
          print(html)
      except URLError as e:
          print(e.reason)

      这里首先实例化HTTPBasicAuthHandler对象,其参数是HTTPPasswordMgrWithDefaultRealm对象,它利用add_password()添加进去用户名和密码,这样就建立了一个处理验证的Handler

      接下来,利用这个Handler并使用build_opener()方法构建一个Opener,这个Opener在发送请求时就相当于已经验证成功了。

      接下来,利用Openeropen()方法打开链接,就可以完成验证了。这里获取到的结果就是验证后的页面源码内容。

    • 代理

      在做爬虫的时候,免不了要使用代理,如果要添加代理,可以这样做:

      from urllib.error import URLError
      from urllib.request import ProxyHandler, build_opener
      
      proxy_handler = ProxyHandler({
          'http': 'http://127.0.0.1:9743',
          'https': 'https://127.0.0.1:9743'
      })
      opener = build_opener(proxy_handler)
      try:
          response = opener.open('https://www.baidu.com')
          print(response.read().decode('utf-8'))
      except URLError as e:
          print(e.reason)

      这里我们在本地搭建了一个代理,它运行在9743端口上。

      这里使用了ProxyHandler,其参数是一个字典,键名是协议类型(比如HTTP或者HTTPS等),键值是代理链接,可以添加多个代理。

      然后,利用这个Handler及build_opener()方法构造一个Opener,之后发送请求即可。

    • Cookies

      Cookies的处理就需要相关的Handler了。

      我们先用实例来看看怎样将网站的Cookies获取下来,相关代码如下:

      import http.cookiejar, urllib.request
      
      cookie = http.cookiejar.CookieJar()
      handler = urllib.request.HTTPCookieProcessor(cookie)
      opener = urllib.request.build_opener(handler)
      response = opener.open('http://www.baidu.com')
      for item in cookie:
          print(item.name+"="+item.value)

      首先,我们必须声明一个CookieJar对象。接下来,就需要利用HTTPCookieProcessor来构建一个Handler,最后利用build_opener()方法构建出Opener,执行open()函数即可。

      运行结果如下:

      BAIDUID=2E65A683F8A8BA3DF521469DF8EFF1E1:FG=1
      BIDUPSID=2E65A683F8A8BA3DF521469DF8EFF1E1
      H_PS_PSSID=20987_1421_18282_17949_21122_17001_21227_21189_21161_20927
      PSTM=1474900615
      BDSVRTM=0
      BD_HOME=0

      可以看到,这里输出了每条Cookie的名称和值。

      不过既然能输出,那可不可以输出成文件格式呢?我们知道Cookies实际上也是以文本形式保存的。

      答案当然是肯定的,这里通过下面的实例来看看:

      filename = 'cookies.txt'
      cookie = http.cookiejar.MozillaCookieJar(filename)
      handler = urllib.request.HTTPCookieProcessor(cookie)
      opener = urllib.request.build_opener(handler)
      response = opener.open('http://www.baidu.com')
      cookie.save(ignore_discard=True, ignore_expires=True)

      这时CookieJar就需要换成MozillaCookieJar,它在生成文件时会用到,是CookieJar的子类,可以用来处理Cookies和文件相关的事件,比如读取和保存Cookies,可以将Cookies保存成Mozilla型浏览器的Cookies格式。

      运行之后,可以发现生成了一个cookies.txt文件,其内容如下:

      # Netscape HTTP Cookie File
      # http://curl.haxx.se/rfc/cookie_spec.html
      # This is a generated file!  Do not edit.
      
      .baidu.com       TRUE   / FALSE   3622386254    BAIDUID     05AE39B5F56C1DEC474325CDA522D44F:FG=1
      .baidu.com       TRUE   / FALSE   3622386254    BIDUPSID    05AE39B5F56C1DEC474325CDA522D44F
      .baidu.com       TRUE   / FALSE                 H_PS_PSSID  19638_1453_17710_18240_21091_18560_17001_
                                                                  21191_21161
      .baidu.com       TRUE   / FALSE   3622386254    PSTM        1474902606
      www.baidu.com    FALSE  / FALSE                 BDSVRTM     0
      www.baidu.com    FALSE  / FALSE                 BD_HOME     0

      另外,LWPCookieJar同样可以读取和保存Cookies,但是保存的格式和MozillaCookieJar不一样,它会保存成libwww-perl(LWP)格式的Cookies文件。

      要保存成LWP格式的Cookies文件,可以在声明时就改为:

      cookie = http.cookiejar.LWPCookieJar(filename)

      此时生成的内容如下:

      #LWP-Cookies-2.0
      Set-Cookie3: BAIDUID="0CE9C56F598E69DB375B7C294AE5C591:FG=1"; path="/"; domain=".baidu.com"; path_spec;
          domain_dot; expires="2084-10-14 18:25:19Z"; version=0
      Set-Cookie3: BIDUPSID=0CE9C56F598E69DB375B7C294AE5C591; path="/"; domain=".baidu.com"; path_spec; domain_dot;
          expires="2084-10-14 18:25:19Z"; version=0
      Set-Cookie3: H_PS_PSSID=20048_1448_18240_17944_21089_21192_21161_20929; path="/"; domain=".baidu.com";
          path_spec; domain_dot; discard; version=0
      Set-Cookie3: PSTM=1474902671; path="/"; domain=".baidu.com"; path_spec; domain_dot; expires="2084-10-14
          18:25:19Z"; version=0
      Set-Cookie3: BDSVRTM=0; path="/"; domain="www.baidu.com"; path_spec; discard; version=0
      Set-Cookie3: BD_HOME=0; path="/"; domain="www.baidu.com"; path_spec; discard; version=0

      由此看来,生成的格式还是有比较大差异的。

      那么,生成了Cookies文件后,怎样从文件中读取并利用呢?

      下面我们以LWPCookieJar格式为例来看一下:

      cookie = http.cookiejar.LWPCookieJar()
      cookie.load('cookies.txt', ignore_discard=True, ignore_expires=True)
      handler = urllib.request.HTTPCookieProcessor(cookie)
      opener = urllib.request.build_opener(handler)
      response = opener.open('http://www.baidu.com')
      print(response.read().decode('utf-8'))

      可以看到,这里调用load()方法来读取本地的Cookies文件,获取到了Cookies的内容。不过前提是我们首先生成了LWPCookieJar格式的Cookies,并保存成文件,然后读取Cookies之后使用同样的方法构建Handler和Opener即可完成操作。

      运行结果正常的话,会输出百度网页的源代码。

      通过上面的方法,我们可以实现绝大多数请求功能的设置了。

      这便是urllib库中request模块的基本用法,如果想实现更多的功能,可以参考官方文档的说明:https://docs.python.org/3/library/urllib.request.html#basehandler-objects

3.1.2 处理异常

前一节我们了解了请求的发送过程,但是在网络不好的情况下,如果出现了异常,该怎么办呢?这时如果不处理这些异常,程序很可能因报错而终止运行,所以异常处理还是十分有必要的。

urllib的error模块定义了由request模块产生的异常。如果出现了问题,request模块便会抛出error模块中定义的异常。

  1. URLError

    URLError类来自urllib库的error模块,它继承自OSError类,是error异常模块的基类,由request模块产生的异常都可以通过捕获这个类来处理。

    它具有一个属性reason,即返回错误的原因。

    下面用一个实例来看一下:

    from urllib import request, error
    try:
        response = request.urlopen('https://cuiqingcai.com/index.htm')
    except error.URLError as e:
        print(e.reason)

    我们打开一个不存在的页面,照理来说应该会报错,但是这时我们捕获了URLError这个异常,运行结果如下:

    Not Found

    程序没有直接报错,而是输出了如上内容,这样通过如上操作,我们就可以避免程序异常终止,同时异常得到了有效处理。

  2. HTTPError

    它是URLError的子类,专门用来处理HTTP请求错误,比如认证请求失败等。它有如下3个属性。

    • code:返回HTTP状态码,比如404表示网页不存在,500表示服务器内部错误等。
    • reason:同父类一样,用于返回错误的原因。
    • headers:返回请求头。

    下面我们用几个实例来看看:

    from urllib import request,error
    try:
        response = request.urlopen('https://cuiqingcai.com/index.htm')
    except error.HTTPError as e:
        print(e.reason, e.code, e.headers, sep='\n')

    运行结果如下:

    Not Found
    404
    Server: nginx/1.4.6 (Ubuntu)
    Date: Wed, 03 Aug 2016 08:54:22 GMT
    Content-Type: text/html; charset=UTF-8
    Transfer-Encoding: chunked
    Connection: close
    X-Powered-By: PHP/5.5.9-1ubuntu4.14
    Vary: Cookie
    Expires: Wed, 11 Jan 1984 05:00:00 GMT
    Cache-Control: no-cache, must-revalidate, max-age=0
    Pragma: no-cache
    Link: <https://cuiqingcai.com/wp-json/>; rel="https://api.w.org/"

    依然是同样的网址,这里捕获了HTTPError异常,输出了reasoncodeheaders属性。

    因为URLErrorHTTPError的父类,所以可以先选择捕获子类的错误,再去捕获父类的错误,所以上述代码更好的写法如下:

    from urllib import request, error
    
    try:
        response = request.urlopen('https://cuiqingcai.com/index.htm')
    except error.HTTPError as e:
        print(e.reason, e.code, e.headers, sep='\n')
    except error.URLError as e:
        print(e.reason)
    else:
        print('Request Successfully')

    这样就可以做到先捕获HTTPError,获取它的错误状态码、原因、headers等信息。如果不是HTTPError异常,就会捕获URLError异常,输出错误原因。最后,用else来处理正常的逻辑。这是一个较好的异常处理写法。

    有时候,reason属性返回的不一定是字符串,也可能是一个对象。再看下面的实例:

    import socket
    import urllib.request
    import urllib.error
    
    try:
        response = urllib.request.urlopen('https://www.baidu.com', timeout=0.01)
    except urllib.error.URLError as e:
        print(type(e.reason))
        if isinstance(e.reason, socket.timeout):
            print('TIME OUT')

    这里我们直接设置超时时间来强制抛出timeout异常。

    运行结果如下:

    <class 'socket.timeout'>
    TIME OUT

    可以发现,reason属性的结果是socket.timeout类。所以,这里我们可以用isinstance()方法来判断它的类型,作出更详细的异常判断。

    本节中,我们讲述了error模块的相关用法,通过合理地捕获异常可以做出更准确的异常判断,使程序更加稳健。

3.1.3 解析链接

前面说过,urllib库里还提供了parse模块,它定义了处理URL的标准接口,例如实现URL各部分的抽取、合并以及链接转换。它支持如下协议的URL处理:file、ftp、gopher、hdl、http、https、imap、mailto、 mms、news、nntp、prospero、rsync、rtsp、rtspu、sftp、 sip、sips、snews、svn、svn+ssh、telnet和wais。本节中,我们介绍一下该模块中常用的方法来看一下它的便捷之处。

  1. urlparse()

    该方法可以实现URL的识别和分段,这里先用一个实例来看一下:

    from urllib.parse import urlparse
    
    result = urlparse('http://www.baidu.com/index.html;user?id=5#comment')
    print(type(result), result)

    这里我们利用urlparse()方法进行了一个URL的解析。首先,输出了解析结果的类型,然后将结果也输出出来。

    运行结果如下:

    <class 'urllib.parse.ParseResult'>
    ParseResult(scheme='http', netloc='www.baidu.com', path='/index.html', params='user', query='id=5',
        fragment='comment')

    可以看到,返回结果是一个ParseResult类型的对象,它包含6个部分,分别是schemenetlocpathparamsqueryfragment

    观察一下该实例的URL:

    http://www.baidu.com/index.html;user?id=5#comment

    可以发现,urlparse()方法将其拆分成了6个部分。大体观察可以发现,解析时有特定的分隔符。比如,://前面的就是scheme,代表协议;第一个/符号前面便是netloc,即域名,后面是path,即访问路径;分号;后面是params,代表参数;问号?后面是查询条件query,一般用作GET类型的URL;井号#后面是锚点,用于直接定位页面内部的下拉位置。

    所以,可以得出一个标准的链接格式,具体如下:

    scheme://netloc/path;params?query#fragment

    一个标准的URL都会符合这个规则,利用urlparse()方法可以将它拆分开来。

    除了这种最基本的解析方式外,urlparse()方法还有其他配置吗?接下来,看一下它的API用法:

    urllib.parse.urlparse(urlstring, scheme='', allow_fragments=True)

    可以看到,它有3个参数。

    • urlstring:这是必填项,即待解析的URL。
    • scheme:它是默认的协议(比如httphttps等)。假如这个链接没有带协议信息,会将这个作为默认的协议。我们用实例来看一下:

      from urllib.parse import urlparse
      
      result = urlparse('www.baidu.com/index.html;user?id=5#comment', scheme='https')
      print(result)

      运行结果如下:

      ParseResult(scheme='https', netloc='', path='www.baidu.com/index.html', params='user', query='id=5',
          fragment='comment')

      可以发现,我们提供的URL没有包含最前面的scheme信息,但是通过指定默认的scheme参数,返回的结果是https

      假设我们带上了scheme

      result = urlparse('http://www.baidu.com/index.html;user?id=5#comment', scheme='https')

      则结果如下:

      ParseResult(scheme='http', netloc='www.baidu.com', path='/index.html', params='user', query='id=5',
          fragment='comment')

      可见,scheme参数只有在URL中不包含scheme信息时才生效。如果URL中有scheme信息,就会返回解析出的scheme

    • allow_fragments:即是否忽略fragment。如果它被设置为Falsefragment部分就会被忽略,它会被解析为pathparams或者query的一部分,而fragment部分为空。

    下面我们用实例来看一下:

    from urllib.parse import urlparse
    
    result = urlparse('http://www.baidu.com/index.html;user?id=5#comment', allow_fragments=False)
    print(result)

    运行结果如下:

    ParseResult(scheme='http', netloc='www.baidu.com', path='/index.html', params='user', query='id=5#comment',
        fragment='')

    假设URL中不包含paramsquery,我们再通过实例看一下:

    from urllib.parse import urlparse
    
    result = urlparse('http://www.baidu.com/index.html#comment', allow_fragments=False)
    print(result)

    运行结果如下:

    ParseResult(scheme='http', netloc='www.baidu.com', path='/index.html#comment', params='', query='', fragment='')

    可以发现,当URL中不包含paramsquery时,fragment便会被解析为path的一部分。

    返回结果ParseResult实际上是一个元组,我们可以用索引顺序来获取,也可以用属性名获取。示例如下:

    from urllib.parse import urlparse
    
    result = urlparse('http://www.baidu.com/index.html#comment', allow_fragments=False)
    print(result.scheme, result[0], result.netloc, result[1], sep='\n')

    这里我们分别用索引和属性名获取了schemenetloc,其运行结果如下:

    http
    http
    www.baidu.com
    www.baidu.com

    可以发现,二者的结果是一致的,两种方法都可以成功获取。

  2. urlunparse()

    有了urlparse(),相应地就有了它的对立方法urlunparse()。它接受的参数是一个可迭代对象,但是它的长度必须是6,否则会抛出参数数量不足或者过多的问题。先用一个实例看一下:

    from urllib.parse import urlunparse
    
    data = ['http', 'www.baidu.com', 'index.html', 'user', 'a=6', 'comment']
    print(urlunparse(data))

    这里参数data用了列表类型。当然,你也可以用其他类型,比如元组或者特定的数据结构。

    运行结果如下:

    http://www.baidu.com/index.html;user?a=6#comment

    这样我们就成功实现了URL的构造。

  3. urlsplit()

    这个方法和urlparse()方法非常相似,只不过它不再单独解析params这一部分,只返回5个结果。上面例子中的params会合并到path中。示例如下:

    from urllib.parse import urlsplit
    
    result = urlsplit('http://www.baidu.com/index.html;user?id=5#comment')
        print(result)

    运行结果如下:

    SplitResult(scheme='http', netloc='www.baidu.com', path='/index.html;user', query='id=5',
        fragment='comment')

    可以发现,返回结果是SplitResult,它其实也是一个元组类型,既可以用属性获取值,也可以用索引来获取。示例如下:

    from urllib.parse import urlsplit
    
    result = urlsplit('http://www.baidu.com/index.html;user?id=5#comment')
    print(result.scheme, result[0])

    运行结果如下:

    http http
  4. urlunsplit()

    urlunparse()类似,它也是将链接各个部分组合成完整链接的方法,传入的参数也是一个可迭代对象,例如列表、元组等,唯一的区别是长度必须为5。示例如下:

    from urllib.parse import urlunsplit
    
    data = ['http', 'www.baidu.com', 'index.html', 'a=6', 'comment']
    print(urlunsplit(data))

    运行结果如下:

    http://www.baidu.com/index.html?a=6#comment
  5. urljoin()

    有了urlunparse()urlunsplit()方法,我们可以完成链接的合并,不过前提是必须要有特定长度的对象,链接的每一部分都要清晰分开。

    此外,生成链接还有另一个方法,那就是urljoin()方法。我们可以提供一个base_url(基础链接)作为第一个参数,将新的链接作为第二个参数,该方法会分析base_urlschemenetlocpath这3个内容并对新链接缺失的部分进行补充,最后返回结果。

    下面通过几个实例看一下:

    from urllib.parse import urljoin
    
    print(urljoin('http://www.baidu.com', 'FAQ.html'))
    print(urljoin('http://www.baidu.com', 'https://cuiqingcai.com/FAQ.html'))
    print(urljoin('http://www.baidu.com/about.html', 'https://cuiqingcai.com/FAQ.html'))
    print(urljoin('http://www.baidu.com/about.html', 'https://cuiqingcai.com/FAQ.html?question=2'))
    print(urljoin('http://www.baidu.com?wd=abc', 'https://cuiqingcai.com/index.php'))
    print(urljoin('http://www.baidu.com', '?category=2#comment'))
    print(urljoin('www.baidu.com', '?category=2#comment'))
    print(urljoin('www.baidu.com#comment', '?category=2'))

    运行结果如下:

    http://www.baidu.com/FAQ.html
    https://cuiqingcai.com/FAQ.html
    https://cuiqingcai.com/FAQ.html
    https://cuiqingcai.com/FAQ.html?question=2
    https://cuiqingcai.com/index.php
    http://www.baidu.com?category=2#comment
    www.baidu.com?category=2#comment
    www.baidu.com?category=2

    可以发现,base_url提供了三项内容schemenetlocpath。如果这3项在新的链接里不存在,就予以补充;如果新的链接存在,就使用新的链接的部分。而base_url中的paramsqueryfragment是不起作用的。

    通过urljoin()方法,我们可以轻松实现链接的解析、拼合与生成。

  6. urlencode()

    这里我们再介绍一个常用的方法——urlencode(),它在构造GET请求参数的时候非常有用,示例如下:

    from urllib.parse import urlencode
    
    params = {
        'name': 'germey',
        'age': 22
    }
    base_url = 'http://www.baidu.com?'
    url = base_url + urlencode(params)
    print(url)

    这里首先声明了一个字典来将参数表示出来,然后调用urlencode()方法将其序列化为GET请求参数。

    运行结果如下:

    http://www.baidu.com?name=germey&age=22

    可以看到,参数就成功地由字典类型转化为GET请求参数了。

    这个方法非常常用。有时为了更加方便地构造参数,我们会事先用字典来表示。要转化为URL的参数时,只需要调用该方法即可。

  7. parse_qs()

    有了序列化,必然就有反序列化。如果我们有一串GET请求参数,利用parse_qs()方法,就可以将它转回字典,示例如下:

    from urllib.parse import parse_qs
    
    query = 'name=germey&age=22'
    print(parse_qs(query))

    运行结果如下:

    {'name': ['germey'], 'age': ['22']}

    可以看到,这样就成功转回为字典类型了。

  8. parse_qsl()

    另外,还有一个parse_qsl()方法,它用于将参数转化为元组组成的列表,示例如下:

    from urllib.parse import parse_qsl
    
    query = 'name=germey&age=22'
    print(parse_qsl(query))

    运行结果如下:

    [('name', 'germey'), ('age', '22')]

    可以看到,运行结果是一个列表,而列表中的每一个元素都是一个元组,元组的第一个内容是参数名,第二个内容是参数值。

  9. quote()

    该方法可以将内容转化为URL编码的格式。URL中带有中文参数时,有时可能会导致乱码的问题,此时用这个方法可以将中文字符转化为URL编码,示例如下:

    from urllib.parse import quote
    
    keyword = '壁纸'
    url = 'https://www.baidu.com/s?wd=' + quote(keyword)
    print(url)

    这里我们声明了一个中文的搜索文字,然后用quote()方法对其进行URL编码,最后得到的结果如下:

    https://www.baidu.com/s?wd=%E5%A3%81%E7%BA%B8
  10. unquote()

    有了quote()方法,当然还有unquote()方法,它可以进行URL解码,示例如下:

    from urllib.parse import unquote
    
    url = 'https://www.baidu.com/s?wd=%E5%A3%81%E7%BA%B8'
    print(unquote(url))

    这是上面得到的URL编码后的结果,这里利用unquote()方法还原,结果如下:

    https://www.baidu.com/s?wd=壁纸

    可以看到,利用unquote()方法可以方便地实现解码。

    本节中,我们介绍了parse模块的一些常用URL处理方法。有了这些方法,我们可以方便地实现URL的解析和构造,建议熟练掌握。

3.1.4 分析Robots协议

利用urllib的robotparser模块,我们可以实现网站Robots协议的分析。本节中,我们来简单了解一下该模块的用法。

  1. Robots协议

    Robots协议也称作爬虫协议、机器人协议,它的全名叫作网络爬虫排除标准(Robots Exclusion Protocol),用来告诉爬虫和搜索引擎哪些页面可以抓取,哪些不可以抓取。它通常是一个叫作robots.txt的文本文件,一般放在网站的根目录下。

    当搜索爬虫访问一个站点时,它首先会检查这个站点根目录下是否存在robots.txt文件,如果存在,搜索爬虫会根据其中定义的爬取范围来爬取。如果没有找到这个文件,搜索爬虫便会访问所有可直接访问的页面。

    下面我们看一个robots.txt的样例:

    User-agent: *
    Disallow: /
    Allow: /public/

    这实现了对所有搜索爬虫只允许爬取public目录的功能,将上述内容保存成robots.txt文件,放在网站的根目录下,和网站的入口文件(比如index.php、index.html和index.jsp等)放在一起。

    上面的User-agent描述了搜索爬虫的名称,这里将其设置为*则代表该协议对任何爬取爬虫有效。比如,我们可以设置:

    User-agent: Baiduspider

    这就代表我们设置的规则对百度爬虫是有效的。如果有多条User-agent记录,则就会有多个爬虫会受到爬取限制,但至少需要指定一条。

    Disallow指定了不允许抓取的目录,比如上例子中设置为/则代表不允许抓取所有页面。

    Allow一般和Disallow一起使用,一般不会单独使用,用来排除某些限制。现在我们设置为/public/,则表示所有页面不允许抓取,但可以抓取public目录。

    下面我们再来看几个例子。禁止所有爬虫访问任何目录的代码如下:

    User-agent: *
    Disallow: /

    允许所有爬虫访问任何目录的代码如下:

    User-agent: *
    Disallow:

    另外,直接把robots.txt文件留空也是可以的。

    禁止所有爬虫访问网站某些目录的代码如下:

    User-agent: *
    Disallow: /private/
    Disallow: /tmp/

    只允许某一个爬虫访问的代码如下:

    User-agent: WebCrawler
    Disallow:
    User-agent: *
    Disallow: /

    这些是robots.txt的一些常见写法。

  2. 爬虫名称

    大家可能会疑惑,爬虫名是哪儿来的?为什么就叫这个名?其实它是有固定名字的了,比如百度的就叫作BaiduSpider。表3-1列出了一些常见的搜索爬虫的名称及对应的网站。

    表3-1 一些常见搜索爬虫的名称及其对应的网站

    爬虫名称名称网站
    BaiduSpider百度[www.baidu.com](http:\\www.baidu.com)
    Googlebot谷歌[www.google.com](http:\\www.google.com)
    360Spider360搜索[www.so.com](http:\\www.so.com)
    YodaoBot有道[www.youdao.com](http:\\www.youdao.com)
    ia_archiverAlexa[www.alexa.cn](http:\\www.alexa.com)
    Scooteraltavista[www.altavista.com](http:\\www.altavista.com)
  3. robotparser

    了解Robots协议之后,我们就可以使用robotparser模块来解析robots.txt了。该模块提供了一个类RobotFileParser,它可以根据某网站的robots.txt文件来判断一个爬取爬虫是否有权限来爬取这个网页。

    该类用起来非常简单,只需要在构造方法里传入robots.txt的链接即可。首先看一下它的声明:

    urllib.robotparser.RobotFileParser(url='')

    当然,也可以在声明时不传入,默认为空,最后再使用set_url()方法设置一下也可。

    下面列出了这个类常用的几个方法。

    • set_url():用来设置robots.txt文件的链接。如果在创建RobotFileParser对象时传入了链接,那么就不需要再使用这个方法设置了。
    • read():读取robots.txt文件并进行分析。注意,这个方法执行一个读取和分析操作,如果不调用这个方法,接下来的判断都会为False,所以一定记得调用这个方法。这个方法不会返回任何内容,但是执行了读取操作。
    • parse():用来解析robots.txt文件,传入的参数是robots.txt某些行的内容,它会按照robots.txt的语法规则来分析这些内容。
    • can_fetch():该方法传入两个参数,第一个是User-agent,第二个是要抓取的URL。返回的内容是该搜索引擎是否可以抓取这个URL,返回结果是TrueFalse
    • mtime():返回的是上次抓取和分析robots.txt的时间,这对于长时间分析和抓取的搜索爬虫是很有必要的,你可能需要定期检查来抓取最新的robots.txt。
    • modified():它同样对长时间分析和抓取的搜索爬虫很有帮助,将当前时间设置为上次抓取和分析robots.txt的时间。

    下面我们用实例来看一下:

    from urllib.robotparser import RobotFileParser
    
    rp = RobotFileParser()
    rp.set_url('http://www.jianshu.com/robots.txt')
    rp.read()
    print(rp.can_fetch('*', 'http://www.jianshu.com/p/b67554025d7d'))
    print(rp.can_fetch('*', "http://www.jianshu.com/search?q=python&page=1&type=collections"))

    这里以简书为例,首先创建RobotFileParser对象,然后通过set_url()方法设置了robots.txt的链接。当然,不用这个方法的话,可以在声明时直接用如下方法设置:

    rp = RobotFileParser('http://www.jianshu.com/robots.txt')

    接着利用can_fetch()方法判断了网页是否可以被抓取。

    运行结果如下:

    True
    False

    这里同样可以使用parse()方法执行读取和分析,示例如下:

    from urllib.robotparser import RobotFileParser
    from urllib.request import urlopen
    
    rp = RobotFileParser()
    rp.parse(urlopen('http://www.jianshu.com/robots.txt').read().decode('utf-8').split('\n'))
    print(rp.can_fetch('*', 'http://www.jianshu.com/p/b67554025d7d'))
    print(rp.can_fetch('*', "http://www.jianshu.com/search?q=python&page=1&type=collections"))

    运行结果一样:

    True
    False

    本节介绍了robotparser模块的基本用法和实例,利用它,我们可以方便地判断哪些页面可以抓取,哪些页面不可以抓取。

3.2 使用requests

上一节中,我们了解了urllib的基本用法,但是其中确实有不方便的地方,比如处理网页验证和Cookies时,需要写OpenerHandler来处理。为了更加方便地实现这些操作,就有了更为强大的库requests,有了它,Cookies、登录验证、代理设置等操作都不是事儿。

接下来,让我们领略一下它的强大之处吧。

3.2.1 基本用法

  1. 准备工作

    在开始之前,请确保已经正确安装好了requests库。如果没有安装,可以参考1.2.1节安装。

  2. 实例引入

    urllib库中的urlopen()方法实际上是以GET方式请求网页,而requests中相应的方法就是get()方法,是不是感觉表达更明确一些?下面通过实例来看一下:

    import requests
    
    r = requests.get('https://www.baidu.com/')
    print(type(r))
    print(r.status_code)
    print(type(r.text))
    print(r.text)
    print(r.cookies)

    运行结果如下:

    <class 'requests.models.Response'>
    200
    <class 'str'>
    <html>
    <head>
    <script>
            location.replace(location.href.replace("https://","http://"));
    </script>
    </head>
    <body>
    <noscript><meta http-equiv="refresh" content="0;url=http://www.baidu.com/"></noscript>
    </body>
    </html>
    <RequestsCookieJar[<Cookie BIDUPSID=992C3B26F4C4D09505C5E959D5FBC005 for .baidu.com/>, <Cookie
        PSTM=1472227535 for .baidu.com/>, <Cookie __bsi=15304754498609545148_00_40_N_N_2_0303_C02F_N_N_N_0
        for .www.baidu.com/>, <Cookie BD_NOT_HTTPS=1 for www.baidu.com/>]>

    这里我们调用get()方法实现与urlopen()相同的操作,得到一个Response对象,然后分别输出了Response的类型、状态码、响应体的类型、内容以及Cookies。

    通过运行结果可以发现,它的返回类型是requests.models.Response,响应体的类型是字符串str,Cookies的类型是RequestsCookieJar

    使用get()方法成功实现一个GET请求,这倒不算什么,更方便之处在于其他的请求类型依然可以用一句话来完成,示例如下:

    r = requests.post('http://httpbin.org/post')
    r = requests.put('http://httpbin.org/put')
    r = requests.delete('http://httpbin.org/delete')
    r = requests.head('http://httpbin.org/get')
    r = requests.options('http://httpbin.org/get')

    这里分别用post()put()delete()等方法实现了POST、PUT、DELETE等请求。是不是比urllib简单太多了?

    其实这只是冰山一角,更多的还在后面。

  3. GET请求

    HTTP中最常见的请求之一就是GET请求,下面首先来详细了解一下利用requests构建GET请求的方法。

    • 基本实例

      首先,构建一个最简单的GET请求,请求的链接为http://httpbin.org/get,该网站会判断如果客户端发起的是GET请求的话,它返回相应的请求信息:

      import requests
      
      r = requests.get('http://httpbin.org/get')
      print(r.text)

      运行结果如下:

      {
        "args": {},
        "headers": {
          "Accept": "*/*",
          "Accept-Encoding": "gzip, deflate",
          "Host": "httpbin.org",
          "User-Agent": "python-requests/2.10.0"
        },
        "origin": "122.4.215.33",
        "url": "http://httpbin.org/get"
      }

      可以发现,我们成功发起了GET请求,返回结果中包含请求头、URL、IP等信息。

      那么,对于GET请求,如果要附加额外的信息,一般怎样添加呢?比如现在想添加两个参数,其中namegermeyage是22。要构造这个请求链接,是不是要直接写成:

      r = requests.get('http://httpbin.org/get?name=germey&age=22')

      这样也可以,但是是不是有点不人性化呢?一般情况下,这种信息数据会用字典来存储。那么,怎样来构造这个链接呢?

      这同样很简单,利用params这个参数就好了,示例如下:

      import requests
      
      data = {
          'name': 'germey',
          'age': 22
      }
      r = requests.get("http://httpbin.org/get", params=data)
      print(r.text)

      运行结果如下:

      {
        "args": {
          "age": "22",
          "name": "germey"
        },
        "headers": {
          "Accept": "*/*",
          "Accept-Encoding": "gzip, deflate",
          "Host": "httpbin.org",
          "User-Agent": "python-requests/2.10.0"
        },
        "origin": "122.4.215.33",
        "url": "http://httpbin.org/get?age=22&name=germey"
      }

      通过运行结果可以判断,请求的链接自动被构造成了:http://httpbin.org/get?age=22&name=germey。

      另外,网页的返回类型实际上是str类型,但是它很特殊,是JSON格式的。所以,如果想直接解析返回结果,得到一个字典格式的话,可以直接调用json()方法。示例如下:

      import requests
      
      r = requests.get("http://httpbin.org/get")
      print(type(r.text))
      print(r.json())
      print(type(r.json()))

      运行结果如下:

      <class 'str'>
      {'headers': {'Accept-Encoding': 'gzip, deflate', 'Accept': '*/*', 'Host': 'httpbin.org', 'User-Agent':
          'python-requests/2.10.0'}, 'url': 'http://httpbin.org/get', 'args': {}, 'origin': '182.33.248.131'}
      <class 'dict'>

      可以发现,调用json()方法,就可以将返回结果是JSON格式的字符串转化为字典。

      但需要注意的是,如果返回结果不是JSON格式,便会出现解析错误,抛出json.decoder.JSONDecodeError异常。

    • 抓取网页

      上面的请求链接返回的是JSON形式的字符串,那么如果请求普通的网页,则肯定能获得相应的内容了。下面以“知乎”→“发现”页面为例来看一下:

      import requests
      import re
      
      headers = {
          'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_4) AppleWebKit/537.36 (KHTML, like Gecko)
              Chrome/52.0.2743.116 Safari/537.36'
      }
      r = requests.get("https://www.zhihu.com/explore", headers=headers)
      pattern = re.compile('explore-feed.*?question_link.*?>(.*?)</a>', re.S)
      titles = re.findall(pattern, r.text)
      print(titles)

      这里我们加入了headers信息,其中包含了User-Agent字段信息,也就是浏览器标识信息。如果不加这个,知乎会禁止抓取。

      接下来我们用到了最基础的正则表达式来匹配出所有的问题内容。关于正则表达式的相关内容,我们会在3.3节中详细介绍,这里作为实例来配合讲解。

      运行结果如下:

      ['\n为什么很多人喜欢提及「拉丁语系」这个词?\n', '\n在没有水的情况下水系宝可梦如何战斗?\n', '\n有哪些经
          验可以送给 Kindle 新人?\n', '\n谷歌的广告业务是如何赚钱的?\n', '\n程序员该学习什么,能在上学期间挣
          钱?\n', '\n有哪些原本只是一个小消息,但回看发现是个惊天大新闻的例子?\n', '\n如何评价今敏?\n', '\n
          源氏是怎么把那么长的刀从背后拔出来的?\n', '\n年轻时得了绝症或大病是怎样的感受?\n', '\n年轻时得了绝
          症或大病是怎样的感受?\n']

      我们发现,这里成功提取出了所有的问题内容。

    • 抓取二进制数据

      在上面的例子中,我们抓取的是知乎的一个页面,实际上它返回的是一个HTML文档。如果想抓取图片、音频、视频等文件,应该怎么办呢?

      图片、音频、视频这些文件本质上都是由二进制码组成的,由于有特定的保存格式和对应的解析方式,我们才可以看到这些形形色色的多媒体。所以,想要抓取它们,就要拿到它们的二进制码。

      下面以GitHub的站点图标为例来看一下:

      import requests
      
      r = requests.get("https://github.com/favicon.ico")
      print(r.text)
      print(r.content)

      这里抓取的内容是站点图标,也就是在浏览器每一个标签上显示的小图标,如图3-3所示。

      {%}

      图3-3 站点图标

      这里打印了Response对象的两个属性,一个是text,另一个是content

      运行结果如图3-4所示,其中前两行是r.text的结果,最后一行是r.content的结果。

      {%}

      图3-4 运行结果

      可以注意到,前者出现了乱码,后者结果前带有一个b,这代表是bytes类型的数据。由于图片是二进制数据,所以前者在打印时转化为str类型,也就是图片直接转化为字符串,这理所当然会出现乱码。

      接着,我们将刚才提取到的图片保存下来:

      import requests
      
      r = requests.get("https://github.com/favicon.ico")
      with open('favicon.ico', 'wb') as f:
          f.write(r.content)

      这里用了open()方法,它的第一个参数是文件名称,第二个参数代表以二进制写的形式打开,可以向文件里写入二进制数据。

      运行结束之后,可以发现在文件夹中出现了名为favicon.ico的图标,如图3-5所示。

      {%}

      图3-5 图标

      同样地,音频和视频文件也可以用这种方法获取。

    • 添加headers

      urllib.request一样,我们也可以通过headers参数来传递头信息。

      比如,在上面“知乎”的例子中,如果不传递headers,就不能正常请求:

      import requests
      
      r = requests.get("https://www.zhihu.com/explore")
      print(r.text)

      运行结果如下:

      <html><body><h1>500 Server Error</h1>
      An internal server error occured.
      </body></html>

      但如果加上headers并加上User-Agent信息,那就没问题了:

      import requests
      
      headers = {
          'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_4) AppleWebKit/537.36 (KHTML, like Gecko)
              Chrome/52.0.2743.116 Safari/537.36'
      }
      r = requests.get("https://www.zhihu.com/explore", headers=headers)
      print(r.text)

      当然,我们可以在headers这个参数中任意添加其他的字段信息。

  4. POST请求

    前面我们了解了最基本的GET请求,另外一种比较常见的请求方式是POST。使用requests实现POST请求同样非常简单,示例如下:

    import requests
    
    data = {'name': 'germey', 'age': '22'}
    r = requests.post("http://httpbin.org/post", data=data)
    print(r.text)

    这里还是请求http://httpbin.org/post,该网站可以判断如果请求是POST方式,就把相关请求信息返回。

    运行结果如下:

    {
      "args": {},
      "data": "",
      "files": {},
      "form": {
        "age": "22",
        "name": "germey"
      },
      "headers": {
        "Accept": "*/*",
        "Accept-Encoding": "gzip, deflate",
        "Content-Length": "18",
        "Content-Type": "application/x-www-form-urlencoded",
        "Host": "httpbin.org",
        "User-Agent": "python-requests/2.10.0"
      },
      "json": null,
      "origin": "182.33.248.131",
      "url": "http://httpbin.org/post"
    }

    可以发现,我们成功获得了返回结果,其中form部分就是提交的数据,这就证明POST请求成功发送了。

  5. 响应

    发送请求后,得到的自然就是响应。在上面的实例中,我们使用textcontent获取了响应的内容。此外,还有很多属性和方法可以用来获取其他信息,比如状态码、响应头、Cookies等。示例如下:

    import requests
    
    r = requests.get('http://www.jianshu.com')
    print(type(r.status_code), r.status_code)
    print(type(r.headers), r.headers)
    print(type(r.cookies), r.cookies)
    print(type(r.url), r.url)
    print(type(r.history), r.history)

    这里分别打印输出status_code属性得到状态码,输出headers属性得到响应头,输出cookies属性得到Cookies,输出url属性得到URL,输出history属性得到请求历史。

    运行结果如下:

    <class 'int'> 200
    <class 'requests.structures.CaseInsensitiveDict'> {'X-Runtime': '0.006363', 'Connection': 'keep-alive',
        'Content-Type': 'text/html; charset=utf-8', 'X-Content-Type-Options': 'nosniff', 'Date': 'Sat, 27 Aug
        2016 17:18:51 GMT', 'Server': 'nginx', 'X-Frame-Options': 'DENY', 'Content-Encoding': 'gzip', 'Vary':
        'Accept-Encoding', 'ETag': 'W/"3abda885e0e123bfde06d9b61e696159"', 'X-XSS-Protection': '1; mode=block',
        'X-Request-Id': 'a8a3c4d5-f660-422f-8df9-49719dd9b5d4', 'Transfer-Encoding': 'chunked', 'Set-Cookie':
        'read_mode=day; path=/, default_font=font2; path=/, _session_id=xxx; path=/; HttpOnly', 'Cache-Control':
        'max-age=0, private, must-revalidate'}
        <class 'requests.cookies.RequestsCookieJar'><RequestsCookieJar[<Cookie _session_id=xxx for
        www.jianshu.com/>, <Cookie default_font=font2 for www.jianshu.com/>, <Cookie read_mode=day for
        www.jianshu.com/>]>
    <class 'str'> http://www.jianshu.com/
    <class 'list'> []

    因为session_id过长,在此简写。可以看到,headerscookies这两个属性得到的结果分别是CaseInsensitiveDictRequestsCookieJar类型。

    状态码常用来判断请求是否成功,而requests还提供了一个内置的状态码查询对象requests.codes,示例如下:

    import requests
    
    r = requests.get('http://www.jianshu.com')
    exit() if not r.status_code == requests.codes.ok else print('Request Successfully')

    这里通过比较返回码和内置的成功的返回码,来保证请求得到了正常响应,输出成功请求的消息,否则程序终止,这里我们用requests.codes.ok得到的是成功的状态码200。

    那么,肯定不能只有ok这个条件码。下面列出了返回码和相应的查询条件:

    # 信息性状态码
    100: ('continue',),
    101: ('switching_protocols',),
    102: ('processing',),
    103: ('checkpoint',),
    122: ('uri_too_long', 'request_uri_too_long'),
    
    # 成功状态码
    200: ('ok', 'okay', 'all_ok', 'all_okay', 'all_good', '\\o/', '?'),
    201: ('created',),
    202: ('accepted',),
    203: ('non_authoritative_info', 'non_authoritative_information'),
    204: ('no_content',),
    205: ('reset_content', 'reset'),
    206: ('partial_content', 'partial'),
    207: ('multi_status', 'multiple_status', 'multi_stati', 'multiple_stati'),
    208: ('already_reported',),
    226: ('im_used',),
    
    # 重定向状态码
    300: ('multiple_choices',),
    301: ('moved_permanently', 'moved', '\\o-'),
    302: ('found',),
    303: ('see_other', 'other'),
    304: ('not_modified',),
    305: ('use_proxy',),
    306: ('switch_proxy',),
    307: ('temporary_redirect', 'temporary_moved', 'temporary'),
    308: ('permanent_redirect',
          'resume_incomplete', 'resume',), # These 2 to be removed in 3.0
    
    # 客户端错误状态码
    400: ('bad_request', 'bad'),
    401: ('unauthorized',),
    402: ('payment_required', 'payment'),
    403: ('forbidden',),
    404: ('not_found', '-o-'),
    405: ('method_not_allowed', 'not_allowed'),
    406: ('not_acceptable',),
    407: ('proxy_authentication_required', 'proxy_auth', 'proxy_authentication'),
    408: ('request_timeout', 'timeout'),
    409: ('conflict',),
    410: ('gone',),
    411: ('length_required',),
    412: ('precondition_failed', 'precondition'),
    413: ('request_entity_too_large',),
    414: ('request_uri_too_large',),
    415: ('unsupported_media_type', 'unsupported_media', 'media_type'),
    416: ('requested_range_not_satisfiable', 'requested_range', 'range_not_satisfiable'),
    417: ('expectation_failed',),
    418: ('im_a_teapot', 'teapot', 'i_am_a_teapot'),
    421: ('misdirected_request',),
    422: ('unprocessable_entity', 'unprocessable'),
    423: ('locked',),
    424: ('failed_dependency', 'dependency'),
    425: ('unordered_collection', 'unordered'),
    426: ('upgrade_required', 'upgrade'),
    428: ('precondition_required', 'precondition'),
    429: ('too_many_requests', 'too_many'),
    431: ('header_fields_too_large', 'fields_too_large'),
    444: ('no_response', 'none'),
    449: ('retry_with', 'retry'),
    450: ('blocked_by_windows_parental_controls', 'parental_controls'),
    451: ('unavailable_for_legal_reasons', 'legal_reasons'),
    499: ('client_closed_request',),
    
    # 服务端错误状态码
    500: ('internal_server_error', 'server_error', '/o\\', '?'),
    501: ('not_implemented',),
    502: ('bad_gateway',),
    503: ('service_unavailable', 'unavailable'),
    504: ('gateway_timeout',),
    505: ('http_version_not_supported', 'http_version'),
    506: ('variant_also_negotiates',),
    507: ('insufficient_storage',),
    509: ('bandwidth_limit_exceeded', 'bandwidth'),
    510: ('not_extended',),
    511: ('network_authentication_required', 'network_auth', 'network_authentication')

    比如,如果想判断结果是不是404状态,可以用requests.codes.not_found来比对。

3.2.2 高级用法

在前一节中,我们了解了requests的基本用法,如基本的GET、POST请求以及Response对象。本节中,我们再来了解下requests的一些高级用法,如文件上传、Cookies设置、代理设置等。

  1. 文件上传

    我们知道requests可以模拟提交一些数据。假如有的网站需要上传文件,我们也可以用它来实现,这非常简单,示例如下:

    import requests
    
    files = {'file': open('favicon.ico', 'rb')}
    r = requests.post("http://httpbin.org/post", files=files)
    print(r.text)

    在前一节中我们保存了一个文件favicon.ico,这次用它来模拟文件上传的过程。需要注意的是,favicon.ico需要和当前脚本在同一目录下。如果有其他文件,当然也可以使用其他文件来上传,更改下代码即可。

    运行结果如下:

    {
      "args": {},
      "data": "",
      "files": {
        "file": "data:application/octet-stream;base64,AAAAAA...="
      },
      "form": {},
      "headers": {
        "Accept": "*/*",
        "Accept-Encoding": "gzip, deflate",
        "Content-Length": "6665",
        "Content-Type": "multipart/form-data; boundary=809f80b1a2974132b133ade1a8e8e058",
        "Host": "httpbin.org",
        "User-Agent": "python-requests/2.10.0"
      },
      "json": null,
      "origin": "60.207.237.16",
      "url": "http://httpbin.org/post"
    }

    以上省略部分内容,这个网站会返回响应,里面包含files这个字段,而form字段是空的,这证明文件上传部分会单独有一个files字段来标识。

  2. Cookies

    前面我们使用urllib处理过Cookies,写法比较复杂,而有了requests,获取和设置Cookies只需一步即可完成。

    我们先用一个实例看一下获取Cookies的过程:

    import requests
    
    r = requests.get("https://www.baidu.com")
    print(r.cookies)
    for key, value in r.cookies.items():
        print(key + '=' + value)

    运行结果如下:

    <RequestsCookieJar[<Cookie BDORZ=27315 for .baidu.com/>, <Cookie __bsi=13533594356813414194_00_14_N_N_2_0303_C02F_N_N_N_0 for .www.baidu.com/>]>
    BDORZ=27315
    __bsi=13533594356813414194_00_14_N_N_2_0303_C02F_N_N_N_0

    这里我们首先调用cookies属性即可成功得到Cookies,可以发现它是RequestCookieJar类型。然后用items()方法将其转化为元组组成的列表,遍历输出每一个Cookie的名称和值,实现Cookie的遍历解析。

    当然,我们也可以直接用Cookie来维持登录状态,下面以知乎为例来说明。首先登录知乎,将Headers中的Cookie内容复制下来,如图3-6所示。

    {%}

    图3-6 Cookie

    这里可以替换成你自己的Cookie,将其设置到Headers里面,然后发送请求,示例如下:

    import requests
    
    headers = {
        'Cookie': 'q_c1=31653b264a074fc9a57816d1ea93ed8b|1474273938000|1474273938000;
            d_c0="AGDAs254kAqPTr6NW1U3XTLFzKhMPQ6H_nc=|1474273938";
            __utmv=51854390.100-1|2=registration_date=20130902=1^3=entry_date=20130902=1;a_t=
            "2.0AACAfbwdAAAXAAAAso0QWAAAgH28HQAAAGDAs254kAoXAAAAYQJVTQ4FCVgA360us8BAklzLYNEHUd6kmHtRQX5a6hi
            ZxKCynnycerLQ3gIkoJLOCQ==";z_c0=Mi4wQUFDQWZid2RBQUFBWU1DemJuaVFDaGNBQUFCaEFsVk5EZ1VKV0FEZnJTNnp3
            RUNTWE10ZzBRZFIzcVNZZTFGQmZn|1474887858|64b4d4234a21de774c42c837fe0b672fdb5763b0',
        'Host': 'www.zhihu.com',
        'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_4) AppleWebKit/537.36 (KHTML, like Gecko)
            Chrome/53.0.2785.116 Safari/537.36',
    }
    r = requests.get('https://www.zhihu.com', headers=headers)
    print(r.text)

    我们发现,结果中包含了登录后的结果,如图3-7所示,这证明登录成功。

    图3-7 运行结果

    当然,你也可以通过cookies参数来设置,不过这样就需要构造RequestsCookieJar对象,而且需要分割一下cookies。这相对烦琐,不过效果是相同的,示例如下:

    import requests
    
    cookies = 'q_c1=31653b264a074fc9a57816d1ea93ed8b|1474273938000|1474273938000; d_c0="AGDAs254kAqPTr6NW1U3XTLFzKhMPQ6H_nc=|1474273938"; __utmv=51854390.100-1|2=registration_date=20130902=1^3=entry_date=20130902=1;a_t="2.0AACAfbwdAAAXAAAAso0
        QWAAAgH28HQAAAGDAs254kAoXAAAAYQJVTQ4FCVgA360us8BAklzLYNEHUd6kmHtRQX5a6hiZxKCynnycerLQ3gIkoJLOCQ==";
        z_c0=Mi4wQUFDQWZid2RBQUFBWU1DemJuaVFDaGNBQUFCaEFsVk5EZ1VKV0FEZnJTNnp3RUNTWE10ZzBRZFIzcVNZZTFGQmZn|
        1474887858|64b4d4234a21de774c42c837fe0b672fdb5763b0'
    jar = requests.cookies.RequestsCookieJar()
    headers = {
        'Host': 'www.zhihu.com',
        'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_4) AppleWebKit/537.36 (KHTML, like Gecko)
            Chrome/53.0.2785.116 Safari/537.36'
    }
    for cookie in cookies.split(';'):
        key, value = cookie.split('=', 1)
        jar.set(key, value)
    r = requests.get("http://www.zhihu.com", cookies=jar, headers=headers)
    print(r.text)

    这里我们首先新建了一个RequestCookieJar对象,然后将复制下来的cookies利用split()方法分割,接着利用set()方法设置好每个Cookie的keyvalue,然后通过调用requests的get()方法并传递给cookies参数即可。当然,由于知乎本身的限制,headers参数也不能少,只不过不需要在原来的headers参数里面设置cookie字段了。

    测试后,发现同样可以正常登录知乎。

  3. 会话维持

    在requests中,如果直接利用get()post()等方法的确可以做到模拟网页的请求,但是这实际上是相当于不同的会话,也就是说相当于你用了两个浏览器打开了不同的页面。

    设想这样一个场景,第一个请求利用post()方法登录了某个网站,第二次想获取成功登录后的自己的个人信息,你又用了一次get()方法去请求个人信息页面。实际上,这相当于打开了两个浏览器,是两个完全不相关的会话,能成功获取个人信息吗?那当然不能。

    有小伙伴可能说了,我在两次请求时设置一样的cookies不就行了?可以,但这样做起来显得很烦琐,我们有更简单的解决方法。

    其实解决这个问题的主要方法就是维持同一个会话,也就是相当于打开一个新的浏览器选项卡而不是新开一个浏览器。但是我又不想每次设置cookies,那该怎么办呢?这时候就有了新的利器——Session对象。

    利用它,我们可以方便地维护一个会话,而且不用担心cookies的问题,它会帮我们自动处理好。示例如下:

    import requests
    
    requests.get('http://httpbin.org/cookies/set/number/123456789')
    r = requests.get('http://httpbin.org/cookies')
    print(r.text)

    这里我们请求了一个测试网址http://httpbin.org/cookies/set/number/123456789。请求这个网址时,可以设置一个cookie,名称叫作number,内容是123456789,随后又请求了http://httpbin.org/cookies,此网址可以获取当前的Cookies。

    这样能成功获取到设置的Cookies吗?试试看。

    运行结果如下:

    {
      "cookies": {}
    }

    这并不行。我们再用Session试试看:

    import requests
    
    s = requests.Session()
    s.get('http://httpbin.org/cookies/set/number/123456789')
    r = s.get('http://httpbin.org/cookies')
    print(r.text)

    再看下运行结果:

    {
      "cookies": {
        "number": "123456789"
      }
    }

    成功获取!这下能体会到同一个会话和不同会话的区别了吧!

    所以,利用Session,可以做到模拟同一个会话而不用担心Cookies的问题。它通常用于模拟登录成功之后再进行下一步的操作。

    Session在平常用得非常广泛,可以用于模拟在一个浏览器中打开同一站点的不同页面,后面会有专门的章节来讲解这部分内容。

  4. SSL证书验证

    此外,requests还提供了证书验证的功能。当发送HTTP请求的时候,它会检查SSL证书,我们可以使用verify参数控制是否检查此证书。其实如果不加verify参数的话,默认是True,会自动验证。

    前面我们提到过,12306的证书没有被官方CA机构信任,会出现证书验证错误的结果。我们现在访问它,都可以看到一个证书问题的页面,如图3-8所示。

    图3-8 错误页面

    现在我们用requests来测试一下:

    import requests
    
    response = requests.get('https://www.12306.cn')
    print(response.status_code)

    运行结果如下:

    requests.exceptions.SSLError: ("bad handshake: Error([('SSL routines', 'tls_process_server_certificate',
        'certificate verify failed')],)",)

    这里提示一个错误SSLError,表示证书验证错误。所以,如果请求一个HTTPS站点,但是证书验证错误的页面时,就会报这样的错误,那么如何避免这个错误呢?很简单,把verify参数设置为False即可。相关代码如下:

    import requests
    
    response = requests.get('https://www.12306.cn', verify=False)
    print(response.status_code)

    这样就会打印出请求成功的状态码:

    /usr/local/lib/python3.6/site-packages/urllib3/connectionpool.py:852: InsecureRequestWarning: Unverified
        HTTPS request is being made. Adding certificate verification is strongly advised. See:
    https://urllib3.readthedocs.io/en/latest/advanced-usage.html#ssl-warnings
      InsecureRequestWarning)
    200

    不过我们发现报了一个警告,它建议我们给它指定证书。我们可以通过设置忽略警告的方式来屏蔽这个警告:

    import requests
    from requests.packages import urllib3
    
    urllib3.disable_warnings()
    response = requests.get('https://www.12306.cn', verify=False)
    print(response.status_code)

    或者通过捕获警告到日志的方式忽略警告:

    import logging
    import requests
    logging.captureWarnings(True)
    response = requests.get('https://www.12306.cn', verify=False)
    print(response.status_code)

    当然,我们也可以指定一个本地证书用作客户端证书,这可以是单个文件(包含密钥和证书)或一个包含两个文件路径的元组:

    import requests
    
    response = requests.get('https://www.12306.cn', cert=('/path/server.crt', '/path/key'))
    print(response.status_code)

    当然,上面的代码是演示实例,我们需要有crt和key文件,并且指定它们的路径。注意,本地私有证书的key必须是解密状态,加密状态的key是不支持的。

  5. 代理设置

    对于某些网站,在测试的时候请求几次,能正常获取内容。但是一旦开始大规模爬取,对于大规模且频繁的请求,网站可能会弹出验证码,或者跳转到登录验证页面,更甚者可能会直接封禁客户端的IP,导致一定时间段内无法访问。

    那么,为了防止这种情况发生,我们需要设置代理来解决这个问题,这就需要用到proxies参数。可以用这样的方式设置:

    import requests
    
    proxies = {
      "http": "http://10.10.1.10:3128",
      "https": "http://10.10.1.10:1080",
    }
    
    requests.get("https://www.taobao.com", proxies=proxies)

    当然,直接运行这个实例可能不行,因为这个代理可能是无效的,请换成自己的有效代理试验一下。

    若代理需要使用HTTP Basic Auth,可以使用类似http://user:password@host:port这样的语法来设置代理,示例如下:

    import requests
    
    proxies = {
        "http": "http://user:password@10.10.1.10:3128/",
    }
    requests.get("https://www.taobao.com", proxies=proxies)

    除了基本的HTTP代理外,requests还支持SOCKS协议的代理。

    首先,需要安装socks这个库:

    pip3 install 'requests[socks]'

    然后就可以使用SOCKS协议代理了,示例如下:

    import requests
    
    proxies = {
        'http': 'socks5://user:password@host:port',
        'https': 'socks5://user:password@host:port'
    }
    requests.get("https://www.taobao.com", proxies=proxies)
  6. 超时设置

    在本机网络状况不好或者服务器网络响应太慢甚至无响应时,我们可能会等待特别久的时间才可能收到响应,甚至到最后收不到响应而报错。为了防止服务器不能及时响应,应该设置一个超时时间,即超过了这个时间还没有得到响应,那就报错。这需要用到timeout参数。这个时间的计算是发出请求到服务器返回响应的时间。示例如下:

    import requests
    
    r = requests.get("https://www.taobao.com", timeout = 1)
    print(r.status_code)

    通过这样的方式,我们可以将超时时间设置为1秒,如果1秒内没有响应,那就抛出异常。

    实际上,请求分为两个阶段,即连接(connect)和读取(read)。

    上面设置的timeout将用作连接和读取这二者的timeout总和。

    如果要分别指定,就可以传入一个元组:

    r = requests.get("https://www.taobao.com", timeout=(5, 30))

    如果想永久等待,可以直接将timeout设置为None,或者不设置直接留空,因为默认是None。这样的话,如果服务器还在运行,但是响应特别慢,那就慢慢等吧,它永远不会返回超时错误的。其用法如下:

    r = requests.get('https://www.taobao.com', timeout=None)

    或直接不加参数:

    r = requests.get('https://www.taobao.com')
  7. 身份验证

    在访问网站时,我们可能会遇到这样的验证页面,如图3-9所示。

    图3-9 验证页面

    此时可以使用requests自带的身份验证功能,示例如下:

    import requests
    from requests.auth import HTTPBasicAuth
    
    r = requests.get('http://localhost:5000', auth=HTTPBasicAuth('username', 'password'))
    print(r.status_code)

    如果用户名和密码正确的话,请求时就会自动验证成功,会返回200状态码;如果验证失败,则返回401状态码。

    当然,如果参数都传一个HTTPBasicAuth类,就显得有点烦琐了,所以requests提供了一个更简单的写法,可以直接传一个元组,它会默认使用HTTPBasicAuth这个类来验证。

    所以上面的代码可以直接简写如下:

    import requests
    
    r = requests.get('http://localhost:5000', auth=('username', 'password'))
    print(r.status_code)

    此外,requests还提供了其他验证方式,如OAuth验证,不过此时需要安装oauth包,安装命令如下:

    pip3 install requests_oauthlib

    使用OAuth1验证的方法如下:

    import requests
    from requests_oauthlib import OAuth1
    
    url = 'https://api.twitter.com/1.1/account/verify_credentials.json'
    auth = OAuth1('YOUR_APP_KEY', 'YOUR_APP_SECRET',
                  'USER_OAUTH_TOKEN', 'USER_OAUTH_TOKEN_SECRET')
    requests.get(url, auth=auth)

    更多详细的功能可以参考requests_oauthlib的官方文档https://requests-oauthlib.readthedocs.org/,在此不再赘述了。

  8. Prepared Request

    前面介绍urllib时,我们可以将请求表示为数据结构,其中各个参数都可以通过一个Request对象来表示。这在requests里同样可以做到,这个数据结构就叫Prepared Request。我们用实例看一下:

    from requests import Request, Session
    
    url = 'http://httpbin.org/post'
    data = {
        'name': 'germey'
    }
    headers = {
        'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_4) AppleWebKit/537.36 (KHTML, like Gecko)
             Chrome/53.0.2785.116 Safari/537.36'
    }
    s = Session()
    req = Request('POST', url, data=data, headers=headers)
    prepped = s.prepare_request(req)
    r = s.send(prepped)
    print(r.text)

    这里我们引入了Request,然后用urldataheaders参数构造了一个Request对象,这时需要再调用Sessionprepare_request()方法将其转换为一个Prepared Request对象,然后调用send()方法发送即可,运行结果如下:

    {
      "args": {},
      "data": "",
      "files": {},
      "form": {
        "name": "germey"
      },
      "headers": {
        "Accept": "*/*",
        "Accept-Encoding": "gzip, deflate",
        "Connection": "close",
        "Content-Length": "11",
        "Content-Type": "application/x-www-form-urlencoded",
        "Host": "httpbin.org",
        "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_4) AppleWebKit/537.36 (KHTML, like Gecko)
            Chrome/53.0.2785.116 Safari/537.36"
      },
      "json": null,
      "origin": "182.32.203.166",
      "url": "http://httpbin.org/post"
    }

    可以看到,我们达到了同样的POST请求效果。

    有了Request这个对象,就可以将请求当作独立的对象来看待,这样在进行队列调度时会非常方便。后面我们会用它来构造一个Request队列。

    本节讲解了requests的一些高级用法,这些用法在后面实战部分会经常用到,需要熟练掌握。更多的用法可以参考requests的官方文档:http://docs.python-requests.org/

3.3 正则表达式

本节中,我们看一下正则表达式的相关用法。正则表达式是处理字符串的强大工具,它有自己特定的语法结构,有了它,实现字符串的检索、替换、匹配验证都不在话下。

当然,对于爬虫来说,有了它,从HTML里提取想要的信息就非常方便了。

  1. 实例引入

    说了这么多,可能我们对它到底是个什么还是比较模糊,下面就用几个实例来看一下正则表达式的用法。

    打开开源中国提供的正则表达式测试工具http://tool.oschina.net/regex/,输入待匹配的文本,然后选择常用的正则表达式,就可以得出相应的匹配结果了。例如,这里输入待匹配的文本如下:

    Hello, my phone number is 010-86432100 and email is cqc@cuiqingcai.com, and my website is https://cuiqingcai.com.

    这段字符串中包含了一个电话号码和一个电子邮件,接下来就尝试用正则表达式提取出来,如图3-10所示。

    图3-10 运行页面

    在网页右侧选择“匹配Email地址”,就可以看到下方出现了文本中的E-mail。如果选择“匹配网址URL”,就可以看到下方出现了文本中的URL。是不是非常神奇?

    其实,这里就是用了正则表达式匹配,也就是用一定的规则将特定的文本提取出来。比如,电子邮件开头是一段字符串,然后是一个@符号,最后是某个域名,这是有特定的组成格式的。另外,对于URL,开头是协议类型,然后是冒号加双斜线,最后是域名加路径。

    对于URL来说,可以用下面的正则表达式匹配:

    [a-zA-z]+://[^\s]*

    用这个正则表达式去匹配一个字符串,如果这个字符串中包含类似URL的文本,那就会被提取出来。

    这个正则表达式看上去是乱糟糟的一团,其实不然,这里面都是有特定的语法规则的。比如,a-z代表匹配任意的小写字母,\s表示匹配任意的空白字符,*就代表匹配前面的字符任意多个,这一长串的正则表达式就是这么多匹配规则的组合。

    写好正则表达式后,就可以拿它去一个长字符串里匹配查找了。不论这个字符串里面有什么,只要符合我们写的规则,统统可以找出来。对于网页来说,如果想找出网页源代码里有多少URL,用匹配URL的正则表达式去匹配即可。

    上面我们说了几个匹配规则,表3-2列出了常用的匹配规则。

    表3-2 常用的匹配规则

    模式描述
    \w匹配字母、数字及下划线
    \W匹配不是字母、数字及下划线的字符
    \s匹配任意空白字符,等价于[\t\n\r\f]
    \S匹配任意非空字符
    \d匹配任意数字,等价于[0-9]
    \D匹配任意非数字的字符
    \A匹配字符串开头
    \Z匹配字符串结尾,如果存在换行,只匹配到换行前的结束字符串
    \z匹配字符串结尾,如果存在换行,同时还会匹配换行符
    \G匹配最后匹配完成的位置
    \n匹配一个换行符
    \t匹配一个制表符
    ^匹配一行字符串的开头
    $匹配一行字符串的结尾
    .匹配任意字符,除了换行符,当re.DOTALL标记被指定时,则可以匹配包括换行符的任意字符
    [...]用来表示一组字符,单独列出,比如[amk]匹配amk
    [^...]不在[]中的字符,比如[^abc]匹配除了abc之外的字符
    *匹配0个或多个表达式
    +匹配1个或多个表达式
    ?匹配0个或1个前面的正则表达式定义的片段,非贪婪方式
    {n}精确匹配n个前面的表达式
    {n, m}匹配nm次由前面正则表达式定义的片段,贪婪方式
    a|b匹配ab
    ( )匹配括号内的表达式,也表示一个组

    看完了之后,可能有点晕晕的吧,不过不用担心,后面我们会详细讲解一些常见规则的用法。

    其实正则表达式不是Python独有的,它也可以用在其他编程语言中。但是Python的re库提供了整个正则表达式的实现,利用这个库,可以在Python中使用正则表达式。在Python中写正则表达式几乎都用这个库,下面就来了解它的一些常用方法。

  2. match()

    这里首先介绍第一个常用的匹配方法——match(),向它传入要匹配的字符串以及正则表达式,就可以检测这个正则表达式是否匹配字符串。

    match()方法会尝试从字符串的起始位置匹配正则表达式,如果匹配,就返回匹配成功的结果;如果不匹配,就返回None。示例如下:

    import re
    
    content = 'Hello 123 4567 World_This is a Regex Demo'
    print(len(content))
    result = re.match('^Hello\s\d\d\d\s\d{4}\s\w{10}', content)
    print(result)
    print(result.group())
    print(result.span())

    运行结果如下:

    41
    <_sre.SRE_Match object; span=(0, 25), match='Hello 123 4567 World_This'>
    Hello 123 4567 World_This
    (0, 25)

    这里首先声明了一个字符串,其中包含英文字母、空白字符、数字等。接下来,我们写一个正则表达式:

    ^Hello\s\d\d\d\s\d{4}\s\w{10}

    用它来匹配这个长字符串。开头的^是匹配字符串的开头,也就是以Hello开头;然后\s匹配空白字符,用来匹配目标字符串的空格;\d匹配数字,3个\d匹配123;然后再写1个\s匹配空格;后面还有4567,我们其实可以依然用4个\d来匹配,但是这么写比较烦琐,所以后面可以跟{4}以代表匹配前面的规则4次,也就是匹配4个数字;然后后面再紧接1个空白字符,最后\w{10}匹配10个字母及下划线。我们注意到,这里其实并没有把目标字符串匹配完,不过这样依然可以进行匹配,只不过匹配结果短一点而已。

    而在match()方法中,第一个参数传入了正则表达式,第二个参数传入了要匹配的字符串。

    打印输出结果,可以看到结果是SRE_Match对象,这证明成功匹配。该对象有两个方法:group()方法可以输出匹配到的内容,结果是Hello 123 4567 World_This,这恰好是正则表达式规则所匹配的内容;span()方法可以输出匹配的范围,结果是(0, 25),这就是匹配到的结果字符串在原字符串中的位置范围。

    通过上面的例子,我们基本了解了如何在Python中使用正则表达式来匹配一段文字。

    • 匹配目标

      刚才我们用match()方法可以得到匹配到的字符串内容,但是如果想从字符串中提取一部分内容,该怎么办呢?就像最前面的实例一样,从一段文本中提取出邮件或电话号码等内容。

      这里可以使用()括号将想提取的子字符串括起来。()实际上标记了一个子表达式的开始和结束位置,被标记的每个子表达式会依次对应每一个分组,调用group()方法传入分组的索引即可获取提取的结果。示例如下:

      import re
      
      content = 'Hello 1234567 World_This is a Regex Demo'
      result = re.match('^Hello\s(\d+)\sWorld', content)
      print(result)
      print(result.group())
      print(result.group(1))
      print(result.span())

      这里我们想把字符串中的1234567提取出来,此时可以将数字部分的正则表达式用()括起来,然后调用了group(1)获取匹配结果。

      运行结果如下:

      <_sre.SRE_Match object; span=(0, 19), match='Hello 1234567 World'>
      Hello 1234567 World
      1234567
      (0, 19)

      可以看到,我们成功得到了1234567。这里用的是group(1),它与group()有所不同,后者会输出完整的匹配结果,而前者会输出第一个被()包围的匹配结果。假如正则表达式后面还有()包括的内容,那么可以依次用group(2)group(3)等来获取。

    • 通用匹配

      刚才我们写的正则表达式其实比较复杂,出现空白字符我们就写\s匹配,出现数字我们就用\d匹配,这样的工作量非常大。其实完全没必要这么做,因为还有一个万能匹配可以用,那就是.*(点星)。其中.(点)可以匹配任意字符(除换行符),*(星)代表匹配前面的字符无限次,所以它们组合在一起就可以匹配任意字符了。有了它,我们就不用挨个字符地匹配了。

      接着上面的例子,我们可以改写一下正则表达式:

      import re
      
      content = 'Hello 123 4567 World_This is a Regex Demo'
      result = re.match('^Hello.*Demo$', content)
      print(result)
      print(result.group())
      print(result.span())

      这里我们将中间部分直接省略,全部用.*来代替,最后加一个结尾字符串就好了。运行结果如下:

      <_sre.SRE_Match object; span=(0, 41), match='Hello 123 4567 World_This is a Regex Demo'>
      Hello 123 4567 World_This is a Regex Demo
      (0, 41)

      可以看到,group()方法输出了匹配的全部字符串,也就是说我们写的正则表达式匹配到了目标字符串的全部内容;span()方法输出(0, 41),这是整个字符串的长度。

      因此,我们可以使用.*简化正则表达式的书写。

    • 贪婪与非贪婪

      使用上面的通用匹配.*时,可能有时候匹配到的并不是我们想要的结果。看下面的例子:

      import re
      
      content = 'Hello 1234567 World_This is a Regex Demo'
      result = re.match('^He.*(\d+).*Demo$', content)
      print(result)
      print(result.group(1))

      这里我们依然想获取中间的数字,所以中间依然写的是(\d+)。而数字两侧由于内容比较杂乱,所以想省略来写,都写成.*。最后,组成^He.*(\d+).*Demo$,看样子并没有什么问题。我们看下运行结果:

      <_sre.SRE_Match object; span=(0, 40), match='Hello 1234567 World_This is a Regex Demo'>
      7

      奇怪的事情发生了,我们只得到了7这个数字,这是怎么回事呢?

      这里就涉及一个贪婪匹配与非贪婪匹配的问题了。在贪婪匹配下,.*会匹配尽可能多的字符。正则表达式中.*后面是\d+,也就是至少一个数字,并没有指定具体多少个数字,因此,.*就尽可能匹配多的字符,这里就把123456匹配了,给\d+留下一个可满足条件的数字7,最后得到的内容就只有数字7了。

      但这很明显会给我们带来很大的不便。有时候,匹配结果会莫名其妙少了一部分内容。其实,这里只需要使用非贪婪匹配就好了。非贪婪匹配的写法是.*?,多了一个?,那么它可以达到怎样的效果?我们再用实例看一下:

      import re
      
      content = 'Hello 1234567 World_This is a Regex Demo'
      result = re.match('^He.*?(\d+).*Demo$', content)
      print(result)
      print(result.group(1))

      这里我们只是将第一个.*改成了.*?,转变为非贪婪匹配。结果如下:

      <_sre.SRE_Match object; span=(0, 40), match='Hello 1234567 World_This is a Regex Demo'>
      1234567

      此时就可以成功获取1234567了。原因可想而知,贪婪匹配是尽可能匹配多的字符,非贪婪匹配就是尽可能匹配少的字符。当.*?匹配到Hello后面的空白字符时,再往后的字符就是数字了,而\d+恰好可以匹配,那么这里.*?就不再进行匹配,交给\d+去匹配后面的数字。所以这样.*?匹配了尽可能少的字符,\d+的结果就是1234567了。

      所以说,在做匹配的时候,字符串中间尽量使用非贪婪匹配,也就是用.*?来代替.*,以免出现匹配结果缺失的情况。

      但这里需要注意,如果匹配的结果在字符串结尾,.*?就有可能匹配不到任何内容了,因为它会匹配尽可能少的字符。例如:

      import re
      
      content = 'http://weibo.com/comment/kEraCN'
      result1 = re.match('http.*?comment/(.*?)', content)
      result2 = re.match('http.*?comment/(.*)', content)
      print('result1', result1.group(1))
      print('result2', result2.group(1))

      运行结果如下:

      result1
      result2 kEraCN

      可以观察到,.*?没有匹配到任何结果,而.*则尽量匹配多的内容,成功得到了匹配结果。

    • 修饰符

      正则表达式可以包含一些可选标志修饰符来控制匹配的模式。修饰符被指定为一个可选的标志。我们用实例来看一下:

      import re
      
      content = '''Hello 1234567 World_This
      is a Regex Demo
      '''
      result = re.match('^He.*?(\d+).*?Demo$', content)
      print(result.group(1))

      和上面的例子相仿,我们在字符串中加了换行符,正则表达式还是一样的,用来匹配其中的数字。看一下运行结果:

      AttributeError Traceback (most recent call last)
      <ipython-input-18-c7d232b39645> in <module>()
            5 '''
            6 result = re.match('^He.*?(\d+).*?Demo$', content)
      ----> 7 print(result.group(1))
      
      AttributeError: 'NoneType' object has no attribute 'group'

      运行直接报错,也就是说正则表达式没有匹配到这个字符串,返回结果为None,而我们又调用了group()方法导致AttributeError

      那么,为什么加了一个换行符,就匹配不到了呢?这是因为.匹配的是除换行符之外的任意字符,当遇到换行符时,.*?就不能匹配了,所以导致匹配失败。这里只需加一个修饰符re.S,即可修正这个错误:

      result = re.match('^He.*?(\d+).*?Demo$', content, re.S)

      这个修饰符的作用是使.匹配包括换行符在内的所有字符。此时运行结果如下:

      1234567

      这个re.S在网页匹配中经常用到。因为HTML节点经常会有换行,加上它,就可以匹配节点与节点之间的换行了。

      另外,还有一些修饰符,在必要的情况下也可以使用,如表3-3所示。

      表3-3 修饰符

      修饰符描述
      re.I使匹配对大小写不敏感
      re.L做本地化识别(locale-aware)匹配
      re.M多行匹配,影响^$
      re.S使.匹配包括换行在内的所有字符
      re.U根据Unicode字符集解析字符。这个标志影响\w\W\b\B
      re.X该标志通过给予你更灵活的格式以便你将正则表达式写得更易于理解

      在网页匹配中,较为常用的有re.Sre.I

    • 转义匹配

      我们知道正则表达式定义了许多匹配模式,如.匹配除换行符以外的任意字符,但是如果目标字符串里面就包含.,那该怎么办呢?

      这里就需要用到转义匹配了,示例如下:

      import re
      
      content = '(百度)www.baidu.com'
      result = re.match('\(百度\)www\.baidu\.com', content)
      print(result)

      当遇到用于正则匹配模式的特殊字符时,在前面加反斜线转义一下即可。例如.就可以用\.来匹配,运行结果如下:

      <_sre.SRE_Match object; span=(0, 17), match='(百度)www.baidu.com'>

      可以看到,这里成功匹配到了原字符串。

      这些是写正则表达式常用的几个知识点,熟练掌握它们对后面写正则表达式匹配非常有帮助。

  3. search()

    前面提到过,match()方法是从字符串的开头开始匹配的,一旦开头不匹配,那么整个匹配就失败了。我们看下面的例子:

    import re
    
    content = 'Extra stings Hello 1234567 World_This is a Regex Demo Extra stings'
    result = re.match('Hello.*?(\d+).*?Demo', content)
    print(result)

    这里的字符串以Extra开头,但是正则表达式以Hello开头,整个正则表达式是字符串的一部分,但是这样匹配是失败的。运行结果如下:

    None

    因为match()方法在使用时需要考虑到开头的内容,这在做匹配时并不方便。它更适合用来检测某个字符串是否符合某个正则表达式的规则。

    这里就有另外一个方法search(),它在匹配时会扫描整个字符串,然后返回第一个成功匹配的结果。也就是说,正则表达式可以是字符串的一部分,在匹配时,search()方法会依次扫描字符串,直到找到第一个符合规则的字符串,然后返回匹配内容,如果搜索完了还没有找到,就返回None

    我们把上面代码中的match()方法修改成search(),再看下运行结果:

    <_sre.SRE_Match object; span=(13, 53), match='Hello 1234567 World_This is a Regex Demo'>
    1234567

    这时就得到了匹配结果。

    因此,为了匹配方便,我们可以尽量使用search()方法。

    下面再用几个实例来看看search()方法的用法。

    首先,这里有一段待匹配的HTML文本,接下来写几个正则表达式实例来实现相应信息的提取:

    html = '''<div id="songs-list">
    <h2 class="title">经典老歌</h2>
    <p class="introduction">
    经典老歌列表
    </p>
    <ul id="list" class="list-group">
    <li data-view="2">一路上有你</li>
    <li data-view="7">
    <a href="/2.mp3" singer="任贤齐">沧海一声笑</a>
    </li>
    <li data-view="4" class="active">
    <a href="/3.mp3" singer="齐秦">往事随风</a>
    </li>
    <li data-view="6"><a href="/4.mp3" singer="beyond">光辉岁月</a></li>
    <li data-view="5"><a href="/5.mp3" singer="陈慧琳">记事本</a></li>
    <li data-view="5">
    <a href="/6.mp3" singer="邓丽君">但愿人长久</a>
    </li>
    </ul>
    </div>'''

    可以观察到,ul节点里有许多li节点,其中li节点中有的包含a节点,有的不包含a节点,a节点还有一些相应的属性——超链接和歌手名。

    首先,我们尝试提取classactiveli节点内部的超链接包含的歌手名和歌名,此时需要提取第三个li节点下a节点的singer属性和文本。

    此时正则表达式可以以li开头,然后寻找一个标志符active,中间的部分可以用.*?来匹配。接下来,要提取singer这个属性值,所以还需要写入singer="(.*?)",这里需要提取的部分用小括号括起来,以便用group()方法提取出来,它的两侧边界是双引号。然后还需要匹配a节点的文本,其中它的左边界是>,右边界是</a>。然后目标内容依然用(.*?)来匹配,所以最后的正则表达式就变成了:

    <li.*?active.*?singer="(.*?)">(.*?)</a>

    然后再调用search()方法,它会搜索整个HTML文本,找到符合正则表达式的第一个内容返回。

    另外,由于代码有换行,所以这里第三个参数需要传入re.S。整个匹配代码如下:

    result = re.search('<li.*?active.*?singer="(.*?)">(.*?)</a>', html, re.S)
    if result:
        print(result.group(1), result.group(2))

    由于需要获取的歌手和歌名都已经用小括号包围,所以可以用group()方法获取。

    运行结果如下:

    齐秦往事随风

    可以看到,这正是classactiveli节点内部的超链接包含的歌手名和歌名。

    如果正则表达式不加active(也就是匹配不带classactive的节点内容),那会怎样呢?我们将正则表达式中的active去掉,代码改写如下:

    result = re.search('<li.*?singer="(.*?)">(.*?)</a>', html, re.S)
    if result:
        print(result.group(1), result.group(2))

    由于search()方法会返回第一个符合条件的匹配目标,这里结果就变了:

    任贤齐沧海一声笑

    active标签去掉后,从字符串开头开始搜索,此时符合条件的节点就变成了第二个li节点,后面的就不再匹配,所以运行结果就变成第二个li节点中的内容。

    注意,在上面的两次匹配中,search()方法的第三个参数都加了re.S,这使得.*?可以匹配换行,所以含有换行的li节点被匹配到了。如果我们将其去掉,结果会是什么?代码如下:

    result = re.search('<li.*?singer="(.*?)">(.*?)</a>', html)
    if result:
        print(result.group(1), result.group(2))

    运行结果如下:

    beyond 光辉岁月

    可以看到,结果变成了第四个li节点的内容。这是因为第二个和第三个li节点都包含了换行符,去掉re.S之后,.*?已经不能匹配换行符,所以正则表达式不会匹配到第二个和第三个li节点,而第四个li节点中不包含换行符,所以成功匹配。

    由于绝大部分的HTML文本都包含了换行符,所以尽量都加上re.S修饰符,以免出现匹配不到的问题。

  4. findall()

    前面我们介绍了search()方法的用法,它可以返回匹配正则表达式的第一个内容,但是如果想要获取匹配正则表达式的所有内容,那该怎么办呢?这时就要借助findall()方法了。该方法会搜索整个字符串,然后返回匹配正则表达式的所有内容。

    还是上面的HTML文本,如果想获取所有a节点的超链接、歌手和歌名,就可以将search()方法换成findall()方法。如果有返回结果的话,就是列表类型,所以需要遍历一下来依次获取每组内容。代码如下:

    results = re.findall('<li.*?href="(.*?)".*?singer="(.*?)">(.*?)</a>', html, re.S)
    print(results)
    print(type(results))
    for result in results:
        print(result)
        print(result[0], result[1], result[2])

    运行结果如下:

    [('/2.mp3', '任贤齐', '沧海一声笑'), ('/3.mp3', '齐秦', '往事随风'), ('/4.mp3', 'beyond', '光辉岁月'),
        ('/5.mp3', '陈慧琳', '记事本'), ('/6.mp3', '邓丽君', '但愿人长久')]
    <class 'list'>
    ('/2.mp3', '任贤齐', '沧海一声笑')
    /2.mp3 任贤齐沧海一声笑
    ('/3.mp3', '齐秦', '往事随风')
    /3.mp3 齐秦往事随风
    ('/4.mp3', 'beyond', '光辉岁月')
    /4.mp3 beyond 光辉岁月
    ('/5.mp3', '陈慧琳', '记事本')
    /5.mp3 陈慧琳记事本
    ('/6.mp3', '邓丽君', '但愿人长久')
    /6.mp3 邓丽君但愿人长久

    可以看到,返回的列表中的每个元素都是元组类型,我们用对应的索引依次取出即可。

    如果只是获取第一个内容,可以用search()方法。当需要提取多个内容时,可以用findall()方法。

  5. sub()

    除了使用正则表达式提取信息外,有时候还需要借助它来修改文本。比如,想要把一串文本中的所有数字都去掉,如果只用字符串的replace()方法,那就太烦琐了,这时可以借助sub()方法。示例如下:

    import re
    
    content = '54aK54yr5oiR54ix5L2g'
    content = re.sub('\d+', '', content)
    print(content)

    运行结果如下:

    aKyroiRixLg

    这里只需要给第一个参数传入\d+来匹配所有的数字,第二个参数为替换成的字符串(如果去掉该参数的话,可以赋值为空),第三个参数是原字符串。

    在上面的HTML文本中,如果想获取所有li节点的歌名,直接用正则表达式来提取可能比较烦琐。比如,可以写成这样子:

    results = re.findall('<li.*?>\s*?(<a.*?>)?(\w+)(</a>)?\s*?</li>', html, re.S)
    for result in results:
        print(result[1])

    运行结果如下:

    一路上有你
    沧海一声笑
    往事随风
    光辉岁月
    记事本
    但愿人长久

    此时借助sub()方法就比较简单了。可以先用sub()方法将a节点去掉,只留下文本,然后再利用findall()提取就好了:

    html = re.sub('<a.*?>|</a>', '', html)
    print(html)
    results = re.findall('<li.*?>(.*?)</li>', html, re.S)
    for result in results:
        print(result.strip())

    运行结果如下:

    <div id="songs-list">
    <h2 class="title">经典老歌</h2>
    <p class="introduction">
    经典老歌列表
    </p>
    <ul id="list" class="list-group">
    <li data-view="2">一路上有你</li>
    <li data-view="7">
    沧海一声笑
    </li>
    <li data-view="4" class="active">
    往事随风
    </li>
    <li data-view="6">光辉岁月</li>
    <li data-view="5">记事本</li>
    <li data-view="5">
    但愿人长久
    </li>
    </ul>
    </div>
    一路上有你
    沧海一声笑
    往事随风
    光辉岁月
    记事本
    但愿人长久

    可以看到,a节点经过sub()方法处理后就没有了,然后再通过findall()方法直接提取即可。可以看到,在适当的时候,借助sub()方法可以起到事半功倍的效果。

  6. compile()

    前面所讲的方法都是用来处理字符串的方法,最后再介绍一下compile()方法,这个方法可以将正则字符串编译成正则表达式对象,以便在后面的匹配中复用。示例代码如下:

    import re
    
    content1 = '2016-12-15 12:00'
    content2 = '2016-12-17 12:55'
    content3 = '2016-12-22 13:21'
    pattern = re.compile('\d{2}:\d{2}')
    result1 = re.sub(pattern, '', content1)
    result2 = re.sub(pattern, '', content2)
    result3 = re.sub(pattern, '', content3)
    print(result1, result2, result3)

    例如,这里有3个日期,我们想分别将3个日期中的时间去掉,这时可以借助sub()方法。该方法的第一个参数是正则表达式,但是这里没有必要重复写3个同样的正则表达式,此时可以借助compile()方法将正则表达式编译成一个正则表达式对象,以便复用。

    运行结果如下:

    2016-12-15  2016-12-17  2016-12-22

    另外,compile()还可以传入修饰符,例如re.S等修饰符,这样在search()findall()等方法中就不需要额外传了。所以,compile()方法可以说是给正则表达式做了一层封装,以便我们更好地复用。

    到此为止,正则表达式的基本用法就介绍完了,后面会通过具体的实例来讲解正则表达式的用法。

3.4 抓取猫眼电影排行

本节中,我们利用requests库和正则表达式来抓取猫眼电影TOP100的相关内容。requests比urllib使用更加方便,而且目前我们还没有系统学习HTML解析库,所以这里就选用正则表达式来作为解析工具。

  1. 本节目标

    本节中,我们要提取出猫眼电影TOP100的电影名称、时间、评分、图片等信息,提取的站点URL为http://maoyan.com/board/4,提取的结果会以文件形式保存下来。

  2. 准备工作

    在本节开始之前,请确保已经正确安装好了requests库。如果没有安装,可以参考第1章的安装说明。

  3. 抓取分析

    我们需要抓取的目标站点为http://maoyan.com/board/4,打开之后便可以查看到榜单信息,如图3-11所示。

    {%}

    图3-11 榜单信息

    排名第一的电影是霸王别姬,页面中显示的有效信息有影片名称、主演、上映时间、上映地区、评分、图片等信息。

    将网页滚动到最下方,可以发现有分页的列表,直接点击第2页,观察页面的URL和内容发生了怎样的变化,如图3-12所示。

    {%}

    图3-12 页面URL变化

    可以发现页面的URL变成http://maoyan.com/board/4?offset=10,比之前的URL多了一个参数,那就是offset=10,而目前显示的结果是排行11~20名的电影,初步推断这是一个偏移量的参数。再点击下一页,发现页面的URL变成了http://maoyan.com/board/4?offset=20,参数offset变成了20,而显示的结果是排行21~30的电影。

    由此可以总结出规律,offset代表偏移量值,如果偏移量为n,则显示的电影序号就是n+1n+10,每页显示10个。所以,如果想获取TOP100电影,只需要分开请求10次,而10次的offset参数分别设置为0、10、20…90即可,这样获取不同的页面之后,再用正则表达式提取出相关信息,就可以得到TOP100的所有电影信息了。

  4. 抓取首页

    接下来用代码实现这个过程。首先抓取第一页的内容。我们实现了get_one_page()方法,并给它传入url参数。然后将抓取的页面结果返回,再通过main()方法调用。初步代码实现如下:

    import requests
    
    def get_one_page(url):
        headers = {
            'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_3) AppleWebKit/537.36 (KHTML, like Gecko)
                Chrome/65.0.3325.162 Safari/537.36'
        }
        response = requests.get(url, headers=headers)
        if response.status_code == 200:
            return response.text
        return None
    
    def main():
        url = 'http://maoyan.com/board/4'
        html = get_one_page(url)
        print(html)
    
    main()

    这样运行之后,就可以成功获取首页的源代码了。获取源代码后,就需要解析页面,提取出我们想要的信息。

  5. 正则提取

    接下来,回到网页看一下页面的真实源码。在开发者模式下的Network监听组件中查看源代码,如图3-13所示。

    {%}

    图3-13 源代码

    注意,这里不要在Elements选项卡中直接查看源码,因为那里的源码可能经过JavaScript操作而与原始请求不同,而是需要从Network选项卡部分查看原始请求得到的源码。

    查看其中一个条目的源代码,如图3-14所示。

    {%}

    图3-14 源代码

    可以看到,一部电影信息对应的源代码是一个dd节点,我们用正则表达式来提取这里面的一些电影信息。首先,需要提取它的排名信息。而它的排名信息是在classboard-indexi节点内,这里利用非贪婪匹配来提取i节点内的信息,正则表达式写为:

    <dd>.*?board-index.*?>(.*?)</i>

    随后需要提取电影的图片。可以看到,后面有a节点,其内部有两个img节点。经过检查后发现,第二个img节点的data-src属性是图片的链接。这里提取第二个img节点的data-src属性,正则表达式可以改写如下:

    <dd>.*?board-index.*?>(.*?)</i>.*?data-src="(.*?)"

    再往后,需要提取电影的名称,它在后面的p节点内,classname。所以,可以用name做一个标志位,然后进一步提取到其内a节点的正文内容,此时正则表达式改写如下:

    <dd>.*?board-index.*?>(.*?)</i>.*?data-src="(.*?)".*?name.*?a.*?>(.*?)</a>

    再提取主演、发布时间、评分等内容时,都是同样的原理。最后,正则表达式写为:

    <dd>.*?board-index.*?>(.*?)</i>.*?data-src="(.*?)".*?name.*?a.*?>(.*?)</a>.*?star.*?>(.*?)</p>.*?releaset
        ime.*?>(.*?)</p>.*?integer.*?>(.*?)</i>.*?fraction.*?>(.*?)</i>.*?</dd>

    这样一个正则表达式可以匹配一个电影的结果,里面匹配了7个信息。接下来,通过调用findall()方法提取出所有的内容。

    接下来,我们再定义解析页面的方法parse_one_page(),主要是通过正则表达式来从结果中提取出我们想要的内容,实现代码如下:

    def parse_one_page(html):
        pattern = re.compile(
            '<dd>.*?board-index.*?>(.*?)</i>.*?data-src="(.*?)".*?name.*?a.*?>(.*?)</a>.*?star.*?>(.*?)</p>.
            *?releasetime.*?>(.*?)</p>.*?integer.*?>(.*?)</i>.*?fraction.*?>(.*?)</i>.*?</dd>',
            re.S)
        items = re.findall(pattern, html)
        print(items)

    这样就可以成功地将一页的10个电影信息都提取出来,这是一个列表形式,输出结果如下:

    [('1', 'http://p1.meituan.net/movie/20803f59291c47e1e116c11963ce019e68711.jpg@160w_220h_1e_1c',
        '霸王别姬', '\n                主演:张国荣,张丰毅,巩俐\n        ', '上映时间:1993-01-01(中国香港)',
        '9.', '6'), ('2', 'http://p0.meituan.net/movie/__40191813__4767047.jpg@160w_220h_1e_1c', '肖申克的救赎',
        '\n                主演:蒂姆·罗宾斯,摩根·弗里曼,鲍勃·冈顿\n        ', '上映时间:1994-10-14(美国)', '9.',
        '5'), ('3', 'http://p0.meituan.net/movie/fc9d78dd2ce84d20e53b6d1ae2eea4fb1515304.jpg@160w_220h_1e_1c',
        '这个杀手不太冷', '\n                主演:让·雷诺,加里·奥德曼,娜塔莉·波特曼\n        ', '上映时间:
        1994-09-14(法国)', '9.', '5'), ('4', 'http://p0.meituan.net/movie/23/6009725.jpg@160w_220h_1e_1c',
        '罗马假日', '\n                主演:格利高利·派克,奥黛丽·赫本,埃迪·艾伯特\n        ', '上映时间:
        1953-09-02(美国)', '9.', '1'), ('5', 'http://p0.meituan.net/movie/53/1541925.jpg@160w_220h_1e_1c',
        '阿甘正传', '\n                主演:汤姆·汉克斯,罗宾·怀特,加里·西尼斯\n        ', '上映时间:
        1994-07-06(美国)', '9.', '4'), ('6', 'http://p0.meituan.net/movie/11/324629.jpg@160w_220h_1e_1c',
        '泰坦尼克号', '\n                主演:莱昂纳多·迪卡普里奥,凯特·温丝莱特,比利·赞恩\n        ',
        '上映时间:1998-04-03', '9.', '5'), ('7', 'http://p0.meituan.net/movie/99/678407.jpg@160w_220h_1e_1c',
        '龙猫', '\n                主演:日高法子,坂本千夏,糸井重里\n        ', '上映时间:1988-04-16(日本)',
        '9.', '2'), ('8', 'http://p0.meituan.net/movie/92/8212889.jpg@160w_220h_1e_1c', '教父', '\n       
        主演:马龙·白兰度,阿尔·帕西诺,詹姆斯·凯恩\n        ', '上映时间:1972-03-24(美国)', '9.', '3'), ('9',
        'http://p0.meituan.net/movie/62/109878.jpg@160w_220h_1e_1c', '唐伯虎点秋香', '\n       
        主演:周星驰,巩俐,郑佩佩\n        ', '上映时间:1993-07-01(中国香港)', '9.', '2'), ('10',
        'http://p0.meituan.net/movie/9bf7d7b81001a9cf8adbac5a7cf7d766132425.jpg@160w_220h_1e_1c', '千与千寻',
        '\n                主演:柊瑠美,入野自由,夏木真理\n        ', '上映时间:2001-07-20(日本)',
        '9.', '3')]

    但这样还不够,数据比较杂乱,我们再将匹配结果处理一下,遍历提取结果并生成字典,此时方法改写如下:

    def parse_one_page(html):
        pattern = re.compile(
            '<dd>.*?board-index.*?>(.*?)</i>.*?data-src="(.*?)".*?name.*?a.*?>(.*?)</a>.*?star.*?>(.*?)</p>.
            *?releasetime.*?>(.*?)</p>.*?integer.*?>(.*?)</i>.*?fraction.*?>(.*?)</i>.*?</dd>',
            re.S)
        items = re.findall(pattern, html)
        for item in items:
            yield {
                'index': item[0],
                'image': item[1],
                'title': item[2].strip(),
                'actor': item[3].strip()[3:] if len(item[3]) > 3 else '',
                'time': item[4].strip()[5:] if len(item[4]) > 5 else '',
                'score': item[5].strip() + item[6].strip()
            }

    这样就可以成功提取出电影的排名、图片、标题、演员、时间、评分等内容了,并把它赋值为一个个的字典,形成结构化数据。运行结果如下:

    {'image': 'http://p1.meituan.net/movie/20803f59291c47e1e116c11963ce019e68711.jpg@160w_220h_1e_1c', 'actor':
        '张国荣,张丰毅,巩俐', 'score': '9.6', 'index': '1', 'title': '霸王别姬', 'time': '1993-01-01(中国香港)'}
    {'image': 'http://p0.meituan.net/movie/__40191813__4767047.jpg@160w_220h_1e_1c', 'actor': '蒂姆·罗宾斯,
        摩根·弗里曼,鲍勃·冈顿', 'score': '9.5', 'index': '2', 'title': '肖申克的救赎', 'time': '1994-10-14(美国)'}
    {'image': 'http://p0.meituan.net/movie/fc9d78dd2ce84d20e53b6d1ae2eea4fb1515304.jpg@160w_220h_1e_1c', 'actor':
        '让·雷诺,加里·奥德曼,娜塔莉·波特曼', 'score': '9.5', 'index': '3', 'title': '这个杀手不太冷', 'time':
        '1994-09-14(法国)'}
    {'image': 'http://p0.meituan.net/movie/23/6009725.jpg@160w_220h_1e_1c', 'actor': '格利高利·派克,
        奥黛丽·赫本,埃迪·艾伯特', 'score': '9.1', 'index': '4', 'title': '罗马假日', 'time': '1953-09-02(美国)'}
    {'image': 'http://p0.meituan.net/movie/53/1541925.jpg@160w_220h_1e_1c', 'actor': '汤姆·汉克斯,罗宾·怀特,
        加里·西尼斯', 'score': '9.4', 'index': '5', 'title': '阿甘正传', 'time': '1994-07-06(美国)'}
    {'image': 'http://p0.meituan.net/movie/11/324629.jpg@160w_220h_1e_1c', 'actor': '莱昂纳多·迪卡普里奥,
        凯特·温丝莱特,比利·赞恩', 'score': '9.5', 'index': '6', 'title': '泰坦尼克号', 'time': '1998-04-03'}
    {'image': 'http://p0.meituan.net/movie/99/678407.jpg@160w_220h_1e_1c', 'actor': '日高法子,坂本千夏,
        糸井重里', 'score': '9.2', 'index': '7', 'title': '龙猫', 'time': '1988-04-16(日本)'}
    {'image': 'http://p0.meituan.net/movie/92/8212889.jpg@160w_220h_1e_1c', 'actor': '马龙·白兰度,阿尔·帕西诺,
        詹姆斯·凯恩', 'score': '9.3', 'index': '8', 'title': '教父', 'time': '1972-03-24(美国)'}
    {'image': 'http://p0.meituan.net/movie/62/109878.jpg@160w_220h_1e_1c', 'actor': '周星驰,巩俐,郑佩佩',
        'score': '9.2', 'index': '9', 'title': '唐伯虎点秋香', 'time': '1993-07-01(中国香港)'}
    {'image': 'http://p0.meituan.net/movie/9bf7d7b81001a9cf8adbac5a7cf7d766132425.jpg@160w_220h_1e_1c', 'actor':
        '柊瑠美,入野自由,夏木真理', 'score': '9.3', 'index': '10', 'title': '千与千寻', 'time': '2001-07-20(日本)'}

    到此为止,我们就成功提取了单页的电影信息。

  6. 写入文件

    随后,我们将提取的结果写入文件,这里直接写入到一个文本文件中。这里通过JSON库的dumps()方法实现字典的序列化,并指定ensure_ascii参数为False,这样可以保证输出结果是中文形式而不是Unicode编码。代码如下:

    def write_to_file(content):
        with open('result.txt', 'a', encoding='utf-8') as f:
            print(type(json.dumps(content)))
            f.write(json.dumps(content, ensure_ascii=False)+'\n')

    通过调用write_to_file()方法即可实现将字典写入到文本文件的过程,此处的content参数就是一部电影的提取结果,是一个字典。

  7. 整合代码

    最后,实现main()方法来调用前面实现的方法,将单页的电影结果写入到文件。相关代码如下:

    def main():
        url = 'http://maoyan.com/board/4'
        html = get_one_page(url)
        for item in parse_one_page(html):
            write_to_file(item)

    到此为止,我们就完成了单页电影的提取,也就是首页的10部电影可以成功提取并保存到文本文件中了。

  8. 分页爬取

    因为我们需要抓取的是TOP100的电影,所以还需要遍历一下,给这个链接传入offset参数,实现其他90部电影的爬取,此时添加如下调用即可:

    if __name__ == '__main__':
        for i in range(10):
            main(offset=i * 10)

    这里还需要将main()方法修改一下,接收一个offset值作为偏移量,然后构造URL进行爬取。实现代码如下:

    def main(offset):
        url = 'http://maoyan.com/board/4?offset=' + str(offset)
        html = get_one_page(url)
        for item in parse_one_page(html):
            print(item)
            write_to_file(item)

    到此为止,我们的猫眼电影TOP100的爬虫就全部完成了,再稍微整理一下,完整的代码如下:

    import json
    import requests
    from requests.exceptions import RequestException
    import re
    import time
    
    def get_one_page(url):
        try:
            headers = {
                'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_3) AppleWebKit/537.36 (KHTML, like
                    Gecko) Chrome/65.0.3325.162 Safari/537.36'
            }
            response = requests.get(url, headers=headers)
            if response.status_code == 200:
                return response.text
            return None
        except RequestException:
            return None
    
    def parse_one_page(html):
        pattern = re.compile('<dd>.*?board-index.*?>(\d+)</i>.*?data-src="(.*?)".*?name"><a'
                             + '.*?>(.*?)</a>.*?star">(.*?)</p>.*?releasetime">(.*?)</p>'
                             + '.*?integer">(.*?)</i>.*?fraction">(.*?)</i>.*?</dd>', re.S)
        items = re.findall(pattern, html)
        for item in items:
            yield {
                'index': item[0],
                'image': item[1],
                'title': item[2],
                'actor': item[3].strip()[3:],
                'time': item[4].strip()[5:],
                'score': item[5] + item[6]
            }
    
    def write_to_file(content):
        with open('result.txt', 'a', encoding='utf-8') as f:
            f.write(json.dumps(content, ensure_ascii=False) + '\n')
    
    def main(offset):
        url = 'http://maoyan.com/board/4?offset=' + str(offset)
        html = get_one_page(url)
        for item in parse_one_page(html):
            print(item)
            write_to_file(item)
    
    if __name__ == '__main__':
        for i in range(10):
            main(offset=i * 10)
            time.sleep(1)

    现在猫眼多了反爬虫,如果速度过快,则会无响应,所以这里又增加了一个延时等待。

  9. 运行结果

    最后,我们运行一下代码,输出结果类似如下:

    {'index': '1', 'image': 'http://p1.meituan.net/movie/20803f59291c47e1e116c11963ce019e68711.jpg@160w_220h_1e_1c',
        'title': '霸王别姬', 'actor': '张国荣,张丰毅,巩俐', 'time': '1993-01-01(中国香港)', 'score': '9.6'}
    {'index': '2', 'image': 'http://p0.meituan.net/movie/__40191813__4767047.jpg@160w_220h_1e_1c', 'title':
        '肖申克的救赎', 'actor': '蒂姆·罗宾斯,摩根·弗里曼,鲍勃·冈顿', 'time': '1994-10-14(美国)', 'score': '9.5'}
    ...
    {'index': '98', 'image': 'http://p0.meituan.net/movie/76/7073389.jpg@160w_220h_1e_1c', 'title': '东京物语',
        'actor': '笠智众,原节子,杉村春子', 'time': '1953-11-03(日本)', 'score': '9.1'}
    {'index': '99', 'image': 'http://p0.meituan.net/movie/52/3420293.jpg@160w_220h_1e_1c', 'title': '我爱你',
        'actor': '宋在河,李彩恩,吉海延', 'time': '2011-02-17(韩国)', 'score': '9.0'}
    {'index': '100', 'image': 'http://p1.meituan.net/movie/__44335138__8470779.jpg@160w_220h_1e_1c', 'title':
        '迁徙的鸟', 'actor': '雅克·贝汉,菲利普·拉波洛,Philippe Labro', 'time': '2001-12-12(法国)', 'score': '9.1'}

    这里省略了中间的部分输出结果。可以看到,这样就成功地把TOP100的电影信息爬取下来了。

    这时我们再看下文本文件,结果如图3-15所示。

    {%}

    图3-15 运行结果

    可以看到,电影信息也已全部保存到了文本文件中了,大功告成!

  10. 本节代码

    本节的代码地址为https://github.com/Python3WebSpider/MaoYan

    本节中,我们通过爬取猫眼TOP100的电影信息练习了requests和正则表达式的用法。这是一个最基础的实例,希望大家可以通过这个实例对爬虫的实现有一个最基本的思路,也对这两个库的用法有更深一步的了解。

目录