使用 Python 轻松抓取网页

[ 翻译自英文原文:Easy Web Scraping with Python ]

一年多以前我写了一篇文章「web scraping using Node.js」。今天我重新回顾了这个话题,但是这一次我将使用 Python,这样这两种语言所提供的技术就能进行对比和比较。

问题

我敢肯定你知道,我在本月初参加了在蒙特利尔举办的 PyCon 大会。所有的演讲和教程的视频都已经发布到 YouTube 上了,目录在 pyvideo.org

我认为知道这个大会上的哪些视频最受欢迎将会是非常有用的,所以我们将要写一个爬虫脚本。这个脚本将会从 pyvideo.org 上获取有效的视频列表,然后从每个 YouTube 页面获取观看的统计数据。听起来很有趣?让我们开始吧!

工具

在抓取网站中有两个基本的任务:

  1. 加载网页到一个 string 里。
  2. 从网页中解析 HTML 来定位感兴趣的位置。

Python 为上面两个任务提供了两个超棒的工具。我将使用 requests 去加载网页,用 BeautifulSoup 去做解析。

我们可以把上面两个包放到一个虚拟环境:

$ mkdir pycon-scraper
$ virtualenv venv
$ source venv/bin/activate
(venv) $ pip install requests beautifulsoup4

如果使用的是 Windows 操作系统,注意上面虚拟环境的激活命令是不同的,你应该使用venv\Scripts\activate

基本的抓取技术

在写一个爬虫脚本时,第一件事情就是手动观察要抓取的页面来确定数据如何定位。

首先,我们要看一看在 http://pyvideo.org/category/50/pycon-us-2014 上的 PyCon 大会视频列表。检查这个页面的 HTML 源代码我们发现视频列表的结果差不多是长这样的:

<div id="video-summary-content">
<div class="video-summary"> <!-- first video -->
<div class="thumbnail-data">...</div>
<div class="video-summary-data">
<div>
<strong><a href="#link to video page#">#title#</a></strong>
</div>
</div>
</div>
<div class="video-summary"> <!-- second video -->
...
</div>
...
</div>

那么第一个任务就是加载这个页面,然后抽取每个单独页面的链接,因为到 YouTube 视频的链接都在这些单独页面上。

使用requests来加载一个 web 页面是非常简单的:

import requests
response = requests.get('http://pyvideo.org/category/50/pycon-us-2014')

就是它!在这个函数返回后就能从response.text中获得这个页面的 HTML 。

下一个任务是抽取每一个单独视频页面的链接。通过 BeautifulSoup 使用 CSS 选择器语法就能完成它,如果你是客户端开发者的话你可能对这会很熟悉。

为了获得这些链接,我们要使用一个选择器,它能抓取在每一个 id 为video-summary-data<div>中所有的<a>元素。由于每个视频都有几个<a>元素,我们将只保留那些 URL 以/video开头的<a>元素,这些就是唯一的单独视频页面。实现上述标准的 CSS 选择器是div.video-summary-data a[href^=/video]。下面的代码片段通过 BeautifulSoup 使用这个选择器来获得指向视频页面的<a>元素:

import bs4
soup = bs4.BeautifulSoup(response.text)
links = soup.select('div.video-summary-data a[href^=/video]')

因为我们真正关心的是这个链接本身而不是包含它的<a>元素,我们可以使用列表解析来改善上述代码。

links = [a.attrs.get('href') for a in soup.select('div.video-summary-data a[href^=/video]')]

现在,我们已经有了一个包含所有链接的数组,这些链接指向了每个单独页面。

下面这段脚本整理了目前我们提到的所有技术:

import requests
import bs4

root_url = 'http://pyvideo.org'
index_url = root_url + '/category/50/pycon-us-2014'

def get_video_page_urls():
response = requests.get(index_url)
soup = bs4.BeautifulSoup(response.text)
return [a.attrs.get('href') for a in soup.select('div.video-summary-data a[href^=/video]')]

print(get_video_page_urls())

如果你运行上面这段脚本你将会获得一个满是 URL 的数组。现在我们需要去解析每个 URL 以获得更多关于每场 PyCon 会议的信息。

抓取相连页面

下一步是加载我们的 URL 数组中每一个页面。如果你想要看看这些页面长什么样的话,这儿是个样例:http://pyvideo.org/video/2668/writing-restful-web-services-with-flask。没错,那就是我,那是我会议中的一个!

从这些页面我们可以抓取到会议的标题,在页面的顶部能看到它。我们也可以从侧边栏获得演讲者的姓名和 YouTube 的链接,侧边栏在嵌入视频的右下方。获取这些元素的代码展示在下方:

def get_video_data(video_page_url):
video_data = {}
response = requests.get(root_url + video_page_url)
soup = bs4.BeautifulSoup(response.text)
video_data['title'] = soup.select('div#videobox h3')[0].get_text()
video_data['speakers'] = [a.get_text() for a in soup.select('div#sidebar a[href^=/speaker]')]
video_data['youtube_url'] = soup.select('div#sidebar a[href^=http://www.youtube.com]')[0].get_text()

关于这个函数需要注意的一些事情:

  • 从首页抓取的 URL 是相对路径,所以root_url需要加到前面。
  • 大会标题是从 id 为videobox<div>里的<h3>元素中获得的。注意[0]是必须的,因为调用select()返回的是一个数组,即使只有一个匹配。
  • 演讲者的姓名和 YouTube 链接的获取方式与首页上的链接获取方式类似。

现在就剩下从每个视频的 YouTube 页面抓取观看数了。接着上面的函数写下去其实是非常简单的。同样,我们也可以抓取 like 数和 dislike 数。

def get_video_data(video_page_url):
# ...
response = requests.get(video_data['youtube_url'])
soup = bs4.BeautifulSoup(response.text)
video_data['views'] = int(re.sub('[^0-9]', '',
soup.select('.watch-view-count')[0].get_text().split()[0]))
video_data['likes'] = int(re.sub('[^0-9]', '',
soup.select('.likes-count')[0].get_text().split()[0]))
video_data['dislikes'] = int(re.sub('[^0-9]', '',
soup.select('.dislikes-count')[0].get_text().split()[0]))
return video_data

上述调用soup.select()函数,使用指定了 id 名字的选择器,采集到了视频的统计数据。但是元素的文本需要被处理一下才能变成数字。考虑观看数的例子,在 YouTube 上显示的是"1,344 views"。用一个空格分开(split)数字和文本后,只有第一部分是有用的。由于数字里有逗号,可以用正则表达式过滤掉任何不是数字的字符。

为了完成爬虫,下面的函数调用了之前提到的所有代码:

def show_video_stats():
video_page_urls = get_video_page_urls()
for video_page_url in video_page_urls:
print get_video_data(video_page_url)

并行处理

上面到目前为止的脚本工作地很好,但是有一百多个视频它就要跑个一会儿了。事实上我们没做什么工作,大部分时间都浪费在了下载页面上,在这段时间脚本时被阻塞的。如果脚本能同时跑多个下载任务,可能就会更高效了,是吗?

回顾当时写一篇使用 Node.js 的爬虫文章的时候,并发性是伴随 JavaScript 的异步特性自带来的。使用 Python 也能做到,不过需要显示地指定一下。像这个例子,我将开启一个拥有8个可并行化进程的进程池。代码出人意料的简洁:

from multiprocessing import Pool

def show_video_stats(options):
pool = Pool(8)
video_page_urls = get_video_page_urls()
results = pool.map(get_video_data, video_page_urls)

multiprocessing.Pool 类开启了8个工作进程等待分配任务运行。为什么是8个?这是我电脑上核数的两倍。当时实验不同大小的进程池时,我发现这是最佳的大小。小于8个使脚本跑的太慢,多于8个也不会让它更快。

调用pool.map()类似于调用常规的map(),它将会对第二个参数指定的迭代变量中的每个元素调用一次第一个参数指定的函数。最大的不同是,它将发送这些给进程池所拥有的进程运行,所以在这个例子中八个任务将会并行运行。

节省下来的时间是相当大的。在我的电脑上,第一个版本的脚本用了75秒完成,然而进程池的版本做了同样的工作只用了16秒!

完成爬虫脚本

我最终版本的爬虫脚本在获得数据后还做了更多的事情。

我添加了一个--sort命令行参数去指定一个排序标准,可以指定views,likes或者dislikes。脚本将会根据指定属性对结果数组进行递减排序。另一个参数,--max代表了要显示的结果数的个数,万一你只想看排名靠前的几条而已。最后,我还添加了一个--csv选项,为了可以轻松地将数据导到电子制表软件中,可以指定数据以 CSV 格式打印出来,而不是表对齐格式。

完整脚本显示在下方:

import argparse
import re
from multiprocessing import Pool
import requests
import bs4

root_url = 'http://pyvideo.org'
index_url = root_url + '/category/50/pycon-us-2014'

def get_video_page_urls():
response = requests.get(index_url)
soup = bs4.BeautifulSoup(response.text)
return [a.attrs.get('href') for a in soup.select('div.video-summary-data a[href^=/video]')]

def get_video_data(video_page_url):
video_data = {}
response = requests.get(root_url + video_page_url)
soup = bs4.BeautifulSoup(response.text)
video_data['title'] = soup.select('div#videobox h3')[0].get_text()
video_data['speakers'] = [a.get_text() for a in soup.select('div#sidebar a[href^=/speaker]')]
video_data['youtube_url'] = soup.select('div#sidebar a[href^=http://www.youtube.com]')[0].get_text()
response = requests.get(video_data['youtube_url'])
soup = bs4.BeautifulSoup(response.text)
video_data['views'] = int(re.sub('[^0-9]', '',
soup.select('.watch-view-count')[0].get_text().split()[0]))
video_data['likes'] = int(re.sub('[^0-9]', '',
soup.select('.likes-count')[0].get_text().split()[0]))
video_data['dislikes'] = int(re.sub('[^0-9]', '',
soup.select('.dislikes-count')[0].get_text().split()[0]))
return video_data

def parse_args():
parser = argparse.ArgumentParser(description='Show PyCon 2014 video statistics.')
parser.add_argument('--sort', metavar='FIELD', choices=['views', 'likes', 'dislikes'],
default='views',
help='sort by the specified field. Options are views, likes and dislikes.')
parser.add_argument('--max', metavar='MAX', type=int, help='show the top MAX entries only.')
parser.add_argument('--csv', action='store_true', default=False,
help='output the data in CSV format.')
parser.add_argument('--workers', type=int, default=8,
help='number of workers to use, 8 by default.')
return parser.parse_args()

def show_video_stats(options):
pool = Pool(options.workers)
video_page_urls = get_video_page_urls()
results = sorted(pool.map(get_video_data, video_page_urls), key=lambda video: video[options.sort],
reverse=True)
max = options.max
if max is None or max > len(results):
max = len(results)
if options.csv:
print(u'"title","speakers", "views","likes","dislikes"')
else:
print(u'Views +1 -1 Title (Speakers)')
for i in range(max):
if options.csv:
print(u'"{0}","{1}",{2},{3},{4}'.format(
results[i]['title'], ', '.join(results[i]['speakers']), results[i]['views'],
results[i]['likes'], results[i]['dislikes']))
else:
print(u'{0:5d} {1:3d} {2:3d} {3} ({4})'.format(
results[i]['views'], results[i]['likes'], results[i]['dislikes'], results[i]['title'],
', '.join(results[i]['speakers'])))

if __name__ == '__main__':
show_video_stats(parse_args())

下方输出的是在我写完代码时前25个观看数最多的会议:

(venv) $ python pycon-scraper.py --sort views --max 25 --workers 8
Views  +1  -1 Title (Speakers)
 3002  27   0 Keynote - Guido Van Rossum (Guido Van Rossum)
 2564  21   0 Computer science fundamentals for self-taught programmers (Justin Abrahms)
 2369  17   0 Ansible - Python-Powered Radically Simple IT Automation (Michael Dehaan)
 2165  27   6 Analyzing Rap Lyrics with Python (Julie Lavoie)
 2158  24   3 Exploring Machine Learning with Scikit-learn (Jake Vanderplas, Olivier Grisel)
 2065  13   0 Fast Python, Slow Python (Alex Gaynor)
 2024  24   0 Getting Started with Django, a crash course (Kenneth Love)
 1986  47   0 It's Dangerous to Go Alone: Battling the Invisible Monsters in Tech (Julie Pagano)
 1843  24   0 Discovering Python (David Beazley)
 1672  22   0 All Your Ducks In A Row: Data Structures in the Standard Library and Beyond (Brandon Rhodes)
 1558  17   1 Keynote - Fernando Pérez (Fernando Pérez)
 1449   6   0 Descriptors and Metaclasses - Understanding and Using Python's More Advanced Features (Mike Müller)
 1402  12   0 Flask by Example (Miguel Grinberg)
 1342   6   0 Python Epiphanies (Stuart Williams)
 1219   5   0 0 to 00111100 with web2py (G. Clifford Williams)
 1169  18   0 Cheap Helicopters In My Living Room (Ned Jackson Lovely)
 1146  11   0 IPython in depth: high productivity interactive and parallel python (Fernando Perez)
 1127   5   0 2D/3D graphics with Python on mobile platforms (Niko Skrypnik)
 1081   8   0 Generators: The Final Frontier (David Beazley)
 1067  12   0 Designing Poetic APIs (Erik Rose)
 1064   6   0 Keynote - John Perry Barlow (John Perry Barlow)
 1029  10   0 What Is Async, How Does It Work, And When Should I Use It? (A. Jesse Jiryu Davis)
  981  11   0 The Sorry State of SSL (Hynek Schlawack)
  961  12   2 Farewell and Welcome Home: Python in Two Genders (Naomi Ceder)
  958   6   0 Getting Started Testing (Ned Batchelder)

结论

我希望这篇文章作为使用 Python 抓取网页的入门介绍对你是有帮助的。我在使用 Python 的过程中一直很惊喜,这些工具健壮又强大,而且相比于 JavaScript 的一个事实是异步调优可以放在最后,而对于 JavaScript 你不可能避免从一开始就工作在异步模式下

————————————————- 原 文 完 —————————————————-

爬虫翻墙

但是如果我们直接运行上面那段最终代码的话是妥妥的会超时报错的。原因你肯定知道,就是不可逾越的长城。一般 Pyvideo.org 是能上去,但是 YouTube 被墙了。所以这里就需要代理出马了。最简单的可以安装 GoAgent 。在本地运行 GoAgent 后,使用 requests.get() 的 proxies 参数,将代理设置成本地的 127.0.0.1 端口为 8087 ,爬虫就能够通过代理访问网页了。使用的代码如下:

proxy = {"http":"http://127.0.0.1:8087","https":"https://127.0.0.1:8087"}
response = requests.get(video_data['youtube_url'],proxies = proxy,verify=False)

将最终版本的代码的第22行response = requests.get(video_data['youtube_url'])替换成上面的代码即可。如果 Pyvideo.org 也上不了(校园网有时候就是这么抽风),就把 proxy 设成全局的,在所有调用 requests.get() 的地方设置 proxies 参数即可。

你可能会疑惑 verify 参数是干嘛的。这是对 HTTPS 请求做 SSL 验证的(YouTube 是 HTTPS 连接)。调用requests.get()的时候默认verify参数为True,就是会进行验证。如果我们通过代理爬取站点,SSL 验证一般是不会通过的,会返回 [SSL: CERTIFICATE_VERIFY_FAILED] 错误。所以需要将verify关闭。