Python學習編寫第一個網絡爬蟲

本內容為《用Python寫網絡爬蟲》書籍內容,有興趣的讀者可以購買本書,本章的代碼皆可在Python3中運行。
為了抓取網站,我們首先需要下載包含有感興趣數據的網頁,該過程一般稱為爬去(crawling)。爬去一個網站有很多種方法,而選用那種方法更加合適,則取決于目標網站的結構。我們首先探討如何安全地下載網頁,然后介紹3種爬去網站的常見方法:

  • 爬取網站地圖;
  • 遍歷每個網頁的數據庫ID;
  • 跟蹤網頁鏈接。

下載網頁

要想爬取網頁,我們首先需要將其下載下來。下面的示例腳本使用Python3的urllib.request模塊下載URL。

import urllib.request
def download(url):
    return urllib.request.urlopen(url).read()
if __name__ == '__main__':
    print(download('http://www.baidu.com'))

當傳入URL參數時,print會輸出download函數獲取的網址源碼。不過,這個代碼片段存在一個問題,即當下載網頁時,我們可能會遇到一些無法控制的錯誤,比如請求的頁面可能不存在。此時,urllib會拋出異常,然后退出腳本。安全起見,下面在給出一個更健壯的版本,可以捕獲這些異常。

def download(url):
    """捕獲錯誤的下載函數"""
    print("Downloading:", url)
    try:
        html = urllib.request.urlopen(url).read()
    except urllib.request.URLError as e:
        print("download error:", e.reason)
        html = None
    return html

現在,當出現下載錯誤時,該函數能夠捕獲到異常,然后返回None。

重新下載

下載時遇到的錯誤經常是臨時性的,比如服務器過載時返回的 503 Service Unavailable 錯誤。對于此類錯誤,我們可以嘗試重新下載,因為這個服務器問題現在可能已解決。不過,我們不需要對所有錯誤都嘗試重新下載。如果服務器返回的是 404 Not Found錯誤,則說明該網頁目前并不存在,再次嘗試同樣的請求一般也不會出現不同的結果。互聯網工程任務組(Internet Engineering Task Force)定義了HTTP錯誤的完整列表,詳情請點擊,從該文檔中,我們可以了解4xx錯誤發生在請求存在問題時,而5xx錯誤則發生在服務端存在問題時。所以,我們只需要確保download在發生5xx錯誤時重試下載即可。下面是支持重試下載功能的新版本代碼。

def download(url, num_retries=2):
    """下載函數,也會重試5xx錯誤。參數二為重試次數,默認為2次"""
    print("Downloading", url)
    try:
        html = urllib.request.urlopen(url).read()
    except urllib.request.URLError as e:    
        print("Download error:", e.reason)
        html = None
        if num_retries > 0:
            if hasattr(e, 'code') and 500 <= e.code < 600:
                #重試 5xx http錯誤
                html = download3(url, num_retries-1)
    return html

現在,當download函數遇到5xx錯誤碼時,將會遞歸調用函數自身進行重試。此外,該函數還增加了一個參數,用于設定重試下載次數,其默認值為兩次。我們在這里限制網頁下載的嘗試次數,是因為服務器錯誤可能暫時還沒有解決。想要測試該函數,可以嘗試下載http://httpstat.us/500,該網站會始終返回500錯誤碼。

設置用戶代理

默認情況下,urllib使用Python-urllib/3.5作為用戶代理下載網頁內容,其中3.5是Python的版本號。如果能使用可辨識的用戶代理則更好,這樣可以避免我們的網絡爬蟲碰到一些問題。此外,也許是因為曾經經歷過質量不佳的Python網絡爬蟲造成的服務器過載。一些網站還會封禁這個默認的用戶代理。比如,在使用Python默認用戶代理的情況下,訪問http://www.meetup.com/,目前會返回的提示是Forbidden。
因此,為了下載更加可靠,我們需要控制用戶代理的設定。下面的代碼對download函數進行了修改,設定了一個默認的用戶代理"wswp"(即Web Scraping with Python的首字母縮寫)。

def download4(url, user_agent='wswp', num_retries=2):
    """包括用戶代理支持的下載函數"""
    print("Downloading:", url)
    headers = {'User-agent': user_agent}
    request = urllib.request.Request(url, headers=headers)
    try:
        html = urllib.request.urlopen(request).read()
    except urllib.request.URLError as e:
        print("Download error:", e.reason)
        html = None
        if num_retries > 0:
            if hasattr(e, 'code') and 500 <= e.code < 600:
                # 重試 5xx http錯誤
                html = download4(url, user_agent, num_retries-1)
    return html

現在,我們擁有了一個靈活的下載函數,可以在后續示例中得到復用。該函數能夠捕獲異常、重試下載并設置用戶代理。

網站地圖爬蟲

在第一個簡單的爬蟲中,我們將使用示例網站robot.txt文件中發現的網站地圖來下載所有網頁。為了解析網站地圖,我們將會使用一個簡單的正則表達式,從<loc>標簽中提取出URL。

import re
from common import download

def crawl_sitemap(url):
    #下載sitemap文件
    sitemap = download(url).decode('utf-8')
    #抓取站點地圖鏈接
    links = re.findall('<loc>(.*?)</loc>', sitemap)
    # 下載每一個鏈接
    for link in links:
        html = download(link)
        # scrape html here
        # ...

現在,運行網站地圖爬蟲,從示例網站中下載所有國家頁面。

>>> crawl_sitemap('http://example.webscraping.com/sitemap.xml')
ownloading: http://example.webscraping.com/sitemap.xml
Downloading: http://example.webscraping.com/view/Afghanistan-1
Downloading: http://example.webscraping.com/view/Aland-Islands-2
Downloading: http://example.webscraping.com/view/Albania-3
Downloading: http://example.webscraping.com/view/Algeria-4
...

可以看出,上述運行結果和我們的預期一致,不過正如前文所述,我們無法依靠Sitemap文件提供每個網頁的鏈接。下一節中,我們將會介紹另一個簡單的爬蟲,該爬蟲不在依賴于Sitemap文件。

ID遍歷爬蟲

本節中,我們將利用網站結構的弱點,更加輕松地訪問所有內容。下面是一些示例國家的URL。

# -×- coding: utf-8 -*-
import itertools
from common import download
def iteration():
    for page in itertools.count(1):
        url = 'http://example.webscraping.com/view/-{}'.format(page)
        html = download(url)
        if html is None:
            # 嘗試下載此網站時收到的錯誤
            # 所以假設已達到最后一個國家ID,并可以停止下載
            break
        else:
            # 成功 - 能夠刮結果
            # ...
            pass

在這段代碼中,我們對ID進行遍歷,直到出現下載錯誤時停止,我們假設此時已到達最后一個國家的頁面。不過,這種實現方式存在一個缺陷,那就是某些記錄可能已被刪除,數據庫ID之間并不是連續的。此時,只要訪問到某個間隔點,爬蟲就會立即退出。下面是這段代碼的改進版本,在該版本中連續發生多次下載錯誤后才退出程序。

def iteration2():
    max_errors = 5 # 允許最大連續下載錯誤數
    num_errors = 0 # 當前連續下載錯誤數
    for page in itertools.count(1):
        url = 'http://example.webscraping.com/view/-{}'.format(page)
        html = download(url)
        if html is None:
            # 嘗試下載此網頁時出錯
            num_errors += 1
            if num_errors == max_errors:
                # 達到最大錯誤數時,退出
                break
            # 所以假設已達到最后一個ID,并可以停止下載
        else:
            # 成功 - 能夠刮到結果
            # ...
            num_errors = 0

上面代碼中實現的爬蟲需要連續5次下載錯誤才會停止遍歷,這樣就很大程度上降低了遇到被刪除記錄時過早停止遍歷的風險。
在爬取網站時,遍歷ID是一個很便捷的方法,但是和網站地圖爬蟲一樣,這種方法也無法保證始終可用。比如,一些網站會檢查頁面別名是否滿足預期,如果不是,則會返回404 Not Found 錯誤。而另一些網站則會使用非連續大數作為ID,或是不使用數值作為ID,此時遍歷就難以發揮作用了。例如,Amazon使用ISBN作為圖書ID,這種編碼包好至少10位數字。使用ID對Amazon的圖書進行遍歷需要測試數十億次,因此這種方法肯定不是抓取該網站內容最高效的方法。

鏈接爬取

到目前為止,我們已經利用示例網站的結構特點實現了兩個簡單爬蟲,用于下載所有的國家頁面。只要這兩種技術可用,就應當使用其進行爬取,因為這兩種方法最小化了需要下載的網頁數量。不過,對于另一些網站,我們需要讓爬蟲表現的更像普通用戶,跟蹤鏈接,訪問感興趣的內容。
通過跟蹤所有鏈接的方式,我們可以很容易地下載種鴿網站的頁面。但是,這種方法會下載大量我們并不需要的網頁。例如,我們想要從一個在線論壇中抓取用戶賬號詳情頁,那么此時我們只需要下載賬號頁,而不需要下載討論帖的頁面。本節中的鏈接爬蟲將使用正則表達式來確定需要下載那些頁面。下面時這段代碼的初始版本。

import re
from common import download
def link_crawler(seed_url, link_regex):
    """從指定的種子網址按照link_regex匹配的鏈接進行抓取"""
    crawal_queue = [seed_url] # 要下載的URL隊列
    while crawal_queue:
        url = crawal_queue.pop()
        html = download(url).decode('utf-8')
        # 使用過濾器來匹配我們的正則表達式
        for link in get_links(html):
            if re.match(link_regex, link):
                # 將這個鏈接添加到爬網隊列
                crawal_queue.append(link)
def get_links(html):
    """從HTML返回一個鏈接列表"""
    # 從網頁提取所有鏈接的正則表達式
    webpage_regex = re.compile('<a[^>]+href=["\'](.*?)["\']', re.IGNORECASE)
    # 來自網頁的所有鏈接的列表
    return webpage_regex.findall(html)

要運行這段代碼,只需要調用link_crawler函數,并傳入兩個參數,要爬取的網站URL和用于跟蹤鏈接的正則表達式。對于示例網站,我們想要爬取的是國家列表索引頁和國家頁面。其中,索引頁鏈接格式如下。

>>> link_crawler('http://example.webscraping.com', '/(index|view)')
Downloading: http://example.webscraping.com
Downloading: /index/1
Traceback (most recent call last):
 ...
ValueError: unknown url type: '/index/1'

可以看出,問題處在下載/index/1時,該鏈接只有網頁的路徑部分,而沒有協議和服務器部分,也就是說這是一個相對鏈接。由于瀏覽器知道你正在瀏覽哪個網頁,所以在瀏覽器瀏覽時,相對鏈接是能夠正常工作的。但是,urllib是無法獲知上下文的。為了讓urllib能夠定位網頁,我們需要將鏈接轉換為決定鏈接的形式,以便包含定位網頁的所有細節。如你所愿,Python中確實有用來實現這一功能的模塊,該模塊稱為urlparse。下面是link_crawler的改進版本,使用了urlparse模塊來創建絕對路徑。

import urllib.parse
def link_crawler(seed_url, link_regex):
    """從指定的種子網址按照link_regex匹配的鏈接進行抓取"""
    crawal_queue = [seed_url] # 要下載的URL隊列
    while crawal_queue:
        url = crawal_queue.pop()
        html = download(url).decode('utf-8')
        for link in get_links(html):
            # 檢查鏈接是否匹配預期正則表達式
            if re.match(link_regex, link):
                # 形式絕對鏈接
                link = urllib.parse.urljoin(seed_url, link)
                crawal_queue.append(link)

當你運行這段代碼時,會發現雖然網頁下載沒有出現錯誤,但是同樣的地點總是會被不斷下載到。這是因為這些地點相互之間存在鏈接。比如,澳大利亞鏈接到了南極洲,而南極洲也存在到澳大利亞的鏈接,此時爬蟲就會在它們之間不斷循環下去。要想避免重復爬取相同的鏈接,我們需要記錄哪些鏈接已經被爬取過。下面是修改后的link_crawler函數,已具備存儲已發現URL的功能,可以避免重復下載。

def link_crawler(seed_url, link_regex):
    """從指定的種子網址按照link_regex匹配的鏈接進行抓取"""
    crawal_queue = [seed_url] # 要下載的URL隊列
    seen = set(crawal_queue) # 跟蹤以前看過的URL
    while crawal_queue:
        url = crawal_queue.pop()
        html = download(url).decode('utf-8')
        for link in get_links(html):
            # 檢查鏈接是否匹配預期正則表達式
            if re.match(link_regex, link):
                # 形式絕對鏈接
                link = urllib.parse.urljoin(seed_url, link)
                # 檢查是否已經看過該鏈接
                if link not in seen:
                    seen.add(link)
                    crawal_queue.append(link)

當運行該腳本時,它會爬取所有地點,并且能夠如期停止。最終,我們得到了一個可用的爬蟲!

高級功能

現在,讓我們為鏈接爬蟲添加了一些功能,使其在爬取其它網站時更加有用。

解析robots.txt

首先,我們需要解析robots.txt文件,以避免下載禁止爬取的URL。使用Python自帶的robotparser模塊,就可以輕松完成這項工作,如下圖的代碼所示。

>>> import robotparser
>>> rp = robotparser.RobotFileParser()
>>> rp.set_url('http://example.webscraping.com/robots.txt')
>>> rp.read()
>>> url = 'http://example.webscraping.com'
>>> user_agent = 'BadCrawler'
>>> rp.can_fetch(user_agent, url)
False
>>> user_agent = 'GoodCrawler'
>>> rp.can_fetch(user_agent, url)
True

rotbotparser模塊首先加載robots.txt文件,然后通過can_fetch()函數確定指定的用戶代理是否允許訪問網頁。在本例中,當用戶代理設置為‘BadCrawler’時,robotparser模塊會返回結果表明無法獲取網頁,這和示例網站robots.txt的定義一樣。
為了將該功能集成到爬蟲中,我們需要在crawl循環中添加該檢查。

while crawal_queue:
        url = crawal_queue.pop()
        # 檢查網址傳遞的robots.txt限制
        if rp.can_fetch(user_agent, url):
            ...
        else:
            print("Blocked by robots.txt", url)

支持代理

有時我們需要使用代理訪問某個網站。比如,Netflix屏蔽了美國以外的大多數國家。使用urllib.request支持代理并沒有想象中的那么容易(可以嘗試使用更友好的Python HTTP模塊 requests來實現該功能,點擊跳轉文檔地址。下面是使用urllib.request支持代理的代碼。

proxy = ...
opener = urllib.request.build_opener()
proxy_params = {urllib.parse.urlparse(url).scheme: proxy}
opener.add_handler(urllib.request.ProxyHandler(proxy_params))
response = opener.open(request)

下面是集成了該功能的新版本 download 函數:

# -*- coding: utf-8 -*-
import urllib.request
import urllib.parse
def download5(url, user_agent='wswp',proxy=None, num_retries=2):
    """支持代理功能的下載函數"""
    print("Downloading:", url)
    headers = {'User-agent': user_agent}
    request = urllib.request.Request(url, headers=headers)
    opener = urllib.request.build_opener()
    if proxy:
        proxy_params = {urllib.parse.urlparse(url).scheme: proxy}
        opener.add_handler(urllib.request.ProxyHandler(proxy_params))
    try:
        html = opener.open(request).read()
    except urllib.request.URLError as e:
        print("Download error:", e.reason)
        html = None
        if num_retries > 0:
            if hasattr(e, 'code') and 500 <= e.code < 600:
                # 重試 5xx HTTP 錯誤
                html = download5(url, user_agent, proxy, num_retries-1)
    return html

下載限速

如果我們爬取網站的速度過快,就會面臨被封禁或是造成服務器過載的風險。為了降低這些風險,我們可以在兩次下載之間添加延時,從而對爬蟲限速。下面是實現了該功能的類的代碼。

class Throttle:
    def  __init__(self, delay):
        self.delay = delay
        self.domains = {}

    def wait(self, url):
        domain = url.parse.urlparse(url).netloc
        last_accessed = self.domains.get(domain)
        if self.delay > 0 and last_accessed is not None:
            sleep_secs = self.delay - (datetime.now() - last_accessed).seconds
            if sleep_secs > 0:
                time.sleep(sleep_secs)
        self.domains[domain] = datetime.now()

     def get_links(html):
        webpage_regex = re.compile('<a[^>]+href=["\'](.*?)["\']', re.IGNORECASE)
        return webpage_regex.findall(html)

Throttle 類記錄了每個域名上次訪問的時間,如果當前時間距離上次訪問時間小于指定延時,則執行睡眠操作。我們可以在每次下載之前調用Throttle對爬蟲進行限速。

throttles = Throttle(delay)
...
throttle.wait(url)
result = download(url, headers, proxy = proxy, num_retries=num_retries)

避免爬蟲陷阱

目前,我們的爬蟲會跟蹤所有之前沒有訪問過的鏈接。但是,一些網站會動態生成頁面內容,這樣就會出現無限多的網頁。比如,網站有一個在線日歷功能,提供了可以訪問下個月和下一年的鏈接,那么下個月的頁面中同樣會包含訪問下個月的鏈接,這樣頁面就會無止境地鏈接下去。這種情況被稱為爬蟲陷阱。
想要避免陷入爬蟲陷阱,一個簡單的方法是記錄到達當前網頁經過了多少個鏈接,也就是深度。當到達最大深度時,爬蟲就不在向隊列中添加該網頁中的鏈接了。要實現這一功能,我們需要修改seen變量。該變量原先只記錄訪問過的網頁鏈接,現在修改為一個字典,增加了頁面深度的記錄。

def link_crawler(.... max_depth=2):
    max_depth = 2
    seen = {}
    ...
    depth = seen[url]
    if depth != max_depth:
            for link in links:
                if link not in seen:
                    seen[link] = depth + 1
                    crawl_queue.append(link)

現在有了這一功能,我們就有信心爬蟲最終一定能夠完成。如果想要禁用該功能,只需將max_depth設為一個負數即可,此時當前深度永遠不會與之相等。

最終版本

這個高級鏈接爬蟲的完整源代碼可以在下載。要測試這段代碼,我們可以將用戶代理設置為BadCrawler,也就是本章前文所述的被robots.txt屏蔽了的那個用戶代理。從下面的運行結果中可以看出,爬蟲果然被屏蔽了,代碼啟動后馬上就會結束。

>>> seed_url = 'http://example.webscraping.com/index'
>>> link_regex = '/(index|view)'
>>> link_crawler(seed_url, link_regex, user_agent='BadCrawlar')
Blocked by robots.txt: http://example.webscraping.com/

現在,讓我們使用默認的用戶代理,并將最大深度設置為1,這樣只有主頁上的鏈接才會被下載。

>>> link_crawler(seed_url, link_regex, max_depth=1)

和預期一樣,爬蟲在下載完國家列表的第一頁之后就停止了。

最終鏈接爬蟲的代碼如下:

import re
import urllib.parse
import urllib.request
import urllib.robotparser
import time
from datetime import datetime
import queue


def link_crawler(seed_url, link_regex=None, delay=5, max_depth=-1, 
        max_urls=-1, headers=None, user_agent='wswp', proxy=None, num_retries=1):
    """從指定的種子網址按照link_regex匹配的鏈接進行抓取"""
    crawal_queue = queue.deque([seed_url]) # 仍然需要抓取的網址隊列
    seen = {seed_url: 0} # 已經看到的網址以及深度
    num_urls = 0 # 跟蹤已下載了多少個URL
    rp = get_robots(seed_url)
    throttle = Throttle(delay)
    headers = headers or {}
    if user_agent:
        headers['User-agent'] = user_agent

    while crawal_queue:
        url = crawal_queue.pop()
        # 檢查網址傳遞的robots.txt限制
        if rp.can_fetch(user_agent, url):
            throttle.wait(url)
            html = download(url, headers, proxy=proxy, num_retries=num_retries)
            links = []

            depth = seen[url]
            if depth != max_depth:
                # 仍然可以進一步爬行
                if link_regex:
                    # 過濾符合我們的正則表達式的鏈接
                    links.extend(link for link in get_links(html) if re.match(link_regex, link))

                for link in links:
                    link = normalize(seed_url, link)
                    # 檢查是否已經抓取這個鏈接
                    if link not in seen:
                        seen[link] = depth + 1
                        # 檢查鏈接在同一域內
                        if same_domain(seed_url, link):
                            # 成功! 添加這個新鏈接到隊列里
                            crawal_queue.append(link)

            # 檢查是否已達到下載的最大值
            num_urls += 1
            if num_urls == max_urls:
                break
        else:
            print("Blocked by robots.txt:", url) # 鏈接已被robots.txt封鎖

class Throttle:
    """Throttle通過睡眠在請求之間下載同一個域"""
    def __init__(self, delay):
        """每個域的下載之間的延遲量"""
        self.delay = delay
        # 上次訪問域時的時間戳
        self.domains = {}

    def wait(self, url):
        domain = urllib.parse.urlparse(url).netloc
        last_accessed = self.domains.get(domain)

        if self.delay > 0 and last_accessed is not None:
            sleep_secs = self.delay - (datetime.now() - last_accessed).seconds
            if sleep_secs > 0:
                time.sleep(sleep_secs)
        self.domains[domain] = datetime.now()

def download(url, headers, proxy, num_retries, data=None):
    print("Downloading:", url)
    request = urllib.request.Request(url, data, headers)
    opener = urllib.request.build_opener()
    if proxy:
        proxy_params = {urllib.parse.urlparse(url).scheme: proxy}
        opener.add_handler(urllib.request.ProxyHandler(proxy_params))
    try:
        response = opener.open(request)
        html = response.read()
        code = response.code
    except urllib.request.URLError as e:
        print("Download error:", e.reason)
        html = ''
        if hasattr(e, 'code'):
            code = e.code
            if num_retries > 0 and 500 <= code < 600:
                # 重試 5xx HTTP 錯誤
                return download(url, headers, proxy, num_retries-1, data)
        else:
            code = None
    return html

def normalize(seed_url, link):
    """通過刪除散列和添加域來規范化此URL"""
    link, _ = urllib.parse.urldefrag(link) # 刪除散列以避免重復
    return urllib.parse.urljoin(seed_url, link)

def same_domain(url1, url2):
    """如果兩個網址屬于同一域,則返回True"""
    return urllib.parse.urlparse(url1).netloc == urllib.parse.urlparse(url2).netloc

def get_robots(url):
    """初始化此域的機器人解析器"""
    rp = urllib.robotparser.RobotFileParser()
    rp.set_url(urllib.parse.urljoin(url, '/robots.txt'))
    rp.read()
    return rp

def get_links(html):
    """從HTML返回一個鏈接列表"""
    # 從網頁提取所有鏈接的正則表達式
    webpage_regex = re.compile('<a[^>]+href=["\'](.*?)["\']', re.IGNORECASE)
    html = html.decode('utf-8')
    # 來自網頁的所有鏈接的列表
    return webpage_regex.findall(html)
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容

  • 1 前言 作為一名合格的數據分析師,其完整的技術知識體系必須貫穿數據獲取、數據存儲、數據提取、數據分析、數據挖掘、...
    whenif閱讀 18,105評論 45 523
  • 書名:《用python寫網絡爬蟲》,通過閱讀并記錄去學習,如果文章有什么錯誤的地方還希望指正本文參考了http:/...
    楓灬葉閱讀 2,904評論 2 2
  • Spring Cloud為開發人員提供了快速構建分布式系統中一些常見模式的工具(例如配置管理,服務發現,斷路器,智...
    卡卡羅2017閱讀 134,915評論 18 139
  • 聲明:本文講解的實戰內容,均僅用于學習交流,請勿用于任何商業用途! 一、前言 強烈建議:請在電腦的陪同下,閱讀本文...
    Bruce_Szh閱讀 12,768評論 6 28
  • 詞:董書利 今天愛讓彼此難堪 在愛與不愛里互換 誓言不見那愛已走遠 雖有千頭卻如此凌亂 相守不再等待而是忍耐 那曾...
    星巢文化閱讀 229評論 0 1