Python高級特性與網絡爬蟲(四):異步多協程維護代理池

在做爬蟲的時候,如果我們過於頻繁地訪問某個網站,可能會導致我們的ip被封掉,在這個時候我們需要代理來僞裝自己的ip,網上的代理服務有很多,免費的和收費的都有。這些ip有的時候不太穩定,所以我們需要維護一個能夠實時從代理服務網站上爬取代理ip並測試其是否可用,選取可用ip,這就需要爬取和測試一個代理池。這篇博文講述的就是這樣一個代理池的構造方法,從一個免費代理網站http://www.66ip.cn上,主要涉及到的有python與redis緩存數據庫的交互,async/await異步多線程編程以及元類(metaclass)的構建,主要包含有如下三個基本模塊:

  1. 存儲模塊:負責存儲抓取下來的代理,爲了保證抓取下來的ip不會重複,同時能實時標識每個ip的可用情況,這裏採用Redis的Sorted Set即有序集合來存儲
  2. 獲取模塊:負責定時從代理網站上抓取代理ip及其端口號
  3. 檢測模塊:定時檢測存儲在Redis中的ip代理是否可用,這裏需要一個檢測鏈接,一般是你要用這些代理ip爬取哪個網站的內容,就用該網站作爲檢測鏈接,這裏我們用百度作爲檢測鏈接。我們用一個分數來標識每個ip代理的狀態,檢測一次,如果代理可用或者不可用就相應地增減分數
    下面將分別介紹各個模塊的Python代碼

存儲模塊(Python和Redis交互)

存儲模塊的存儲後端採用的是Redis的有序集合,集合的元素是一個個代理ip,每個代理ip都會有一個分數字段,該集合會根據每一個元素的分數對集合進行排序,數值小的排在前面,數值大的排在後面。一個代理的分數作爲判斷該代理是否可用的標誌,100分爲最高分,0分爲最低分,分數設置的規則如下:新獲取的代理初始分數爲10,如果測試可用,則置爲100,如果不可用,分數則減1,分數減到0之後該代理將被移除,模塊代碼如下所示,包括__init__()(建立和Redis連接),add()(向數據庫中添加代理並設置分數),random()(隨機獲取代理,首先獲取100分的代理,隨機選擇一個返回,如果不存在100分的代理,就按照排名獲取,選取前100名,然後隨機選擇一個返回)等方法,涉及到的Redis庫函數包括zscore,zadd,zrangebyscore,zrevrange,zincrby,zrem,zcard,具體用法參見注釋

import redis
from random import choice

REDIS_HOST='localhost'
REDIS_PORT=6379
REDIS_PASSWORD=None #替換成你的redis數據庫密碼
REDIS_KEY='proxies'
class RedisClient(object):
    def __init__(self,host=REDIS_HOST,port=REDIS_PORT,password=REDIS_PASSWORD):
        self.db=redis.StrictRedis(host=host,port=port,password=password,decode_responses=True) #建立和Redis的連接

    def add(self,proxy,score=INITIAL_SCORE):
        if not self.db.zscore(REDIS_KEY,proxy): #Redis Zscore 命令返回有序集(有序集合名稱爲REDIS_KEY)中,成員的分數值。 如果成員元素不是有序集 key 的成員,或 key 不存在,返回 nil
            return self.db.zadd(REDIS_KEY,{proxy: score}) #添加代理,proxy爲ip+端口,score爲初始分數10

    def random(self):
        """
        get random proxy
        firstly try to get proxy with max score
        if not exists, try to get proxy by rank
        if not exists, raise error
        :return: proxy, like 8.8.8.8:8
        """
        # try to get proxy with max score
        #zrangebyscore(key min max),返回有序集 key 中,所有 score 值介於 min 和 max 之間(包括等於 min 或 max )的成員,有序集成員按 score 值遞增(從小到大)次序排列
        result = self.db.zrangebyscore(REDIS_KEY, MAX_SCORE, MAX_SCORE)
        if len(result):
            return choice(result)
        # else get proxy by rank
        # Zrevrange 命令返回有序集中,指定區間內的成員,成員的位置按分數值遞減(從大到小)來排列(和zrangebyscore相反)
        result = self.db.zrevrange(REDIS_KEY, MIN_SCORE, MAX_SCORE)
        if len(result):
            return choice(result)
        # else raise error
        raise

    def decrease(self, proxy):
        """
        decrease score of proxy, if small than PROXY_SCORE_MIN, delete it
        :param proxy: proxy
        :return: new score
        """
        score = self.db.zscore(REDIS_KEY, proxy)
        # current score is larger than PROXY_SCORE_MIN
        if score and score > MIN_SCORE:
            print('代理',proxy,'當前分數',score,'減1')
            #zincrby(key,value,proxy):對有序集合中的proxy元素的值執行加value的操作
            return self.db.zincrby(REDIS_KEY,-1,proxy)
        # otherwise delete proxy
        else:
            print('代理', proxy, '當前分數', score, '移除')
            #zrem(key,proxy):從有序集合中移除元素proxy
            return self.db.zrem(REDIS_KEY, proxy)

    def exists(self, proxy):
        """
        if proxy exists
        :param proxy: proxy
        :return: if exists, bool
        """
        return not self.db.zscore(REDIS_KEY, proxy) is None

    def max(self, proxy):
        """
        set proxy to max score
        :param proxy: proxy
        :return: new score
        """
        print('代理', proxy, '可用,設置爲', MAX_SCORE)
        return self.db.zadd(REDIS_KEY, {proxy: MAX_SCORE})

    def count(self):
        """
        get count of proxies
        :return: count, int
        """
        return self.db.zcard(REDIS_KEY) #zcard返回集合key中的元素數量

    def all(self):
        """
        get all proxies
        :return: list of proxies
        """
        return self.db.zrangebyscore(REDIS_KEY, MIN_SCORE, MAX_SCORE)

    def batch(self, start, end):
        """
        get batch of proxies
        :param start: start index
        :param end: end index
        :return: list of proxies
        """
        return self.db.zrevrange(REDIS_KEY, start, end - 1)

獲取模塊(元類metaclass)

獲取模塊定義一個crawler類來從http://www.66ip.cn中爬取ip及其端口號,我們首先來看一下www.66ip.cn的網頁結構,如下圖所示,ip和端口號都在表格中,翻頁則是訪問http://www.66ip.cn/2.html即可訪問第二頁表格的代理。
在這裏插入圖片描述
獲取模塊的Crawler類的相關代碼如下所示,這裏藉助了元類(metaclass)來實現獲取類Crawler中所有以crawl開頭的方法。metaclass,直譯爲元類,簡單的解釋就是:當我們定義了類以後,就可以根據這個類創建出實例,先定義類,然後創建實例。但是如果我們想創建出類呢?那就必須根據metaclass創建出類,所以:先定義元類(不自定義時,默認用type),然後創建類。metaclass的原理其實是這樣的:當定義好類之後,創建類的時候其實是調用了type的__new__方法爲這個類分配內存空間,Crawler的metaclass參數設置爲ProxyMetaClass,Python解釋器在創建Crawler時,要執行ProxyMetaClass.__new__()來創建,在此,我們可以修改類的定義,attrs爲類的屬性,我們爲attrs添加兩個屬性__CrawlFunc__(包含Craweler中所有以Crawl開頭的方法名稱)和__CrawlFuncCount__(Craweler中以Crawl開頭的方法的數量),之後返回修改後的定義return type.__new__(cls,name,bases,attrs),創建類的時候其實是調用了type的__new__\方法,此時attrs爲修改後的attrs。這樣做的目的是爲了方便Crawler類的擴展,這裏只定義了一個爬取66ip的方法crawl_daili66,爬取前四頁的ip,以後想要添加爬取別的代理網站的ip的方法就可以直接添加以crawl開頭的方法,能夠返回ip:port的字符串即可(yield ‘:’.join([ip,port])),不用關係類其他部分的實現邏輯。最後通過Getter類來創建RedisClient()和Crawler()實例,將爬取的代碼添加到有序集合proxies中。

import requests
from bs4 import BeautifulSoup

POOL_UPPER_THRESHOLD=10000 #代理池中最大能夠存儲的代理數量

class ProxyMetaClass(type):  #元類
    def __new__(cls,name,bases,attrs): #attrs參數包含了類的一些屬性
        count=0
        attrs['__CrawlFunc__']=[]
        for k,v in attrs.items():   #遍歷attrs即可獲得類的所有方法信息,判斷方法開頭是否爲crawl
            if 'crawl' in k:
                attrs['__CrawlFunc__'].append(k)
                count+=1
        attrs['__CrawlFuncCount__']=count
        return type.__new__(cls,name,bases,attrs)

class Crawler(object,metaclass=ProxyMetaClass):
    def get_proxies(self,callback):
        proxies=[]
        for proxy in eval("self.{}()".format(callback)): #eval執行所有的callback方法
            print('成功獲取到代理',proxy)
            proxies.append(proxy)
        return proxies

    def crawl_daili66(self,page_count=4):
        start_url="http://www.66ip.cn/{}.html"
        urls=[start_url.format(page) for page in range(1,page_count+1)]
        for url in urls:
            r = requests.get(url, headers=header)
            soup = BeautifulSoup(r.text, 'lxml')
            length = len(soup.find_all("table")[2].find_all("td"))
            for i in range(5,length, 5):
                ip = soup.find_all("table")[2].find_all("td")[i].text
                port = soup.find_all("table")[2].find_all("td")[i + 1].text
                yield ':'.join([ip,port])
class Getter(): #動態調用Crawl類中以crawl開頭的方法
    def __init__(self):
        self.redis=RedisClient()
        self.crawler=Crawler()

    def is_over_threshold(self):
        if self.redis.count()>=POOL_UPPER_THRESHOLD:
            return True
        else:
            return False

    def run(self):
        print("獲取器開始執行")
        if not self.is_over_threshold():
            for callback_label in range(self.crawler.__CrawlFuncCount__):
                callback=self.crawler.__CrawlFunc__[callback_label]
                proxies=self.crawler.get_proxies(callback)
                for proxy in proxies:
                    self.redis.add(proxy)

檢測模塊(async/await異步多協程)

成功將各個網站的代理爬取下來並存入Redis有序集合後,需要一個檢測模塊來對所有的代理進行多輪檢測,根據檢測結果調整代理的相應分數。由於代理的數量比較多,每次測試代理ip的請求又是一個比較耗時的操作,所以我們使用異步請求庫aiohttp來進行檢測,該python庫使用pip安裝即可。所謂的異步請求,是相對於同步請求requests來說的,我們之前使用requests發出一個get請求,程序需要等網頁加載完成之後才能繼續執行,也就是這個過程會阻塞等待響應,我們在這個等待時間裏可以去做其他事情,類似於多進程多線程那樣的操作,異步請求庫aiohttp就支持這樣的操作,在請求發出之後,程序可以執行其他事情,比如對其他代理髮起檢測之類的。最終實現檢測功能的類代碼如下所示,通過async關鍵字使test_single_proxy成爲一個協程,異步上下文管理器指的是在異步請求aiohttp.ClientSession()的enter和exit方法處能夠暫停執行的上下文管理器。最後在run()方法中,通過asyncio.get_event_loop()創建一個事件loop,loop.run_until_complete(future)能夠將future註冊到循環當中,而asyncio.wait(tasks)則是將各個協程包裝成爲一個future(future可以理解爲是一個內部包含衆多協程的大協程),最後實例化爬取存儲模塊Getter()和Tester(),循環執行爬取存儲和測試操作。

import aiohttp
import asyncio
import time

VALID_STATUS_CODES=[200]
TEST_URL='http://www.baidu.com'
BATCH_TEST_SIZE=100

class Tester(object):
    def __init__(self):
        self.redis=RedisClient()

    async def test_single_proxy(self,proxy): #使方法變爲一個協程的關鍵字
        conn=aiohttp.TCPConnector(ssl=False)
        async with aiohttp.ClientSession(connector=conn) as session: #類似requests中的Session對象
            try:
                if isinstance(proxy,bytes):
                    proxy=proxy.decode('utf-8')
                real_proxy='http://'+proxy
                print('正在測試',proxy)
                async with session.get(TEST_URL,proxy=real_proxy,timeout=15) as response:
                    if response.status in VALID_STATUS_CODES:
                        self.redis.max(proxy)
                        print('代理可用',proxy)
                    else:
                        self.redis.decrease(proxy)
                        print("請求響應碼不合法",proxy)
            except:
                self.redis.decrease(proxy)
                print('代理請求失敗',proxy)
    def run(self):
        print('測試器開始運行')
        try:
            proxies=self.redis.all()
            loop=asyncio.get_event_loop()
            for i in range(0,len(proxies),BATCH_TEST_SIZE): 
                test_proxies=proxies[i:i+BATCH_TEST_SIZE]
                tasks=[self.test_single_proxy(proxy) for proxy in test_proxies]
                loop.run_until_complete(asyncio.wait(tasks))
                time.sleep(5)
        except Exception as e:
            print('測試器發生錯誤',e.args)

if __name__ =='__main__':
	Get=Getter()
	test=Tester()
	while(True):
		Get.run()
		test.run()

通過async/await多協程爬取微博圖片

爲了加深對async/await異步多協程機制的理解,針對https://blog.csdn.net/weixin_41977332/article/details/105591034中多進程爬取微博用戶圖片程序改造成了多協程爬取,代碼如下所示,異步上下文管理器async with不能使用普通的同步請求的文件打開函數open(),有相應的異步文件操作庫aiofiles可以使用。每個協程在執行到await f.write(r.content)便可以在等待IO操作時切換下一個協程執行。

#author:xingfengxueyu
#Date:2020/05/01

from urllib.parse import urlencode
import requests
import time
import os
import asyncio
import aiofiles
base_url='https://m.weibo.cn/api/container/getIndex?'

async def get_pics(item):
    global lock
    if item.get('mblog')!=None:
        item=item.get('mblog')
    else:
        return
    item = item.get('pics')
    if item != None:
        for pic in item:
            if pic.get('large') != None:
                url=pic.get('large').get('url')
                title=url.split('/')[-1]
                r=requests.get(url)
                async with aiofiles.open(title,'wb') as f: #async和await的配合 
                    #print("開始寫入圖片{}".format(title))
                    await f.write(r.content)

def get_all_weibo(since_id):
    if since_id==None:
        return None
    headers={
        'Referer': 'https://m.weibo.cn/u/6235323673?from=myfollow_all&is_all=1&sudaref=login.sina.com.cn',
        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.25 Safari/537.36 Core/1.70.3706.400 SLBrowser/10.0.4040.400',
        'X-Requested-With': 'XMLHttpRequest',
    }
    params={
        'from': 'myfollow_all',
        'is_all': '1',
        'sudaref': 'login.sina.com.cn',
        'type': 'uid',
        'value': '6235323673',
        'containerid': '1076036235323673',
        'since_id': since_id,
    }
    url=base_url+urlencode(params)
    r=requests.get(url,headers=headers)
    return r.json().get('data').get('cards')

if __name__ =='__main__':
    items=get_all_weibo('4494883576803454')
    start=time.time()
    loop=asyncio.get_event_loop()
    while(1):
        tasks=[get_pics(item) for item in items]
        loop.run_until_complete(asyncio.wait(tasks))
        for i in range(len(items)):  #尋找最後一條含有mblog字段的card,取其mblog字段中的id作爲下一次循環的id
            if items[len(items)-1-i].get('mblog')!=None:
                since_id=items[-1].get('mblog').get('id')
                break
        items=get_all_weibo(since_id)
        time.sleep(0.5)
        print(len(os.listdir('yaoyao')))
        if(len(os.listdir('yaoyao'))>200):
            break
        if items==None or len(items)<=1:
            break
        items=items[1:]
    print("運行時間:",time.time()-start)
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章