大規模非同步新聞爬蟲:實現功能強大、簡潔易用的網址池(URL Pool)

王平發表於2018-12-03

一個生產者消費者模型圖片

對於比較大型的爬蟲來說,URL管理的管理是個核心問題,管理不好,就可能重複下載,也可能遺漏下載。這裡,我們設計一個URL Pool來管理URL。
這個URL Pool就是一個生產者-消費者模式:

生產者-消費者流程圖

依葫蘆畫瓢,URLPool就是這樣的

設計的網路爬蟲URLPool

我們從網址池的使用目的出發來設計網址池的介面,它應該具有以下功能:

  • 往池子裡面新增URL;
  • 從池子裡面取URL以下載;
  • 池子內部要管理URL狀態;

前面我提到URL的狀態有以下4中:

  • 已經下載成功
  • 下載多次失敗無需再下載
  • 正在下載
  • 下載失敗要再次嘗試

前兩個是永久狀態,也就是已經下載成功的不再下載,多次嘗試後仍失敗的也就不再下載,它們需要永久儲存起來,以便爬蟲重啟後,這種永久狀態記錄不會消失,已經成功下載的URL不再被重複下載。永久儲存的方法有很多種:

比如,直接寫入文字檔案,但它不利於查詢某個URL是否已經存在文字中;
比如,直接寫入MySQL等關係型資料庫,它利用查詢,但是速度又比較慢;
比如,使用key-value資料庫,查詢和速度都符合要求,是不錯的選擇!

我們這個URL Pool選用LevelDB來作為URL狀態的永久儲存。LevelDB是Google開源的一個key-value資料庫,速度非常快,同時自動壓縮資料。我們用它先來實現一個UrlDB作為永久儲存資料庫。

UrlDB 的實現

import leveldb

class UrlDB:
    '''Use LevelDB to store URLs what have been done(succeed or faile)
    '''
    status_failure = b'0'
    status_success = b'1'

    def __init__(self, db_name):
        self.name = db_name + '.urldb'
        self.db = leveldb.LevelDB(self.name)

    def load_from_db(self, status):
        urls = []
        for url, _status in self.db.RangeIter():
            if status == _status:
                urls.append(url)
        return urls

    def set_success(self, url):
        if isinstance(url, str):
            url = url.encode('utf8')
        try:
            self.db.Put(url, self.state_success)
            s = True
        except:
            s = False
        return s

    def set_failure(self, url):
        if isinstance(url, str):
            url = url.encode('utf8')
        try:
            self.db.Put(url, self.status_failure)
            s = True
        except:
            s = False
        return s

    def has(self, url):
        if isinstance(url, str):
            url = url.encode('utf8')
        try:
            attr = self.db.Get(url)
            return attr
        except:
            pass
        return False

UrlDB將被UrlPool使用,主要有三個方法被使用:

  • has(url) 檢視是否已經存在某url
  • set_success(url) 儲存url狀態為成功
  • set_failure(url) 儲存url狀態為失敗

UrlPool 的實現

而正在下載和下載失敗次數這兩個URL的狀態只需暫時儲存在內容即可,我們把它們放到UrlPool這個類中進行管理。接著我們來實現網址池:

#Author: veelion

import pickle
import leveldb
import time
import urllib.parse as urlparse


class UrlPool:
    '''URL Pool for crawler to manage URLs
    '''

    def __init__(self, pool_name):
        self.name = pool_name
        self.db = UrlDB(pool_name)

        self.pool = {}  # host: set([urls]), 記錄待下載URL
        self.pending = {}  # url: pended_time, 記錄已被pend但還未被更新狀態(正在下載)的URL
        self.failure = {}  # url: times, 記錄失敗的URL的次數
        self.failure_threshold = 3
        self.pending_threshold = 60  # pending的最大時間,過期要重新下載
        self.in_mem_count = 0
        self.max_hosts = ['', 0]  # [host: url_count] 目前pool中url最多的host及其url數量
        self.hub_pool = {}  # {url: last_query_time}
        self.hub_refresh_span = 0
        self.load_cache()

    def load_cache(self,):
        path = self.name + '.pkl'
        try:
            with open(path, 'rb') as f:
                self.pool = pickle.load(f)
            cc = [len(v) for k, v in self.pool]
            print('saved pool loaded! urls:', sum(cc))
        except:
            pass

    def set_hubs(self, urls, hub_refresh_span):
        self.hub_refresh_span = hub_refresh_span
        self.hub_pool = {}
        for url in urls:
            self.hub_pool[url] = 0

    def set_status(self, url, status_code):
        if url in self.pending:
            self.pending.pop(url)

        if status_code == 200:
            self.db.set_success(url)
            return
        if status_code == 404:
            self.db.set_failure(url)
            return
        if url in self.failure:
            self.failure[url] += 1
            if self.failure[url] > self.failure_threshold:
                self.db.set_failure(url)
                self.failure.pop(url)
            else:
                self.add(url)
        else:
            self.failure[url] = 1

    def push_to_pool(self, url):
        host = urlparse.urlparse(url).netloc
        if not host or '.' not in host:
            print('try to push_to_pool with bad url:', url, ', len of ur:', len(url))
            return False
        if host in self.pool:
            if url in self.pool[host]:
                return True
            self.pool[host].add(url)
            if len(self.pool[host]) > self.max_hosts[1]:
                self.max_hosts[1] = len(self.pool[host])
                self.max_hosts[0] = host
        else:
            self.pool[host] = set([url])
        self.in_mem_count += 1
        return True

    def add(self, url, always):
        if always:
            return self.push_to_pool(url)
        pended_time = self.pending.get(url, 0)
        if time.time() - pended_time < self.pending_threshold:
            print('being downloading:', url)
            return
        if self.db.has(url):
            return
        if pended_time:
            self.pending.pop(url)
        return self.push_to_pool(url)

    def addmany(self, urls, always=False):
        if isinstance(urls, str):
            print('urls is a str !!!!', urls)
            self.add(urls, always)
        else:
            for url in urls:
                self.add(url, always)

    def pop(self, count, hubpercent=50):
        print('\n\tmax of host:', self.max_hosts)

        # 取出的url有兩種型別:hub=1, 普通=2
        url_attr_url = 0
        url_attr_hub = 1
        # 1. 首先取出hub,保證獲取hub裡面的最新url.
        hubs = {}
        hub_count = count * hubpercent // 100
        for hub in self.hub_pool:
            span = time.time() - self.hub_pool[hub]
            if span < self.hub_refresh_span:
                continue
            hubs[hub] = url_attr_hub  # 1 means hub-url
            self.hub_pool[hub] = time.time()
            if len(hubs) >= hub_count:
                break

        # 2. 再取出普通url
        # 如果某個host有太多url,則每次可以取出3(delta)個它的url
        if self.max_hosts[1] * 10 > self.in_mem_count:
            delta = 3
            print('\tset delta:', delta, ', max of host:', self.max_hosts)
        else:
            delta = 1
        left_count = count - len(hubs)
        urls = {}
        for host in self.pool:
            if not self.pool[host]:
                # empty_host.append(host)
                continue
            if self.max_hosts[0] == host:
                while delta > 0:
                    url = self.pool[host].pop()
                    self.max_hosts[1] -= 1
                    if not self.pool[host]:
                        break
                    delta -= 1
            else:
                url = self.pool[host].pop()
            urls[url] = url_attr_url
            self.pending[url] = time.time()
            if len(urls) >= left_count:
                break
        self.in_mem_count -= len(urls)
        print('To pop:%s, hubs: %s, urls: %s, hosts:%s' % (count, len(hubs), len(urls), len(self.pool)))
        urls.update(hubs)
        return urls

    def size(self,):
        return self.in_mem_count

    def empty(self,):
        return self.in_mem_count == 0

    def __del__(self):
        path = self.name + '.pkl'
        try:
            with open(path, 'wb') as f:
                pickle.dump(self.pool, f)
            print('self.pool saved!')
        except:
            pass

UrlPool的實現有些複雜,且聽我一一分解。

UrlPool 的使用

先看看它的主要成員及其用途:

  • self.db 是一個UrlDB的示例,用來永久儲存url的永久狀態
  • self.pool 是用來存放url的,它是一個字典(dict)結構,key是url的host,value是一個用來儲存這個host的所有url的集合(set)。
  • self.pending 用來管理正在下載的url狀態。它是一個字典結構,key是url,value是它被pop的時間戳。當一個url被pop()時,就是它被下載的開始。當該url被set_status()時,就是下載結束的時刻。如果一個url被add() 入pool時,發現它已經被pended的時間超過pending_threshold時,就可以再次入庫等待被下載。否則,暫不入池。
  • self.failue 是一個字典,key是url,value是識別的次數,超過failure_threshold就會被永久記錄為失敗,不再嘗試下載。
  • hub_pool 是一個用來儲存hub頁面的字典,key是hub url,value是上次重新整理該hub頁面的時間.

以上成員就構成了我們這個網址池的資料結構,再通過以下成員方法對這個網址池進行操作:

1. load_cache() 和 dump_cache() 對網址池進行快取
load_cache() 在 init()中呼叫,建立pool的時候,嘗試去載入上次退出時快取的URL pool;
dump_cache() 在 del() 中呼叫,也就是在網址池銷燬前(比如爬蟲意外退出),把記憶體中的URL pool快取到硬碟。
這裡使用了pickle 模組,這是一個把記憶體資料序列化到硬碟的工具。

** 2. set_hubs() 方法設定hub URL**
hub網頁就是像百度新聞那樣的頁面,整個頁面都是新聞的標題和連結,是我們真正需要的新聞的聚合頁面,並且這樣的頁面會不斷更新,把最新的新聞聚合到這樣的頁面,我們稱它們為hub頁面,其URL就是hub url。在新聞爬蟲中新增大量的這樣的url,有助於爬蟲及時發現並抓取最新的新聞。
該方法就是將這樣的hub url列表傳給網址池,在爬蟲從池中取URL時,根據時間間隔(self.hub_refresh_span)來取hub url。

** 3. add(), addmany(), push_to_pool() 對網址池進行入池操作**
把url放入網址池時,先檢查記憶體中的self.pending是否存在該url,即是否正在下載該url。如果正在下載就不入池;如果正下載或已經超時,就進行到下一步;
接著檢查該url是否已經在leveldb中存在,存在就表明之前已經成功下載或徹底失敗,不再下載了也不入池。如果沒有則進行到下一步;
最後通過push_to_pool() 把url放入self.pool中。存放的規則是,按照url的host進行分類,相同host的url放到一起,在取出時每個host取一個url,儘量保證每次取出的一批url都是指向不同的伺服器的,這樣做的目的也是為了儘量減少對抓取目標伺服器的請求壓力。力爭做一個伺服器友好的爬蟲 O(∩_∩)O

** 4. pop() 對網址池進行出池操作**
爬蟲通過該方法,從網址池中獲取一批url去下載。取出url分兩步:
第一步,先從self.hub_pool中獲得,方法是遍歷hub_pool,檢查每個hub-url距上次被pop的時間間隔是否超過hub頁面重新整理間隔(self.hub_refresh_span),來決定hub-url是否應該被pop。
第二步,從self.pool中獲取。前面push_to_pool中,介紹了pop的原則,就是每次取出的一批url都是指向不同伺服器的,有了self.pool的特殊資料結構,安裝這個原則獲取url就簡單了,按host(self.pool的key)遍歷self.pool即可。

** 5. set_status() 方法設定網址池中url的狀態**
其引數status_code 是http響應的狀態碼。爬蟲在下載完URL後進行url狀態設定。
首先,把該url成self.pending中刪除,已經下載完畢,不再是pending狀態;
接著,根據status_code來設定url狀態,200和404的直接設定為永久狀態;其它status就記錄失敗次數,並再次入池進行後續下載嘗試。

通過以上成員變數和方法,我們把這個網址池(UrlPool)解析的清清楚楚。小猿們可以毫不客氣的收藏起來,今後在寫爬蟲時可以用它方便的管理URL,並且這個實現只有一個py檔案,方便加入到任何專案中。

爬蟲知識點

1. 網址的管理
網址的管理,其目的就是為了:不重抓,不漏抓。

2. pickle 模組
把記憶體資料儲存到硬碟,再把硬碟資料重新載入到記憶體,這是很多程式停止和啟動的必要步驟。pickle就是實現資料在記憶體和硬碟之間轉移的模組。

3. leveldb 模組
這是一個經典且強大的硬碟型key-value資料庫,非常適合url-status這種結構的儲存。

4. urllib.parse
解析網址的模組,在處理url時首先想到的模組就應該是它。

下一篇我們把mysql再封裝一下。

猿人學banner宣傳圖

我的公眾號:猿人學 Python 上會分享更多心得體會,敬請關注。

***版權申明:若沒有特殊說明,文章皆是猿人學 yuanrenxue.com 原創,沒有猿人學授權,請勿以任何形式轉載。***

相關文章