0 准备工作
这篇文章将以爬取IMDb(Internet Movie Database)著名的TOP 250电影榜单为例,简单叙述用Python编写一个网络爬虫的方法和内在逻辑。
首先我们来看一下这个电影榜单的样子。
IMDb TOP 250可以看到,这个榜单总共有250条记录,每条记录由排名和题名、IMDb评分和我的评分三列构成的。在本例中,我们只对排名,题名,年份,IMDb评分感兴趣,并将在下面的工作中,试图把它们导出到CSV文件。阅读过拙作《使用pandas-datareader包下载雅虎财经股价数据》的朋友们,可能会对如何将DataFrame类型导出到CSV文件有一些印象。我们将在本篇再次应用该方法。
在正式开始工作之前,你要对你所看到的网页是怎样形成的有一个不多不少的了解。比如,你要知道你正在阅读的内容,它的形状,它的颜色,它的样式,是由一行行的代码,而非我们所熟知的类似于MS WORD的所见即所得的编写形式表现的。在主流浏览器,如Google Chrome中,你只需要轻按‘Ctrl + U’组合键,即可查看当前网页源代码。
以下是榜单界面的部分源代码。
IMDb TOP 250 Source Code非常丑。
再比如,你要知道浏览器的网址栏里的那行网址,它有一个比较正式的名字叫做URL. 我们可以发现,对于任意一个URL,都有一段源代码与其对应。这段源代码或长或短,而且可能用不同语言编写而成,但是在我们面前,它只是一个有待我们处理的字符串,如鱼在砧。
对于网页的形成,你只需要了解到这里,你甚至不需要知道网页是什么语言编写成的,就可以开始工作了。下面,打开你的Python编辑器,并在你的浏览器(如Google Chrome)中打开IMDb TOP 250电影榜单,你便做好了所有的准备工作。
1 获取网页源代码
获取网页源代码有很多种方法,下面记录一种我在知名MOOC——《Udacity CS101 计算机科学导论》中学到的方法,即使用urllib2包。
import urllib2
def get_page(url):
try:
return urllib2.urlopen(url).read()
except:
return ''
在这段代码中,我们定义了一个叫做get_page的函数,它的作用是将我们传入的URL的源代码返回,返回类型为string.
我们在定义get_page函数时,用到了try/except语句。该语句的作用是,先执行try后面的语句,如果这些语句出现了任何error,则执行except后面的语句。此例中,如果urllib2.urlopen(url).read()
函数未能正确运行,get_page函数将返回一个空的字符串''。
用这个函数来试着获取榜单的源代码,并将它保存到一个名为page的变量中。
page = get_page('http://www.imdb.com/chart/top?ref_=nv_ch_250_4')
2 观察源代码所包含信息的规律
page现在就是我们要进行处理的字符串了,它包括了所有我们需要的排名、题名、年份和评分信息。我们现在要观察这些信息处于源代码的哪些位置。
仍然使用你的Google Chrome浏览器,使用'Ctrl + U'组合键,查看榜单网页的源代码。使用'Ctrl + F'组合键,呼出查找命令,帮助你探索这些信息的规律。
我们记得,排在榜单第一名的是1994年犯罪剧情电影《肖申克的救赎》,因此我们在查找框中查找关键词‘shawshank’,可以看到,这个关键词在整个网页中只出现过一次。
查找结果这个关键词直接定位到了《肖申克的救赎》出现的位置。我们可以观察一下包含了《肖申克的救赎》电影信息的数十行代码。
...
<td class="titleColumn">
1.
<a href="/title/tt0111161/?pf_rd_m=A2FGELUUNOQJNL&pf_rd_p=2398042102&pf_rd_r=0HCQTT49N5CQ4Y7B95CP&pf_rd_s=center-1&pf_rd_t=15506&pf_rd_i=top&ref_=chttp_tt_1"
title="Frank Darabont (dir.), Tim Robbins, Morgan Freeman" >The Shawshank Redemption</a>
<span class="secondaryInfo">(1994)</span>
</td>
<td class="ratingColumn imdbRating">
<strong title="9.2 based on 1,794,787 user ratings">9.2</strong>
</td>
<td class="ratingColumn">
<div class="seen-widget seen-widget-tt0111161 pending" data-titleid="tt0111161">
<div class="boundary">
<div class="popover">
<span class="delete"> </span><ol><li>1<li>2<li>3<li>4<li>5<li>6<li>7<li>8<li>9<li>10</ol> </div>
</div>
<div class="inline">
<div class="pending"></div>
<div class="unseeable">NOT YET RELEASED</div>
<div class="unseen"> </div>
<div class="rating"></div>
<div class="seen">Seen</div>
</div>
</div>
</td>
<td class="watchlistColumn">
...
仔细观察后我们发现,这一段源代码不但包含了我们所需的排名、题名、年份、评分等信息,还包含了导演、主演、IMDb索引号、评分数等信息。这真是一个惊喜,我决定将导演和IMDb索引号也爬下来。所以现在我们关注的数据有排名、题名、年份、导演、评分、索引号等六个。我们可以发现以下规律。
-
排名出现在
...<td class="titleColumn">1.<a href="/title/tt0111161/?pf_rd_...
这段代码中。但我们发现,对于这整个榜单,每一部电影的排名就是它出现在网页中的顺序,因此,我决定偷一个懒,不对排名进行处理。但是,通过'Ctrl + F'组合键,对'titleColumn'进行查找后,可以发现整段源代码中,只有250个'titleColumn',因此我们可以认为,'titleColumn'字符串标记着每一部电影的信息的开始。 -
题名、导演出现在
<a href="/title/tt0111161/?pf_rd_m=A2FGELUUNOQJNL&pf_rd_p=2398042102&pf_rd_r=0HCQTT49N5CQ4Y7B95CP&pf_rd_s=center-1&pf_rd_t=15506&pf_rd_i=top&ref_=chttp_tt_1" title="Frank Darabont (dir.), Tim Robbins, Morgan Freeman" >The Shawshank Redemption</a>
这段代码中。我们可以观察到,导演"Frank Darabont"的名字后面标注有"(dir.)",题名则出现在导演、主演之后和'</a>'之前。 -
年份信息出现在
<span class="secondaryInfo">(1994)</span>
这一行代码中,它被包含在括号里。用来标记它位置的关键词是"secondaryInfo". -
评分信息出现在
<strong title="9.2 based on 1,794,787 user ratings">9.2</strong>
这一行代码中,用来标记它位置的关键词是"user ratings". -
IMDb索引号出现在
<div class="seen-widget seen-widget-tt0111161 pending" data-titleid="tt0111161">
这一行代码中,用来标记它位置的关键词是"data-titleid".
3 爬取第一个电影信息
我们首先来想一个简单的思路:IMDb TOP 250榜单中有250部电影,我们要想出爬取一部电影的方法,使之循环250次,来爬取所有的电影。于是开始试着爬取第一部电影《肖申克的救赎》的影片信息。
第一步,我们要定位《肖申克的救赎》这部电影的电影信息开始的位置,即找到之前确定的关键词"titleColumn"。
在这里,要简单讲一下string字符型的两个使用方法。
-
find方法。以
str1.find('titleColumn')
为例,这个命令返回的是'titleColumn'这个字符串在page这个字符串中第一次出现的位置,如果str1的值是'2tnkl09a?"{U(EFtitleColumn9fq309v-sjjj='
,那么,str1.find('titleColumn')
将返回15(字符串位置的排序是从0开始的,如str1[0]的值是'2')。 -
截取string变量的某一段,如
str1[3:10]
,它返回的是str1这个字符串从第4位和第11位中间的字符串,包含第4位,但不包含第11位。特别地,str1[3:]
返回的是str1从第4位开始一直到最后的字符串,str1[:10]
返回的是str1从头开始一直到第10位的字符串,str1[3:-1]
返回的是str1从第4位开始一直到倒数第2位的字符串。
到此为止,你已经对text processing有了一定的了解,这已经足够我们进行下面的工作了。
page = page[page.find('titleColumn'):]
这段代码的含义是,首先找到'titleColumn'在字符串page中第一次出现的位置,然后截取从这个位置开始一直到结尾的字符串片段,并覆盖到变量page上。之所以对page进行覆盖操作,是因为我们对'titleColumn'之前的字符串一律不感兴趣。
现在我们将以导演信息为例,应用text processing方法。
导演Frank Darabont的名字,出现在'titleColumn'这一字符串出现后,第一个'title="'出现的位置之后的第7个位置,和' (dir.)'字符串出现之前。因此,我们可以通过find方法来确定这两个标志字符串的位置,并通过这两个位置来找到导演的名字。
d_name = page[page.find('title="') + 7:page.find(' (dir.)')]
print d_name
这里我们使用page.find('title="') + 7
,是因为希望使截取字符串片段从't'往右移七位处开始,即导演Frank Darabont的'F'所在的位置。
将截取的字符串片段赋值给变量d_name并查看打印结果。
Frank Darabont
我们使用我们掌握的信息规律、关键词和text processing方法,可以一一爬取其他我们需要的信息,下面是爬取《肖申克的救赎》所有目标信息的方法。
# director
page = page[page.find('titleColumn'):]
d_name = page[page.find('title="') + 7:page.find(' (dir.)')]
# title
page = page[page.find(d_name):]
m_name = page[page.find('>') + 1:page.find('</a>')]
# year
page = page[page.find('secondaryInfo'):]
year = page[page.find('(') + 1:page.find(')')]
# rating
page = page[page.find('user ratings'):]
rating = page[page.find('>') + 1:page.find('</strong>')]
rating = float(rating)
# imdb_id
page = page[page.find('data-titleid'):]
imdb_id = page[page.find('"') + 1:page.find('">')]
print m_name
print year
print d_name
print rating
print imdb_id
由于评分rating本身应该是一个浮点型数据,但是我们爬取的rating是string类型数据,因此使用函数float(rating)
来使其转化为float类型。这样在之后我们可以通过Python对其进行数值运算。
观察一下输出的内容。
The Shawshank Redemption
1994
Frank Darabont
9.2
tt0111161
《肖申克的救赎》,出品于1994年,导演为Frank Darabont,IMDb评分9.2,IMDb中的ID是tt0111161.
到此为止,除了排名(rank)以外(我们知道是第一),我们已经爬取了所有我们感兴趣的关于影片《肖申克的救赎》的信息。
4 爬取榜单所有电影信息
我们之前的代码爬取了榜单中排名第一的电影《肖申克的救赎》的信息,接下来我们要做的工作是使我们之前的代码通用化,以爬取榜单每一个电影的信息。
我们掌握了一个信息是这个榜单有250个电影,所以可以使用循环语句,使其运行250次(我们将在最后讨论如果我们不知道榜单有多少电影的情况),并将所有的信息分别写入建好的list中。
def get_info(page, d_list=[], m_list=[], y_list=[], r_list=[], id_list=[]):
# director
page = page[page.find('titleColumn'):]
d_name = page[page.find('title="') + 7:page.find(' (dir.)')]
d_list.append(d_name)
# title
page = page[page.find(d_name):]
m_name = page[page.find('>') + 1:page.find('</a>')]
m_list.append(m_name)
# year
page = page[page.find('secondaryInfo'):]
year = page[page.find('(') + 1:page.find(')')]
y_list.append(year)
# rating
page = page[page.find('user ratings'):]
rating = page[page.find('>') + 1:page.find('</strong>')]
rating = float(rating)
r_list.append(rating)
# imdb_id
page = page[page.find('data-titleid'):]
imdb_id = page[page.find('"') + 1:page.find('">')]
id_list.append(imdb_id)
# return uncrawled page
return page
这个function有六个参数,其中一个是源代码段page,另外五个是五个list类型的参数。
首先来看返回值,这个function的返回值是经过修剪后的新page,如果我们不返回这一变量,实参page的值在循环中将不会因为形参page的值发生变化而发生变化。当page第二次查找'titleColumn'这一字符串时,将不会定位在电影《教父》的代码段附近,而是仍定位在《肖申克的救赎》代码段的附近。因此我们需要返回一个变化后的page值,来覆盖掉之前的page值。
list这种数据类型具有一个Attribute叫做append. 如d_list.append(d_name)
这个命令,是将d_name的值作为一个新的list元素,添加在list的最末端。
list这种数据类型,它有一个指针的性质。举例来说,对于传入function的int,float,string等参数来说,传入的只是这些参数的值(将值赋给形参),并不会影响function外变量的值。但是传入list后,对于list的修改,实际上是对于list所指向地址的值的修改,因此传入function中的list(形参),如果被function所影响,function外的list(实参)也将发生改变。这是因为将list的值赋给形参后,形参和实参指向的是一个地址,代表的是同一个列表。
通过这个性质,我们可以建立5个空的list类型变量,并将它们传入get_info中,如此循环250次,就可以获取所有电影信息。
d_list=[]
m_list=[]
y_list=[]
r_list=[]
id_list=[]
for _ in range(0, 250):
page = get_info(page, d_list, m_list, y_list, r_list, id_list)
在以上代码中,我们建立了5个空的列表,来存放五种不同数据,并写了一个循环来爬取250个电影信息。
for循环语句for _ in range(0, 250):
中,'_'可以理解为一个匿名变量,它的唯一作用就是作为循环子,因为在for循环要执行的语句中,它并没有什么作用,所以我们不想给它起名字,这种情况下,可以使用'_'作为匿名变量。
`range(0, 250)'这个函数返回的是一个list,它从0开始,每次加一,到249截止,长度为250。在这里,它的实际数值也没有意义,只要它的长度是250,就可以保证循环执行250次。
循环语句只有一句话,把get_info返回的修剪过的新page,覆盖原来的page,但实际上,由于list的指针性质,所有的list都发生了改变。
如果我们不知道循环次数是250, 我们可以使用while循环。当所有电影都爬完了以后,剩下的page中将不再含有关键字'titleColumn',因此,只要每次循环之前,用while语句判断'titleColumn'是否存在于字符串之中即可。
改良后的循环如下。
while 'titleColumn' in page:
page = get_info(page, d_list, m_list, y_list, r_list, id_list)
5 整合、查看和导出数据
在拙作《使用pandas-datareader包下载雅虎财经股价数据》中,我讨论过pandas包的部分用法,在这里,我将介绍一下如何使用Python的一大数据结构——字典,来生成DataFrame类型变量。
字典是一种一对一的映射数据结构,它的每一个元素皆由两部分组成,即key和value. key是查找到value的唯一索引。
对于key和value可以是什么,字典的要求十分松散。在此例中,我们需要建立一个形式为字典的数据类型。它的每一个元素的key是列名,而value是这一列的值。
data = {'Title':m_list, 'Year':y_list, 'Director':d_list,
'Rating':r_list, 'IMDb ID':id_list}
在这里,我们自己建立一个rank列表,将它的key设为‘Rank’并加入到data字典中。
data['Rank'] = range(1, 251)
到这里为止,我们需要的所有数据,都已经包含在了字典data中。pandas有一个从字典生成DataFrame的方法,它对字典的要求是作为字典中value的列表是等长的。我们的字典中,每一个列表长度都是250. 因此可以使用该方法。
import pandas as pd
dataset = pd.DataFrame(data, columns=['Rank', 'Title', 'Year',
'Director', 'Rating', 'IMDb ID'])
print dataset.head()
在码代码时,一个列表中间,是可以换行的。但有一些不能换行的情况,我们将在以后讨论。
在这个函数中,columns参数是一个字典各key值组成的列表,长度为列数,规定了每一列出现的顺序。如果不规定columns参数,生成的DataFrame的列将根据字典默认顺序排列,字典默认顺序一般情况下并不是我们的理想顺序。
观察所得数据的前六行。
Rank Title Year Director Rating \
0 1 The Shawshank Redemption 1994 Frank Darabont 9.2
1 2 The Godfather 1972 Francis Ford Coppola 9.2
2 3 The Godfather: Part II 1974 Francis Ford Coppola 9.0
3 4 The Dark Knight 2008 Christopher Nolan 8.9
4 5 12 Angry Men 1957 Sidney Lumet 8.9
IMDb_ID
0 tt0111161
1 tt0068646
2 tt0071562
3 tt0468569
4 tt0050083
最后,使用to_csv命令,将得到的结果导入到CSV文件。
dataset.to_csv('.\out\imdb250.csv', index=False)
index参数设置为False,即使index列(0, 1, 2, 3, 4...)不输入到CSV文件中。
其实,针对网页的源代码,已有前辈写好如Beautiful Soup的功能包供方便地使用,但是十分惭愧,我还没有试用过。
此例还有一个方法是使用RegEx,可以借助软件RegExBuddy简单地进行处理,之后将有一篇笔记专门介绍这种方法。
这个爬虫程序是我写的第一个爬虫程序,它的特点就是非常Naive,逻辑容易理解,运行速度也非常快。其核心部分就是观察我们感兴趣的信息的规律(颇有RegEx的感觉)。我也将在之后爬取EDGAR数据库信息的笔记中讨论在爬取数据时翻页的操作。如果您发现任何问题或有任何疑问,欢迎指正或讨论。
by JohnnyMOON
COB @UIUC
EM: gengyug2@illinois.edu
网友评论