“等了好久終於等到今天,夢裡好久終於把夢實現”,腦海裡不禁響起來劉德華這首歌。是啊,終於可以寫我最喜歡的非同步爬蟲了。前面那麼多章節,一步一步、循序漸進的講解,實在是“嘮叨”了不少,可是為了小猿們能由淺入深的學習爬蟲,老猿我又不得不說那麼多“嘮叨”,可把我給憋死了,今天就大書特書非同步爬蟲,說個痛快!
關於非同步IO這個概念,可能有些小猿們不是非常明白,那就先來看看非同步IO是怎麼回事兒。
為了大家能夠更形象得理解這個概念,我們拿放羊來打個比方:
- 下載請求開始,就是放羊出去吃草;
- 下載任務完成,就是羊吃飽回羊圈。
同步放羊的過程就是這樣的:
羊倌兒小同要放100只羊,他就先放一隻羊出去吃草,等羊吃飽了回來在放第二隻羊,等第二隻羊吃飽了回來再放第三隻羊出去吃草……這樣放羊的羊倌兒實在是……
再看看非同步放羊的過程:
羊倌兒小異也要放100只羊,他觀察後發現,小同放羊的方法比較笨,他覺得草地一下能容下10只羊(頻寬)吃草,所以它就一次放出去10只羊等它們回來,然後他還可以給羊剪剪羊毛。有的羊吃得快回來的早,他就把羊關到羊圈接著就再放出去幾隻,儘量保證草地上都有10只羊在吃草。
很明顯,非同步放羊的效率高多了。同樣的,網路世界裡也是非同步的效率高。
到了這裡,可能有小猿要問,為什麼不用多執行緒、多程式實現爬蟲呢? 沒錯,多執行緒和多程式也可以提高前面那個同步爬蟲的抓取效率,但是非同步IO提高的更多,也更適合爬蟲這個場景。後面機會我們可以對比一下三者抓取的效率。
1. 非同步的downloader
還記得我們之前使用requests實現的那個downloader嗎?同步情況下,它很好用,但不適合非同步,所以我們要先改造它。幸運的是,已經有aiohttp模組來支援非同步http請求了,那麼我們就用aiohttp來實現非同步downloader。
async def fetch(session, url, headers=None, timeout=9):
_headers = {
'User-Agent': ('Mozilla/5.0 (compatible; MSIE 9.0; '
'Windows NT 6.1; Win64; x64; Trident/5.0)'),
}
if headers:
_headers = headers
try:
async with session.get(url, headers=_headers, timeout=timeout) as response:
status = response.status
html = await response.read()
encoding = response.get_encoding()
if encoding == 'gb2312':
encoding = 'gbk'
html = html.decode(encoding, errors='ignore')
redirected_url = str(response.url)
except Exception as e:
msg = 'Failed download: {} | exception: {}, {}'.format(url, str(type(e)), str(e))
print(msg)
html = ''
status = 0
redirected_url = url
return status, html, redirected_url
這個非同步的downloader,我們稱之為fetch(),它有兩個必須引數:
- seesion: 這是一個aiohttp.ClientSession的物件,這個物件的初始化在crawler裡面完成,每次呼叫fetch()時,作為引數傳遞。
- url:這是需要下載的網址。
實現中使用了非同步上下文管理器(async with),編碼的判斷我們還是用cchardet來實現。
有了非同步下載器,我們的非同步爬蟲就可以寫起來啦~
2. 非同步新聞爬蟲
跟同步爬蟲一樣,我們還是把整個爬蟲定義為一個類,它的主要成員有:
- self.urlpool 網址池
- self.loop 非同步的事件迴圈
- self.seesion aiohttp.ClientSession的物件,用於非同步下載
- self.db 基於aiomysql的非同步資料庫連線
self._workers
當前併發下載(放出去的羊)的數量
通過這幾個主要成員來達到非同步控制、非同步下載、非同步儲存(資料庫)的目的,其它成員作為輔助。爬蟲類的相關方法,參加下面的完整實現程式碼:
#!/usr/bin/env python3
# File: news-crawler-async.py
# Author: veelion
import traceback
import time
import asyncio
import aiohttp
import urllib.parse as urlparse
import farmhash
import lzma
import uvloop
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
import sanicdb
from urlpool import UrlPool
import functions as fn
import config
class NewsCrawlerAsync:
def __init__(self, name):
self._workers = 0
self._workers_max = 30
self.logger = fn.init_file_logger(name+ '.log')
self.urlpool = UrlPool(name)
self.loop = asyncio.get_event_loop()
self.session = aiohttp.ClientSession(loop=self.loop)
self.db = sanicdb.SanicDB(
config.db_host,
config.db_db,
config.db_user,
config.db_password,
loop=self.loop
)
async def load_hubs(self,):
sql = 'select url from crawler_hub'
data = await self.db.query(sql)
self.hub_hosts = set()
hubs = []
for d in data:
host = urlparse.urlparse(d['url']).netloc
self.hub_hosts.add(host)
hubs.append(d['url'])
self.urlpool.set_hubs(hubs, 300)
async def save_to_db(self, url, html):
urlhash = farmhash.hash64(url)
sql = 'select url from crawler_html where urlhash=%s'
d = await self.db.get(sql, urlhash)
if d:
if d['url'] != url:
msg = 'farmhash collision: %s <=> %s' % (url, d['url'])
self.logger.error(msg)
return True
if isinstance(html, str):
html = html.encode('utf8')
html_lzma = lzma.compress(html)
sql = ('insert into crawler_html(urlhash, url, html_lzma) '
'values(%s, %s, %s)')
good = False
try:
await self.db.execute(sql, urlhash, url, html_lzma)
good = True
except Exception as e:
if e.args[0] == 1062:
# Duplicate entry
good = True
pass
else:
traceback.print_exc()
raise e
return good
def filter_good(self, urls):
goodlinks = []
for url in urls:
host = urlparse.urlparse(url).netloc
if host in self.hub_hosts:
goodlinks.append(url)
return goodlinks
async def process(self, url, ishub):
status, html, redirected_url = await fn.fetch(self.session, url)
self.urlpool.set_status(url, status)
if redirected_url != url:
self.urlpool.set_status(redirected_url, status)
# 提取hub網頁中的連結, 新聞網頁中也有“相關新聞”的連結,按需提取
if status != 200:
return
if ishub:
newlinks = fn.extract_links_re(redirected_url, html)
goodlinks = self.filter_good(newlinks)
print("%s/%s, goodlinks/newlinks" % (len(goodlinks), len(newlinks)))
self.urlpool.addmany(goodlinks)
else:
await self.save_to_db(redirected_url, html)
self._workers -= 1
async def loop_crawl(self,):
await self.load_hubs()
last_rating_time = time.time()
counter = 0
while 1:
tasks = self.urlpool.pop(self._workers_max)
if not tasks:
print('no url to crawl, sleep')
await asyncio.sleep(3)
continue
for url, ishub in tasks.items():
self._workers += 1
counter += 1
print('crawl:', url)
asyncio.ensure_future(self.process(url, ishub))
gap = time.time() - last_rating_time
if gap > 5:
rate = counter / gap
print('\tloop_crawl() rate:%s, counter: %s, workers: %s' % (round(rate, 2), counter, self._workers))
last_rating_time = time.time()
counter = 0
if self._workers > self._workers_max:
print('====== got workers_max, sleep 3 sec to next worker =====')
await asyncio.sleep(3)
def run(self):
try:
self.loop.run_until_complete(self.loop_crawl())
except KeyboardInterrupt:
print('stopped by yourself!')
del self.urlpool
pass
if __name__ == '__main__':
nc = NewsCrawlerAsync('yrx-async')
nc.run()
爬蟲的主流程是在方法loop_crawl()裡面實現的。它的主體是一個while迴圈,每次從self.urlpool裡面獲取定量的爬蟲作為下載任務(從羊圈裡面選出一批羊),通過ensure_future()開始非同步下載(把這些羊都放出去)。而process()這個方法的流程是下載網頁並儲存、提取新的url,這就類似羊吃草、下崽等。
通過self._workers
和self._workers_max
來控制併發量。不能一直併發,給本地CPU、網路頻寬帶來壓力,同樣也會給目標伺服器帶來壓力。
至此,我們實現了同步和非同步兩個新聞爬蟲,分別實現了NewsCrawlerSync和NewsCrawlerAsync兩個爬蟲類,他們的結構幾乎完全一樣,只是抓取流程一個是順序的,一個是併發的。小猿們可以通過對比兩個類的實現,來更好的理解非同步的流程。
爬蟲知識點
1. uvloop模組
uvloop這個模組是用Cython編寫建立在libuv庫之上,它是asyncio內建事件迴圈的替代,使用它僅僅是多兩行程式碼而已:
import uvloop
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
uvloop使得asyncio很快,比odejs、gevent和其它Python非同步框架的快至少2倍,接近於Go語言的效能。
這是uvloop作者的效能對比測試。
目前,uvloop不支援Windows系統和Python 3.5 及其以上版本,這在它原始碼的setup.py檔案中可以看到:
if sys.platform in ('win32', 'cygwin', 'cli'):
raise RuntimeError('uvloop does not support Windows at the moment')
vi = sys.version_info
if vi < (3, 5):
raise RuntimeError('uvloop requires Python 3.5 or greater')
所以,使用Windows的小猿們要執行非同步爬蟲,就要把uvloop那兩行註釋掉哦。
思考題
1. 給同步的downloader()或非同步的fetch()新增功能
或許有些小猿還沒見過這樣的html程式碼,它出現在<head>
裡面:
<meta http-equiv="refresh" content="5; url=https://example.com/">
它的意思是,告訴瀏覽器在5秒之後跳轉到另外一個url:https://example.com/
。
那麼問題來了,請給downloader(fetch())新增程式碼,讓它支援這個跳轉。
2. 如何控制hub的重新整理頻率,及時發現最新新聞
這是我們寫新聞爬蟲要考慮的一個很重要的問題,我們實現的新聞爬蟲中並沒有實現這個機制,小猿們來思考一下,並對手實現實現。
到這老猿要講的實現一個非同步定向新聞爬蟲已經講完了,感謝你的閱讀,有任何建議和問題請再下方留言,我會一一回復你,你也可以關注 猿人學 公眾號,那裡可以及時看到我新發的文章。
後面的章節,是介紹如何使用工具,比如如何使用charles抓包,如何管理瀏覽器cookie,如何使用selenium等等,也歡迎你的閱讀。
我的公眾號:猿人學 Python 上會分享更多心得體會,敬請關注。
***版權申明:若沒有特殊說明,文章皆是猿人學 yuanrenxue.com 原創,沒有猿人學授權,請勿以任何形式轉載。***