在做爬蟲的時候,如果我們過於頻繁地訪問某個網站,可能會導致我們的ip被封掉,在這個時候我們需要代理來僞裝自己的ip,網上的代理服務有很多,免費的和收費的都有。這些ip有的時候不太穩定,所以我們需要維護一個能夠實時從代理服務網站上爬取代理ip並測試其是否可用,選取可用ip,這就需要爬取和測試一個代理池。這篇博文講述的就是這樣一個代理池的構造方法,從一個免費代理網站http://www.66ip.cn上,主要涉及到的有python與redis緩存數據庫的交互,async/await異步多線程編程以及元類(metaclass)的構建,主要包含有如下三個基本模塊:
- 存儲模塊:負責存儲抓取下來的代理,爲了保證抓取下來的ip不會重複,同時能實時標識每個ip的可用情況,這裏採用Redis的Sorted Set即有序集合來存儲
- 獲取模塊:負責定時從代理網站上抓取代理ip及其端口號
- 檢測模塊:定時檢測存儲在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)