《用python写网络爬虫》--网页抓取方法

来源:互联网 发布:虚拟机软件有哪些 知乎 编辑:程序博客网 时间:2024/06/08 07:08

目标:

掌握三种网页抓取的方法:正则表达式、BeautifulSoup模块,lxml模块

1.正则表达式

首先确定目标网页:http://example.webscraping.com/view/United-Kingdom-239,抓取目标:网页中的面积数据

import urllibfrom urllib.request import urlopenimport reimport urllib.errordef download(url):    print('Downloading:',url)    try:        request=urllib.request.Request(url)        response=urllib.request.urlopen(request)        html=response.read().decode('utf-8')    except urllib.error.URLError as e:        print('Download error:',e.reason)        html=None    return htmlurl='http://example.webscraping.com/view/United-Kingdom-239'html=download(url)# print(html)pattern=re.findall('<td class="w2p_fw">(.*?)</td>',html)print(pattern)

这样的代码得到的部分结果是:
['<img src="/places/static/images/flags/gb.png" />', '244,820 square kilometres', '62,348,447', 'GB', 'United Kingdom', 'London', '<a href="/continent/EU">EU</a>', '.uk', 'GBP', 'Pound', '44', '@# #@@|@## #@@|@@# #@@

可以看到多个国家属性都用到了'<td class="w2p_fw">标签,要想分离出面积属性,我们可以只选择其中的第二个元素,如下所示:
pattern=re.findall('<td class="w2p_fw">(.*?)</td>',html)[1]

此时得到的结果:
244,820 square kilometres

虽然现在可以使用这个方案,但是如果网页发生变化,该方案很可能就会失效。比如表格发生了变化,去除了第二行中的国土面积数据。如果我们只是现在抓取数据,就可以忽略这种未来可能发生的变化,但是,如果我们希望未来还能再次抓取该数据,就需要给出更加健壮的解决方案,从而尽可能避免这种布局变化所带来的问题。想要该正则表达式更加健壮,我们可以将其父元素<tr>也加入进来。由于该元素具有ID属性,所以应该是唯一的。

pattern=re.findall('<tr id="places_area__row">.*?<td class="w2p_fw">(.*?)</td>',html)

可以得到相同的结果

虽然这个正则表达式更容易适应未来的变化,但又存在着难以构造、可读性差的问题,此外,还有一些微小的布局变化也会使该正则表达式无法满足,过于脆弱,所以接下来介绍另外两种网页抓取的方法。

2.Beautiful Soup

Beautiful Soup是一个非常流行的Python模块。该模块可以解析网页,并提供定位内容的便捷接口。

使用Beautiful Soup的第一步是将已下载的HTML内容解析为soup文档。由于大多数网页都不具备良好的HTML格式,因此Beautiful soup需要对其实际格式进行确定。举个例子,用Beautiful Soup来解析属性值两侧引号缺失和标签未闭合的问题。

from bs4 import BeautifulSoupbroken_html='<ul class=country><li>Area<li>Population</ul>'#解析HTMLsoup=BeautifulSoup(broken_html,'lxml')fixed_html=soup.prettify()#可以用prettify实现格式化输出,使HTML标准print(fixed_html)


运行结果:



<html> <body>  <ul class="country">   <li>    Area   </li>   <li>    Population   </li>  </ul> </body></html>

从上面的执行结果我们可以看出,Beautiful Soup能够正确解析缺失的引号并闭合标签,此外还添加了<html>和<body>标签使其成为完整的HTML文档。现在可以使用find()和find_all()方法来定位我们需要的元素了。

from bs4 import BeautifulSoupbroken_html='<ul class=country><li>Area<li>Population</ul>'#解析HTMLsoup=BeautifulSoup(broken_html,'lxml')fixed_html=soup.prettify()#可以用prettify实现格式化输出,使HTML标准ul=soup.find('ul',attrs={'class':'country'})print(ul.find('li'))#打印出了匹配的第一个print(ul.find_all('li'))#打印出了所有匹配到的
#运行结果
<li>Area</li>[<li>Area</li>, <li>Population</li>]

#接下来,我们使用这个方法来抽取示例国家面积的完整数据:
import urllibfrom urllib.request import urlopenimport urllib.errorfrom bs4 import BeautifulSoupdef download(url):    print('Downloading:',url)    try:        request=urllib.request.Request(url)        response=urllib.request.urlopen(request)        html=response.read().decode('utf-8')    except urllib.error.URLError as e:        print('Download error:',e.reason)        html=None    return htmlurl='http://example.webscraping.com/view/United-Kingdom-239'html=download(url)bs=BeautifulSoup(html,'lxml')pattern=bs.find('tr',{'id':'places_area__row'})square=pattern.find('td',{'class':'w2p_fw'})print(square.text)

#运行结果:
Downloading: http://example.webscraping.com/view/United-Kingdom-239244,820 square kilometres

3.Lxml

Lxml使用C语言编写,解析速度比Beautiful Soup更快,不过安装过程也更为复杂。

和Beautiful Soup一样,使用lxml模块的第一步也是将有可能不合法的HTML解析为统一格式。下面是使用该模块解析同一个不完整HTML例子。

import lxml.htmlbroken_html='<ul class=country><li>Area<li>Population</ul>'tree=lxml.html.fromstring(broken_html)#解析HTML语句fixed_html=lxml.html.tostring(tree,pretty_print=True)#将html元素转换成string,注意结果是stringtype类型print(fixed_html)

#运行结果:

b'<ul class="country">\n<li>Area</li>\n<li>Population</li>\n</ul>\n'

同样地,lxml也可以正确解析属性两侧缺失的引号,并闭合标签,不过该模块没有额外添加<html>和<body>标签。

解析完输入内容以后,进入选择元素的步骤,此时lxml有几种不同的方法,比如Xpath选择器和类似Beautiful Soup的find方法。不过在这里将会使用CSS选择器,因为它更加简洁,并能在解析动态内容时得以复用。

下面是使用lxml的CSS选择器抽取面积数据的示例代码。

import urllibfrom urllib.request import urlopenimport urllib.errorimport lxml.htmldef download(url):    print('Downloading:',url)    try:        request=urllib.request.Request(url)        response=urllib.request.urlopen(request)        html=response.read().decode('utf-8')    except urllib.error.URLError as e:        print('Download error:',e.reason)        html=None    return htmlurl='http://example.webscraping.com/view/United-Kingdom-239'html=download(url)tree=lxml.html.fromstring(html)td=tree.cssselect('tr#places_area__row>td.w2p_fw')[0]#css代码先找到ID为places_area__row的表格行元素,然后选择class为w2p_fw的表格数据子标签。square=td.text_content()print(square)

运行结果:
Downloading: http://example.webscraping.com/view/United-Kingdom-239244,820 square kilometres

如果td=tree.cssselect('tr#places_area__row>td.w2p_fw')[0]后面没有[0],那么会出现这样的报错:

  square=td.text_content()AttributeError: 'list' object has no attribute 'text_content'

这个如果没[0]返回的是一个列表,这个列表中只有一个元素,所以要将元素取出来,然后才有text_content  属性。

css选择器

css选择器表示选择元素所使用的模式。下面是一些常用的选择器示例。

选择所有的标签:*

选择<a>标签 :a

选择所有class=“link”的元素:.link

选择class=“link”的<a>标签:a.link

选择id=“home”的<a>标签:a#home

选择父元素为<a>标签的所有<span>标签:a > span

选择<a>标签内部的所有<span>标签:a span

选择title属性为“home”的所有<a>标签:a[title=home]


4.性能对比

要想更好地对比这三种抓取方法评估取舍,我们需要对其相对效率进行对比。一般情况下,爬虫会抽取网页中多个字段。因此,为了让对比更加真实,需要为本章中每个爬虫都实现一个扩展版本,用于抽取国家网页中的每个可用数据。

def re_scraper(html):    results={}    for field in FIELDS:        results[field]=re.search('<tr id="places_%s__row">.*?<td class="w2p_fw">(.*?)</td>'%field,html).groups()[0]        #向空字典中添加field元素    return results#用Beautiful Soup来爬取网页中的内容def bs_scraper(html):    soup=BeautifulSoup(html,'lxml')    results={}    for field in FIELDS:        results[field]=soup.find('table').find('tr',{'id':'places_%s__row'%field}).find('td',{'class':'w2p_fw'}).text    return results#用lxml来爬取内容def lxml_scraper(html):    tree=lxml.html.fromstring(html)    #解析HTML语言    results={}    for field in FIELDS:        results[field]=tree.cssselect('table>tr#places_%s__row>td.w2p_fw'%field)[0].text_content()    return resultsurl = 'http://example.webscraping.com/view/United-Kingdom-239'html = download(url)FIELDS = (        'area', 'population', 'iso', 'country', 'capital', 'continent', 'tld', 'currency_code', 'currency_name',        'phone', 'postal_code_format', 'postal_code_regex', 'languages', 'neighbours')content1=re_scraper(html)content2=bs_scraper(html)content3=lxml_scraper(html)print(content1)print(content2)print(content3)

运行结果:

Downloading: http://example.webscraping.com/view/United-Kingdom-239{'area': '244,820 square kilometres', 'currency_code': 'GBP', 'population': '62,348,447', 'tld': '.uk', 'currency_name': 'Pound', 'continent': '<a href="/continent/EU">EU</a>', 'capital': 'London', 'postal_code_format': '@# #@@|@## #@@|@@# #@@|@@## #@@|@#@ #@@|@@#@ #@@|GIR0AA', 'postal_code_regex': '^(([A-Z]\\d{2}[A-Z]{2})|([A-Z]\\d{3}[A-Z]{2})|([A-Z]{2}\\d{2}[A-Z]{2})|([A-Z]{2}\\d{3}[A-Z]{2})|([A-Z]\\d[A-Z]\\d[A-Z]{2})|([A-Z]{2}\\d[A-Z]\\d[A-Z]{2})|(GIR0AA))$', 'languages': 'en-GB,cy-GB,gd', 'iso': 'GB', 'country': 'United Kingdom', 'neighbours': '<div><a href="/iso/IE">IE </a></div>', 'phone': '44'}{'area': '244,820 square kilometres', 'currency_code': 'GBP', 'population': '62,348,447', 'tld': '.uk', 'currency_name': 'Pound', 'continent': 'EU', 'capital': 'London', 'postal_code_format': '@# #@@|@## #@@|@@# #@@|@@## #@@|@#@ #@@|@@#@ #@@|GIR0AA', 'postal_code_regex': '^(([A-Z]\\d{2}[A-Z]{2})|([A-Z]\\d{3}[A-Z]{2})|([A-Z]{2}\\d{2}[A-Z]{2})|([A-Z]{2}\\d{3}[A-Z]{2})|([A-Z]\\d[A-Z]\\d[A-Z]{2})|([A-Z]{2}\\d[A-Z]\\d[A-Z]{2})|(GIR0AA))$', 'languages': 'en-GB,cy-GB,gd', 'iso': 'GB', 'country': 'United Kingdom', 'neighbours': 'IE ', 'phone': '44'}{'area': '244,820 square kilometres', 'currency_code': 'GBP', 'population': '62,348,447', 'tld': '.uk', 'currency_name': 'Pound', 'continent': 'EU', 'capital': 'London', 'postal_code_format': '@# #@@|@## #@@|@@# #@@|@@## #@@|@#@ #@@|@@#@ #@@|GIR0AA', 'postal_code_regex': '^(([A-Z]\\d{2}[A-Z]{2})|([A-Z]\\d{3}[A-Z]{2})|([A-Z]{2}\\d{2}[A-Z]{2})|([A-Z]{2}\\d{3}[A-Z]{2})|([A-Z]\\d[A-Z]\\d[A-Z]{2})|([A-Z]{2}\\d[A-Z]\\d[A-Z]{2})|(GIR0AA))$', 'languages': 'en-GB,cy-GB,gd', 'iso': 'GB', 'country': 'United Kingdom', 'neighbours': 'IE ', 'phone': '44'}

抓取结果:

现在,我们已经完成了所有爬虫的代码实现,接下来将通过如下代码片段,测试这三种方法的相对性能

NUM_ITERATIONS=1000#每个爬虫的测试次数url = 'http://example.webscraping.com/view/United-Kingdom-239'html = download(url)FIELDS = (        'area', 'population', 'iso', 'country', 'capital', 'continent', 'tld', 'currency_code', 'currency_name',        'phone', 'postal_code_format', 'postal_code_regex', 'languages', 'neighbours')for name,scraper in [('Regular experssions',re_scraper),('BeautifulSoup',bs_scraper),('Lxml',lxml_scraper)]:    #爬虫开始的时间    start=time.time()    for i in range(NUM_ITERATIONS):        if scraper==re_scraper:            re.purge()        #在默认情况下,正则表达式模块会缓存搜索结果,为了与其他爬虫的对比更加公平,我们需要使用该方法清除缓存        result=scraper(html)        assert(result['area']=='244,820 square kilometres')        #检查爬虫结果是否和预期一样    end=time.time()    print('%s:%.2f seconds'%(name,end-start))

运行结果:

Regular experssions:3.80 secondsBeautifulSoup:25.27 secondsLxml:4.73 seconds

由于硬件的区别不同的电脑上会有不同的执行结果。不过每种方法之间的相对差异应当是相当的


5.结论


    抓取方法   性能     使用难度     安装难度正则表达式快困难简单(内置模块)Beautiful Soup慢简单简单(纯Python)Lxml快简单相对困难如果你的爬虫瓶颈是下载网页,而不是抽取数据的话,那么使用较慢的方法(如Beautiful Soup)也不成问题。如果只需抓取少量数据,并且想要避免额外以来的话,那么正则表达式可能更加合适。不过通常情况下,lxml是抓取数据的最好选择,既快速又健壮。

6.为链接爬虫添加抓取回调

前面我们已经了解了如何抓取国家数据,接下来我们需要将其集成到上一章的链接爬虫中。要想复用这段爬虫代码抓取其他网站,我们需要添加一个callback参数处理抓取行为。callback是一个函数,在发生某个特定事件之后会调用该函数(在本例中,会在网页下载完成后使用)。该抓取callback函数包含url和html两个参数,并且可以返回一个待爬取的URL列表。下面是其实现代码:

def link_crawler(...,scrape_callback=None):    ...            links=[]            if scrape_callback:                links.extend(scrape_callback(url,html)or[])

在上面的代码片段中,我们显示了新增加的抓取callback函数代码,现在,我们只需对传入的scrape_callback函数定制化处理,就能使用该爬虫抓取其他网站了,下面对lxml抓取示例的代码进行了修改,使其能够在callback函数中使用。

def scrape_callback(url,html):    if re.search('/view/',url):        tree = lxml.html.fromstring(html)        row={}        for field in FIELDS:            row[field]=tree.cssselect('table>tr#places_%s__row>td.w2p_fw'%field)[0].text_content()        print(url,row)url = 'http://example.webscraping.com'FIELDS = ('area', 'population', 'iso', 'country', 'capital', 'continent', 'tld', 'currency_code', 'currency_name',            'phone', 'postal_code_format', 'postal_code_regex', 'languages', 'neighbours')if __name__=='__main__':    link_crawler(url,'/(index|view)',delay=0, user_agent='GoodCrawler',scrape_callback=scrape_callback)

部分运行结果
Downloading: http://example.webscraping.com/index/24Downloading: http://example.webscraping.com/index/25Downloading: http://example.webscraping.com/view/Zimbabwe-252http://example.webscraping.com/view/Zimbabwe-252 {'iso': 'ZW', 'languages': 'en-ZW,sn,nr,nd', 'phone': '263', 'area': '390,580 square kilometres', 'postal_code_format': '', 'population': '11,651,858', 'tld': '.zw', 'currency_code': 'ZWL', 'capital': 'Harare', 'postal_code_regex': '', 'currency_name': 'Dollar', 'continent': 'AF', 'country': 'Zimbabwe', 'neighbours': 'ZA MZ BW ZM '}Downloading: http://example.webscraping.com/view/Zambia-251http://example.webscraping.com/view/Zambia-251 {'iso': 'ZM', 'languages': 'en-ZM,bem,loz,lun,lue,ny,toi', 'phone': '260', 'area': '752,614 square kilometres', 'postal_code_format': '#####', 'population': '13,460,305', 'tld': '.zm', 'currency_code': 'ZMW', 'capital': 'Lusaka', 'postal_code_regex': '^(\\d{5})$', 'currency_name': 'Kwacha', 'continent': 'AF', 'country': 'Zambia', 'neighbours': 'ZW TZ MZ CD NA MW AO '}Downloading: http://example.webscraping.com/view/Yemen-250http://example.webscraping.com/view/Yemen-250 {'iso': 'YE', 'languages': 'ar-YE', 'phone': '967', 'area': '527,970 square kilometres', 'postal_code_format': '', 'population': '23,495,361', 'tld': '.ye', 'currency_code': 'YER', 'capital': 'Sanaa', 'postal_code_regex': '', 'currency_name': 'Rial', 'continent': 'AS', 'country': 'Yemen', 'neighbours': 'SA OM '}Downloading: http://example.webscraping.com/view/Western-Sahara-249http://example.webscraping.com/view/Western-Sahara-249 {'iso': 'EH', 'languages': 'ar,mey', 'phone': '212', 'area': '266,000 square kilometres', 'postal_code_format': '', 'population': '273,008', 'tld': '.eh', 'currency_code': 'MAD', 'capital': 'El-Aaiun', 'postal_code_regex': '', 'currency_name': 'Dirham', 'continent': 'AF', 'country': 'Western Sahara', 'neighbours': 'DZ MR MA '}Downloading: http://example.webscraping.com/view/Wallis-and-Futuna-248

上面这个callback函数回去抓取国家数据,然后将其显示出来,不过通常情况下,在抓取网站时,我们更希望可以复用这些数据,因此,下面,我们将对其功能进行拓展,把得到的结果数据保存到csv表格中,代码如下:

class ScrapeCallback:    def __init__(self):        csvFile=open('countries.csv','w',newline='',encoding='utf-8')        self.writer=csv.writer(csvFile)        self.fields=('area', 'population', 'iso', 'country', 'capital', 'continent', 'tld', 'currency_code', 'currency_name',            'phone', 'postal_code_format', 'postal_code_regex', 'languages', 'neighbours')        self.writer.writerow(self.fields)    def __call__(self, url, html):        if re.search('/view/',url):            tree = lxml.html.fromstring(html)            row=[]            for field in self.fields:                row.append(tree.cssselect('table>tr#places_%s__row>td.w2p_fw'%field)[0].text_content())            self.writer.writerow(row)url = 'http://example.webscraping.com'# html = download(url)if __name__=='__main__':    link_crawler(url,'/(index|view)',delay=0, user_agent='GoodCrawler',scrape_callback=ScrapeCallback())

为了实现该callback,我们使用了回调类,而不再是回调函数,以便保持csv中writer属性的状态,csv的writer属性在构造方法中进行了实例化处理,然后在__call__方法种执行了多次写操作。请注意,__call__是一个特殊方法,在对象作为函数被调用时会调用该方法,这也是链接爬虫中cache_callback的调用方法。也就是说,scrape__callback(url,html)和调用scrape__callback.__call__(url,html)是等价的。

运行结果:




0 0
原创粉丝点击