使用requests和lxml编写python爬虫小记

前一段时间写了不少Python的爬虫程序, 为此还看了极客学院上的一些教程, 现在来简单总结一下. 主要介绍用requests + lxml的方式, scrapy的话之前写过一篇介绍性的文章, 这里就不重复了. 而且感觉一般简单的爬虫项目, 一个Python文件就基本可以搞定, 没必要用scrapy建立一个工程文件夹搞那么正式...

安装需要的库(python2):

pip install requests, lxml

然后在Python程序最开始导入:

import requests  
from lxml import etree

requests基础用法

抓取html内容

用requests获取目标网址的html代码非常简单, 只需要用requests.get方法, 传入网址URL即可.

举个例子, 想要抓取维基语录的HTML内容, 代码很简单:

url = 'https://zh.wikiquote.org/zh-cn/阿爾伯特·愛因斯坦'  
r = requests.get(url)  
html = r.text

requests.get()返回一个response对象r, 可以用r.ok或者r.status_code检查对象是否正常返回(status code=200).

编码问题

处理非英文网页时经常遇到的问题就是编码的问题了(不知道py3是不是对Unicode支持好一点?), 前面得到的html其实并非字符串而是Unicode对象:

>>> type(html)  
<type 'unicode'>

Unicode对象处理的时候一不小心就会得到以下的错误:

UnicodeEncodeError: 'ascii' codec can't encode characters in position 101-113: ordinal not in range(128)

所以在那些需要string类型的地方, 需要用encode函数转换:

>>> type(html.encode('utf-8'))  
<type 'str'>

另外实际中还遇到过比较奇葩的情况, 是返回的response的编码并不对(这个编码是requests根据网页内容自己推断的, 所以有时会出错), 比如这个网址, requests以为它的encoding是'ISO-8859-1', 所以为了保险起见, 最好手动指定r.encoding:

r.encoding = 'utf-8'

另: 还有一种经常用的解决utf编码的方式, 就是在文件开头加上这四句话:

# coding: utf-8  
import sys  
reload(sys)  
sys.setdefaultencoding('utf-8')  

不过, 看到有人说这种方式并不好, 所以最好别用这么暴力的方式吧...

用scrapy shell检查得到的html文件内容

需要注意的一点是, requests.get得到的html内容并不一定和在浏览器打开链接得到的内容相同!

为了检查是否得到了想要的html内容, 有两个方式, 一个是把得到的内容输出为一个.html文件, 然后用浏览器打开, 比如这样:

>>> with open('tmp.html', 'w') as f:  
...     f.write(html.encode('utf8')) # 注意要显式指定编码

这样做其实并不方便, 输出到本地文件以后还要用文件浏览器找到那个文件再打开, 而且打开的网页并没有图片, 也没有css样式.

我比较喜欢用scrapy shell这个工具, 这个工具在之前的文章也提到过, 它非常适合快速测试一些东西.

首先安装一下scrapy吧还是:  pip install scrapy

然后输入scrapy shell即可使用. 用fetch(url)可以把返回的结果存放在(scrapy shell默认的)response变量中, 可以把fetch操作理解为response = requests.get(url). 然后查看得到的html文件 只需要 view(response), 就会自动用浏览器打开下载的临时文件, 非常方便.

$ scrapy shell --nolog  
[s] Available Scrapy objects:  
[s]   scrapy     scrapy module (contains scrapy.Request, scrapy.Selector, etc)  
[s]   crawler    <scrapy.crawler.Crawler object at 0x7f8aa3b70e50>  
[s]   item       {}  
[s]   settings   <scrapy.settings.Settings object at 0x7f8aa3b70cd0>  
[s] Useful shortcuts:  
[s]   shelp()           Shell help (print this help)  
[s]   fetch(req_or_url) Fetch request (or URL) and update local objects  
[s]   view(response)    View response in a browser

In [1]: url = 'https://zh.wikiquote.org/zh-cn/阿爾伯特·愛因斯坦'

In [2]: fetch(url)

In [3]: view(response)  
Out[3]: True

对于有些网站, 直接用requests.get抓取会得到403forbidden错误, 这时就要修改一下get函数的headers参数了, 把一个Python字典传给headers参数, 这个字典理, 'user-agent'对应chrome/firefox使用的内容. 例子:

hea = {'User-Agent':'Mozilla/5.0 (Windows NT 6.3; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2272.118 Safari/537.36'}  
r = requests.get('http://jp.tingroom.com/yuedu/yd300p/', headers = hea)

headers参数对于那些不太好爬的网站非常有用, 不过关于如何知道往header里放什么东西, 需要用chrome-dev-tools, 这个后面再说.

lxml以及xpath语法

还是继续上面维基语录的例子, 假设现在已经获取了网页的html文件, 下一步就是在html文件里提取想要的内容了. 比如我们想要从维基语录上抓取爱因斯坦的所有名言.

从html中提取感兴趣的内容, 一种选择是用正则表达式, 不过正则表达式写起来太蛋疼了 — (?<=blablah).*(?=blah)之类的, 每次用都得从新查. 而且处理html代码时经常容易出错.

html语言可以看做是一种xml语言, 而xml语言其实是分层次的(可以parse为一个xml树), 操作xml元素的神器就是xpath语言了.

xpath基础语法

xpath的语法其实不难, 入门的话话二十分钟看看这里估计就差不多. 这里简单列一下:

选取节点的语法有:

  • / 从根节点选取, // 从所有匹配的节点选取
  • . 当前节点, .. 当前的父节点
  • nodename选取节点, @选取节点的属性
  • 通配符: *, 选取若干路径则用|分隔
  • text(): 获取该节点的文本内容

例子:

  • //img/@src: 选取所有img节点的src属性
  • //img/../text: 选取img节点的父节点下的text节点(所以text和img为"sibling"关系)
  • //*/@src: 选取任何节点的src属性

然后过滤节点的谓词语法有: (谓词放在方括号中)

  • [1]选取第一个元素, [last()]选取最后一个, [position<3] 选取前两个
  • [@lang="eng"] 选取属性lang等于"eng"的元素

遇到更复杂的xpath不会写的话 尝试翻译成英文然后Google一下, 几乎总会找到答案.

使用chrome-dev-tool获得元素的xpath

可以直接用chrome的开发者工具获取网页元素的xpath, 在该网页上按下crtl-shift-I就可以打开devtool了:

点击左上角那个指针的小图标, 然后再在网页上点击想要查找的元素, 就可以快速定位到它在html里对应的代码了:

在代码中点击右键, 可以得到xpath:

不过一般chrome找到的xpath并不具有通用性, 所以最好还是自己分析得到合适的xpath代码.

chrome给找到的xpath是//*[@id="mw-content-text"]/ul[1]/li[1], 经过分析和测试, //div[@id="mw-content-text"]/ul[position()<last()]/li/text()应该是比较正确的所有名言的xpath代码. 为了测试xpath, 可以直接在chrome-dev-tool里面按下ctrl-F查找xpath:

用lxml.etree操作xpath

学会了xpath, 接下来要在Python里使用xpath则需要lxml.

步骤是: 首先用网页html内容建立一个etree对象, 然后在使用它的xpath方法, 传入之前得到的xpath语句. 返回的结果为一个list, list里面就是所有匹配的元素了.

url = 'https://zh.wikiquote.org/zh-cn/阿爾伯特·愛因斯坦'  
r = requests.get(url)  
sel = etree.HTML(r.text)  
for quote in sel.xpath('//div[@id="mw-content-text"]/ul[position()<last()]/li/text()'):  
    print quote.strip()

xpath使用技巧

这里说一下xpath的实际使用技巧. 正好前面的代码也不完善, 结合这个例子来说.

  1. 先抓大再抓小

其实之前的xpath还有不完美的地方, 比如爱因斯坦的页面中有不少名言还有"原文"这一信息:

在一个li节点下面有可能还有东西, 所以我们可以先获得这一个个li元素, 然后再在每个li元素里面尝试查找"原文"的信息. 代码如下:

for li in sel.xpath('//div[@id="mw-content-text"]/ul[position()<last()]/li'):  
    quote = li.xpath('./text()')[0]  
    print quote.strip()  
    origin = li.xpath('./ul/li/span/i/text()')  
    if len(origin)>0: print 'origin:', origin[0]

更复杂的例子比如豆瓣电影的页面, 每一个电影的entry都有电影名/上映时间/国家等好多信息. 处理这样的页面, 必须要先把大的元素(整个电影信息的div)抓取, 然后再在每个大元素里分别提取信息.

  1. string()获得nested节点文字内容

上面的代码运行结果还有不满意的地方: 对于一些带有超链接的名言, 我们的程序不能获取那些带有超链接的文字, 比如这句话:

它的html代码是这样的:

<li>  
一个  
<a href="/w/index.php?title=%E5%BF%AB%E4%B9%90&amp;action=edit&amp;redlink=1" class="new" title="快乐(页面不存在)">快乐</a>  
的人总是满足与活于当下,而非浪费时间揣想<a href="/wiki/%E6%9C%AA%E6%9D%A5" title="未来">未来</a>  

</li>

如果直接用/text()处理的话, 只能得到"一个"这俩字... 问题出在这个元素是nested的, 里面嵌套了别的元素(两个<a>), 而这种情况还非常常见, 所以怎么办呢? 需要用xpath的string()函数, 它可以返回节点的正确字符串表示. 所以代码再次修改, quote的获取改为: quote = li.xpath('string(.)').

xpath里提供了蛮丰富的函数, 遇到比较复杂的操作的时候可以参考一下.

  1. 删除不想要的节点

进行了上面的修改, 又引入了新的问题: 对于那些有"原文"信息的li元素而言, 用string()函数的话会把这些原文信息也包括在内了, 这不是我们想要的结果. 比如这样的节点:

这时, 可以用lxml提供的remove函数, 在li节点中把不需要的节点先去掉, 然后再使用string()就不会有不需要的内容了.

最终的代码为:

for li in sel.xpath('//div[@id="mw-content-text"]/ul[position()<last()]/li'):  
    print '---'  
    origin = li.xpath('./ul')   
    badnodes = li.xpath('./ul') # remove 'origin' stuff in the li element  
    for bad in badnodes:   
        bad.getparent().remove(bad)  
    quote = li.xpath('string(.)')  
    print quote.strip()  
    if len(origin)>0:   
        print origin[0].xpath('string(.)').strip()  

动态页面/模拟登录: 善用chrome-dev-tools

上面的维基语录的例子还算比较简单, 对于那些需要动态加载的网页或者需要登录才可以查看的内容, 就需要多用chrome开发者工具了. 由于这方面要根据不同网站去试验(+猜测), 所以这里介绍的不会太详细...

一般来说, 对于动态加载的网页, 可以打开ctrl-shift-I打开devtools以后, 选择network标签页然后刷新, 在最开始的地方一般会有form提交(可以用requests.post模拟)或者url请求之类的东西, 一路追踪过去即可.

这里展示一下用cookies模拟登录微博的过程. weibo电脑版的页面太过凌乱, 用微博手机版(weibo.cn).

用dev-tools获取登录cookies

cookies就是一小段(加密后的)字符串, 它的大概是本地存储的保留用户信息的加密字符, 有的网站点选"下次自动登录"时, 其实就是生成了一个cookie保存在本地, 下次登录时只要向网站发送这串cookies字符, 如果cookies没有过期的话就可以直接登录了.

在要点击登录前, 打开devtools并选择network标签. 然后在登录以后, 找开头的几个requests, 定位到一个header带有cookie的request上面, cookie就在这里了(我试验发现, 好像需要登录以后再刷新一下, 这时dev-tools得到的cookies才是可用的):

另一种办法是用chrome自带的监测页面 , (设置capture→Include the actual bytes sent/received), 也可以得到cookies:

在requests里使用cookies

一旦获得了cookies字符串, 模拟登录就很简单: 在requests.get里传入headers参数:

import requests  
hea = {'Cookie':'_T_WM=3a52fbed11ed299552cf910553be7d3b; SUB=_2A251Y_geDeTxGedG6lUQ9SrKyj2IHXVWr5hWrDV6PUJbkdAKLUejkW1CLxUVXEMZZq8EFgsGuIYNqC6MqQ..; gsid_CTandWM=4uno88c512gBK6O5nyuKd7CIW9R'}  
url = 'http://weibo.cn'  
html = requests.get(url, headers = cook).content # use content instead of text   
print html  

保存爬取的内容

保存文本内容: csv

保存文本信息我一般喜欢放进csv里面, 而用pandas操作csv文件会比较方便: 在程序中, 把每一个抓取的条目(item)放进一个字典, 然后append到dataframe里面, 最后直接to_csv搞定.

下面是个简单的示意代码, 假设我们要抓取一些文章的title, date和发表地点三个信息:

import pandas as pd  
df = pd.DataFrame()  
# ...  
for item in __loop__:  
  #...  
  title, place, date = __code_for_extracting_these_fields__   
  #...  
  series = pd.Series({'title':title, 'place':place, 'date':date})  
  df = df.append(series, ignore_index=True)  
df = df[['title', 'date', 'place']] # adjust column order  
df.to_csv('melanthon.csv', index=False, encoding='utf-8')    

保存非文本内容

有些时候我们要下载图片/视频等非文本的信息, 我们可以用xpath定位到图片/视频的链接地址处, 那么下载到本地文件, 我查的有两个办法.

第一个方法简单粗暴: 用urlretrieve, 直接往函数里传入url和本地路径即可:

from urllib import urlretrieve    
urlretrieve(img_url, fpath)

另一个方法还是用requests, 用分片的方式获取文件(我猜这种更适合大文件的下载?):

resp = requests.get(url, stream=True)  
f = open(fpath, 'wb')  
for chunk in resp.iter_content(chunk_size=1024):  
       if chunk: # filter out keep-alive new chunks  
           f.write(chunk)  
f.close()

并行下载

在下载大文件的时候可以非常明显感受到, 下载文件的过程占据了大部分程序的执行时间.
比较简单的加速办法就是, 先把所有要下载的文件url(以及本地保存的fpath)放进一个list里, 最后在一起下载, 这时就可以使用Python的多进程模块进行加速了.

核心的代码只其实就是pool.map, 把爬去的函数map到要爬的url列表上:

from multiprocessing.dummy import Pool  
pool = Pool(4)  
results = pool.map(crawl_func, urls_list)  
pool.close()  
pool.join()

下面是个实际的例子, 首先定义了一个download函数用于下载视频, 然后download_videos函数, 多线程下载视频.

def download((url, fpath), headers={}):  
    fname = os.path.split(fpath)[-1]  
    print 'start downloading %s ...' % fname  
    with open(fpath, 'wb') as f:  
        while 1:  
            resp = requests.get(url, stream=True, headers=headers); time.sleep(1.0)  
            if resp.ok: break  
            print resp.status_code  
        for chunk in resp.iter_content(chunk_size=1024):  
            if chunk: # filter out keep-alive new chunks  
                f.write(chunk)  
    print 'download finished: %s' % fpath  

def download_videos(video_urls_list):# input = list of (url,fpath) pairs  
    print 'downloading %d files in parallel...' % len(video_urls_list)  
    from multiprocessing import Pool  
    pool = Pool(processes=4)  
    pool.map(download, video_urls_list)  
    pool.close()  
    pool.join()      
    print 'all downloading finished !'  

最后, 我写了一个极客学院课程视频的下载脚本, 用cookies模拟登录. 一百来行的代码, 跑一晚上可以下载好几十G的视频...
gist放在: https://gist.github.com/X-Wei/46817a6614e3677391ab13e420b4cb9f (不过这里用的cookies早就过期了)

comments powered by Disqus