16-链接爬虫
[toc]
1.5.5 链接爬虫
到目前为止,我们已经利用示例网站的结构特点实现了两个简单爬虫,用于下载所有已发布的国家(或地区)页面。只要这两种技术可用,就应当使用它们进行爬取,因为这两种方法将需要下载的网页数量降至最低。不过,对于另一些网站,我们需要让爬虫表现得更像普通用户,跟踪链接,访问感兴趣的内容。
通过跟踪每个链接的方式,我们可以很容易地下载整个网站的页面。但是,这种方法可能会下载很多并不需要的网页。例如,我们想要从一个在线论坛中抓取用户账号详情页,那么此时我们只需要下载账号页,而不需要下载讨论贴的页面。本章使用的链接爬虫将使用正则表达式来确定应当下载哪些页面。下面是这段代码的初始版本。
import re
def link_crawler(start_url, link_regex):
""" Crawl from the given start URL following links matched by
link_regex
"""
crawl_queue = [start_url]
while crawl_queue:
url = crawl_queue.pop()
html = download(url)
if html is not None:
continue
# filter for links matching our regular expression
for link in get_links(html):
if re.match(link_regex, link):
crawl_queue.append(link)
def get_links(html):
""" Return a list of links from html
"""
# a regular expression to extract all links from the webpage
webpage_regex = re.compile("""<a[^>]+href=["'](.*?)["']""",
re.IGNORECASE)
# list of all links from the webpage
return webpage_regex.findall(html)
要运行这段代码,只需要调用 link_crawler 函数,并传入两个参数:要爬取的网站URL以及用于匹配你想跟踪的链接的正则表达式。对于示例网站来说,我们想要爬取的是国家(或地区)列表索引页和国家(或地区)页面。
我们查看站点可以得知索引页链接遵循如下格式:
国家(或地区)页遵循如下格式:
- http://example.python-scraping.com/view/Afghanistan-1
- http://example.python-scraping.com/view/Aland-Islands-2
因此,我们可以用 /(index|view)/ 这个简单的正则表达式来匹配这两类网页。当爬虫使用这些输入参数运行时会发生什么呢?你会得到如下所示的下载错误。
>>> link_crawler('http://example.python-scraping.com', '/(index|view)/')
Downloading: http://example.python-scraping.com
Downloading: /index/1
Traceback (most recent call last):
...
ValueError: unknown url type: /index/1
正则表达式是从字符串中抽取信息的非常好的工具,因此我推荐每名程序员都应当“学会如何阅读和编写一些正则表达式”。即便如此,它们往往会非常脆弱,容易失效。我们将在本书后续部分介绍更先进的抽取链接和识别页面的方式。
可以看出,问题出在下载 /index/1 时,该链接只有网页的路径部分,而没有协议和服务器部分,也就是说这是一个 相对链接 。由于浏览器知道你正在浏览哪个网页,并且能够采取必要的步骤处理这些链接,因此在浏览器浏览时,相对链接是能够正常工作的。但是, urllib 并没有上下文。为了让 urllib 能够定位网页,我们需要将链接转换为 绝对链接 的形式,以便包含定位网页的所有细节。如你所愿,Python的 urllib 中有一个模块可以用来实现该功能,该模块名为 parse 。下面是 link_crawler 的改进版本,使用了 urljoin 方法来创建绝对路径。
from urllib.parse import urljoin
def link_crawler(start_url, link_regex):
""" Crawl from the given start URL following links matched by
link_regex
"""
crawl_queue = [start_url]
while crawl_queue:
url = crawl_queue.pop()
html = download(url)
if not html:
continue
for link in get_links(html):
if re.match(link_regex, link):
abs_link = urljoin(start_url, link)
crawl_queue.append(abs_link)
当你运行这段代码时,会看到虽然下载了匹配的网页,但是同样的地点总是会被不断下载到。产生该行为的原因是这些地点相互之间存在链接。比如,澳大利亚链接到了南极洲,而南极洲又链接回了澳大利亚,此时爬虫就会继续将这些URL放入队列,永远不会到达队列尾部。要想避免重复爬取相同的链接,我们需要记录哪些链接已经被爬取过。下面是修改后的 link_crawler 函数,具备了存储已发现URL的功能,可以避免重复下载。
def link_crawler(start_url, link_regex):
crawl_queue = [start_url]
# keep track which URL's have seen before
seen = set(crawl_queue)
while crawl_queue:
url = crawl_queue.pop()
html = download(url)
if not html:
continue
for link in get_links(html):
# check if link matches expected regex
if re.match(link_regex, link):
abs_link = urljoin(start_url, link)
# check if have already seen this link
if abs_link not in seen:
seen.add(abs_link)
crawl_queue.append(abs_link)
当运行该脚本时,它会爬取所有地点,并且能够如期停止。最终,我们得到了一个可用的链接爬虫!
高级功能
现在,让我们为链接爬虫添加一些功能,使其在爬取其他网站时更加有用。
1.解析robots.txt
首先,我们需要解析 robots.txt 文件,以避免下载禁止爬取的URL。使用Python的 urllib 库中的 robotparser 模块,就可以轻松完成这项工作,如下面的代码所示。
>>> from urllib import robotparser
>>> rp = robotparser.RobotFileParser()
>>> rp.set_url('http://example.python-scraping.com/robots.txt')
>>> rp.read()
>>> url = 'http://example.python-scraping.com'
>>> user_agent = 'BadCrawler'
>>> rp.can_fetch(user_agent, url)
False
>>> user_agent = 'GoodCrawler'
>>> rp.can_fetch(user_agent, url)
True
robotparser 模块首先加载 robots.txt 文件,然后通过 can_fetch() 函数确定指定的用户代理是否允许访问网页。在本例中,当用户代理设置为 'BadCrawler' 时, robotparser 模块的返回结果表明无法获取网页,正如我们在示例网站的 robots.txt 文件中看到的定义一样。
为了将 robotparser 集成到链接爬虫中,我们首先需要创建一个新函数用于返回 robotparser 对象。
def get_robots_parser(robots_url):
" Return the robots parser object using the robots_url "
rp = robotparser.RobotFileParser()
rp.set_url(robots_url)
rp.read()
return rp
我们需要可靠地设置 robots_url ,此时我们可以通过向函数传递额外的关键词参数的方法实现这一目标。我们还可以设置一个默认值,防止用户没有传递该变量。假设从网站根目录开始爬取,那么我们可以简单地将 robots.txt 添加到URL的结尾处。此外,我们还需要定义 user_agent 。
def link_crawler(start_url, link_regex, robots_url=None,
user_agent='wswp'):
...
if not robots_url:
robots_url = '{}/robots.txt'.format(start_url)
rp = get_robots_parser(robots_url)
最后,我们在 crawl 循环中添加解析器检查。
...
while crawl_queue:
url = crawl_queue.pop()
# check url passes robots.txt restrictions
if rp.can_fetch(user_agent, url):
html = download(url, user_agent=user_agent)
...
else:
print('Blocked by robots.txt:', url)
我们可以通过使用坏的用户代理字符串来测试我们这个高级链接爬虫以及 robotparser 的使用。
>>> link_crawler('http://example.python-scraping.com', '/(index|view)/',
user_agent='BadCrawler')
Blocked by robots.txt: http://example.python-scraping.com
2.支持代理
有时我们需要使用代理访问某个网站。比如,Hulu在美国以外的很多国家被屏蔽,YouTube上的一些视频也是。使用 urllib 支持代理并没有想象中那么容易。我们将在后面的小节介绍一个对用户更友好的Python HTTP模块—— requests ,该模块同样也能够处理代理。下面是使用 urllib 支持代理的代码。
proxy = 'http://myproxy.net:1234' # example string
proxy_support = urllib.request.ProxyHandler({'http': proxy})
opener = urllib.request.build_opener(proxy_support)
urllib.request.install_opener(opener)
# now requests via urllib.request will be handled via proxy
下面是集成了该功能的新版本 download 函数。
def download(url, user_agent='wswp', num_retries=2, charset='utf-8',
proxy=None):
print('Downloading:', url)
request = urllib.request.Request(url)
request.add_header('User-agent', user_agent)
try:
if proxy:
proxy_support = urllib.request.ProxyHandler({'http': proxy})
opener = urllib.request.build_opener(proxy_support)
urllib.request.install_opener(opener)
resp = urllib.request.urlopen(request)
cs = resp.headers.get_content_charset()
if not cs:
cs = charset
html = resp.read().decode(cs)
except (URLError, HTTPError, ContentTooShortError) as e:
print('Download error:', e.reason)
html = None
if num_retries > 0:
if hasattr(e, 'code') and 500 <= e.code < 600:
# recursively retry 5xx HTTP errors
return download(url, num_retries - 1)
return html
目前在默认情况下(Python 3.5), urllib 模块不支持 https 代理。该问题可能会在Python未来的版本中发现变化,因此请查阅最新的文档。此外,你还可以使用文档推荐的诀窍( https://code.activestate.com/recipes/456195 ),或继续阅读来学习如何使用 requests 库。
3.下载限速
如果我们爬取网站的速度过快,就会面临被封禁或是造成服务器过载的风险。为了降低这些风险,我们可以在两次下载之间添加一组延时,从而对爬虫限速。下面是实现了该功能的类的代码。
from urllib.parse import urlparse
import time
class Throttle:
"""Add a delay between downloads to the same domain
"""
def __init__(self, delay):
# amount of delay between downloads for each domain
self.delay = delay
# timestamp of when a domain was last accessed
self.domains = {}
def wait(self, url):
domain = urlparse(url).netloc
last_accessed = self.domains.get(domain)
if self.delay > 0 and last_accessed is not None:
sleep_secs = self.delay - (time.time() - last_accessed)
if sleep_secs > 0:
# domain has been accessed recently
# so need to sleep
time.sleep(sleep_secs)
# update the last accessed time
self.domains[domain] = time.time()
Throttle 类记录了每个域名上次访问的时间,如果当前时间距离上次访问时间小于指定延时,则执行睡眠操作。我们可以在每次下载之前调用 throttle 对爬虫进行限速。
throttle = Throttle(delay)
...
throttle.wait(url)
html = download(url, user_agent=user_agent, num_retries=num_retries,
proxy=proxy, charset=charset)
4.避免爬虫陷阱
目前,我们的爬虫会跟踪所有之前没有访问过的链接。但是,一些网站会动态生成页面内容,这样就会出现无限多的网页。比如,网站有一个在线日历功能,提供了可以访问下个月和下一年的链接,那么下个月的页面中同样会包含访问再下个月的链接,这样就会一直持续请求到部件设定的最大时间(可能会是很久之后的时间)。该站点可能还会在简单的分页导航中提供相同的功能,本质上是分页请求不断访问空的搜索结果页,直至达到最大页数。这种情况被称为 爬虫陷阱 。
想要避免陷入爬虫陷阱,一个简单的方法是记录到达当前网页经过了多少个链接,也就是深度。当到达最大深度时,爬虫就不再向队列中添加该网页中的链接了。要实现最大深度的功能,我们需要修改 seen 变量。该变量原先只记录访问过的网页链接,现在修改为一个字典,增加了已发现链接的深度记录。
def link_crawler(..., max_depth=4):
seen = {}
...
if rp.can_fetch(user_agent, url):
depth = seen.get(url, 0)
if depth == max_depth:
print('Skipping %s due to depth' % url)
continue
...
for link in get_links(html):
if re.match(link_regex, link):
abs_link = urljoin(start_url, link)
if abs_link not in seen:
seen[abs_link] = depth + 1
crawl_queue.append(abs_link)
有了该功能之后,我们就有信心爬虫最终一定能够完成了。如果想要禁用该功能,只需将 max_depth 设为一个负数即可,此时当前深度永远不会与之相等。
5.最终版本
这个高级链接爬虫的完整源代码可以在异步社区中下载得到,其文件名为 advanced_link_crawler.py 。为了方便按照本书操作,可以派生该代码库,并使用它对比及测试你自己的代码。
要测试该链接爬虫,我们可以将用户代理设置为 BadCrawler ,也就是本章前文所述的被 robots.txt 屏蔽了的那个用户代理。从下面的运行结果中可以看出,爬虫确实被屏蔽了,代码启动后马上就会结束。
>>> start_url = 'http://example.python-scraping.com/index'
>>> link_regex = '/(index|view)'
>>> link_crawler(start_url, link_regex, user_agent='BadCrawler')
Blocked by robots.txt: http://example.python-scraping.com/
现在,让我们使用默认的用户代理,并将最大深度设置为1,这样只有主页上的链接才会被下载。
>>> link_crawler(start_url, link_regex, max_depth=1)
Downloading: http://example.python-scraping.com//index
Downloading: http://example.python-scraping.com/index/1
Downloading: http://example.python-scraping.com/view/Antigua-and-Barbuda-10
Downloading: http://example.python-scraping.com/view/Antarctica-9
Downloading: http://example.python-scraping.com/view/Anguilla-8
Downloading: http://example.python-scraping.com/view/Angola-7
Downloading: http://example.python-scraping.com/view/Andorra-6
Downloading: http://example.python-scraping.com/view/American-Samoa-5
Downloading: http://example.python-scraping.com/view/Algeria-4
Downloading: http://example.python-scraping.com/view/Albania-3
Downloading: http://example.python-scraping.com/view/Aland-Islands-2
Downloading: http://example.python-scraping.com/view/Afghanistan-1
和预期一样,爬虫在下载完国家(或地区)列表的第一页之后就停止了。
正则表达式是从字符串中抽取信息的非常好的工具,因此我推荐每名程序员都应当“学会如何阅读和编写一些正则表达式”。即便如此,它们往往会非常脆弱,容易失效。我们将在本书后续部分介绍更先进的抽取链接和识别页面的方式。