引言
在上篇使用Scrapy爬取知乎用戶信息我們編寫了一個單機的爬蟲,這篇記錄了使用Scrapy-Redis
將其重寫,使其具備分布式抓取的能力,并使用Docker
部署到我們兩臺云server
上
為什么要分布式,顯然單機的爬蟲無論在機器的帶寬還是ip
等在爬取的時候都會有一定的限制,為了提高我們爬取的效率,我們需要編寫一個可以在多臺機器上同時運行的爬蟲,在其爬取狀態同步的同時,對我們想要的信息進行爬取。而Scrapy-Redis 是github
上的一個開源項目。
為什么使用Docker
,說到Docker
可能很多人老早就聽說過其在江湖上的傳說,在我寫這篇文章之前,也僅僅是對其有個模糊的了解,但是我昨天晚上在部署我的Project
到我的兩臺服務器上的時候,我被基礎環境的配置給弄瘋了,之前我在阿里云服務器上安裝了Py3
,勉強可以支撐我之前的需求。
但是昨晚在安裝Scrapy
的時候,為了裝一個Package
,我幾乎要把Py3、OpenSSL
等組件重新安裝一遍,在痛苦的掙扎之后,我想到了Docker
(也許是瞄到了官方的例子中的Dockerfile
),果不其然簡單的google
下就看到了大量的部署Scrapy
的案例。
簡單的看了下官方文檔,沒一會就將我的項目部署到了兩臺服務器上并正常運行,這太驚艷了!
原理
分布式爬蟲原理
Scrapy-Redis
其僅僅是一雙飛翔的翅膀,它自身并沒有爬蟲能力,它僅僅是為Scrapy
這個框架披上了一層鎧甲。所以閱讀本小節需要對Scrapy
的工作原理有一定的了解,如不感興趣可以跳過
試想如果我們自行編寫一個分布式爬蟲在多臺主機上運行,則我們需要將爬蟲的爬取隊列進行共享,也就是說每一臺主機都需要訪問到一個共享的隊列,然后我們的爬蟲就是從隊列中取一個request
進行爬取,當然這些Scrapy-Redis
都已經幫我們做好了,他可能需要做的是如下操作:
- 初始化爬蟲,創建一個
Redis
的客戶端,連接上Redis
- 查看請求隊列是否為空,如果是空則等待,當請求的隊列不為空,則從請求隊列中拿出一個
request
- 獲得
request
,經過scheduler
調度后,engine
會將request
取出,送給downloader
進行請求 - 經過請求后,返回給
Engine
,Engine
將結果返回給用戶寫的爬蟲,對結果進行處理,可能出現下一個request
,也可能是item
- 如果請求后得到的是一個
request
,則會通過scheduler
再次調度,判斷request
是否重復,并將request
放入請求隊列 - 如果經過得到了
item
,則Scrapy
會將item
交給我們的pipeline
處理
可見,scrapy_redis
就是將request
調度的隊列、請求的隊列和獲取的item
放在了一個多臺主機可以同時訪問的redis
的數據結構里
Scrapy-Redis
源碼分析
所謂請求分布式,就是使用redis
對request
抓取隊列和url
去重集合進行管理,從而達到爬蟲的狀態同步。
以下就包含了Scrapy-Redis
的源碼結構,也就只有這么幾個文件:
$ tree ScrapyRedisZhihu/scrapy_redis/
ScrapyRedisZhihu/scrapy_redis/
├── connection.py
├── defaults.py
├── dupefilter.py
├── __init__.py
├── picklecompat.py
├── pipelines.py
├── queue.py
├── scheduler.py
├── spiders.py
└── utils.py
0 directories, 10 files
初始化爬蟲,創建一個
Redis
的客戶端,連接上Redis
,查看SPIDER_NAME:start_urls
請求隊列是否為空,如果是空則等待,當請求的隊列不為空,則從請求隊列中拿出一個request
首先connection.py
和defaults.py
僅僅根據我們的setting.py
配置文件,為爬蟲提供一個reids client
的實例。
而對于不同的Spider
對上層都暴露出一個創建爬蟲的接口,上層這個接口就是crawler
的_create_spider
方法,調用這個方法干了一件事情:調用不同spider
的from_crawler
這個方法,無論我們是Spider
還是RedisSpider
。在不同的spider
中from_crawler
函數都會重寫,原生Scrapy
提供的from_crawler
會創建出不同類型的爬蟲并將其返回給Crawler
,而我們一般都不會去重寫這個method
。而這個from_crawler
會創建出不同類型的爬蟲如zhihu_spider、lajou_spider
等并將其返回給上層。
由于我們的爬蟲是繼承了RedisSpider
這個類,RedisSpider
繼承了Scrapy
原生的Spider
類,RedisSpider
主要重寫了Spider
的from_crawler
類方法,Spider
的from_crawler
類方法起到了一個Constructor
的作用,解耦了spider
實例化的具體實現和上層調用。
class RedisSpider(RedisMixin, Spider):
@classmethod
def from_crawler(self, crawler, *args, **kwargs):
obj = super(RedisSpider, self).from_crawler(crawler, *args, **kwargs)
obj.setup_redis(crawler) # RedisMixin中的setup_redis
return obj
并注意,RedisSpider
它采用了Python
的多繼承Mixin
設計,他不僅繼承了Spider
還繼承了RedisMixin
,當我們RedisSpider
初始化,它和原生Scrapy
不同的是:調用setup_redis
來初始化和redis
的相關設置
class RedisMixin(object):
def setup_redis(self, crawler=None):
"""Setup redis connection and idle signal."""
self.server = connection.from_settings(crawler.settings)
# The idle signal is called when the spider has no requests left,
crawler.signals.connect(self.spider_idle, signal=signals.spider_idle)
setup_redis
也只是做了兩件事,根據我們的setting.py
配置文件,初始化一個redis
連接;和為爬蟲設置一個信號,當spider
空閑,會調用spider_idle
這個方法,spider_idle
則會將調用schedule_next_requests
,而schedule_next_requests
也只是調用next_requests
如下:
class RedisMixin(object):
def spider_idle(self):
"""Schedules a request if available, otherwise waits."""
self.schedule_next_requests()
raise DontCloseSpider
def schedule_next_requests(self):
"""Schedules a request if available"""
for req in self.next_requests():
self.crawler.engine.crawl(req, spider=self)
def next_requests(self):
"""Returns a request to be scheduled or none."""
use_set = self.settings.getbool('REDIS_START_URLS_AS_SET', defaults.START_URLS_AS_SET)
fetch_one = self.server.spop if use_set else self.server.lpop
found = 0
while found < self.redis_batch_size:
data = fetch_one(self.redis_key)
if not data:
# Queue empty.
break
req = self.make_request_from_data(data)
if req:
yield req
found += 1
def make_request_from_data(self, data):
url = bytes_to_str(data, self.redis_encoding)
return self.make_requests_from_url(url)
由上我們可以知道當spider
空閑的時候,會嘗試在redis_key
這個數據結構中嘗試獲得一個url
,這個數據結構可以是默認的list
也可以設置為set
,設置為set
則需要用REDIS_START_URLS_AS_SET
在setting
中進行設置。
如果獲得一個url
就進行一些decode
處理,并進行請求,這里make_requests_from_url
默認就是用的標準Spider
類中的make_requests_from_url
方法,其僅僅是return Request(url, dont_filter=True)
在如果沒有url
就會拋出DontCloseSpider
這個異常,在上層engine._spider_idle
早已將其捕獲,并會再次重新調用spider_idle
。這樣就實現了一直嘗試獲得一個的request
,沒有則并進行等待并重新調度。
需要注意的是,在第一次調用spider_idle
之前,爬蟲會調用start_requests
這個方法,這是由上層crawler
中的crawl
方法所決定的,這個方法也是Scrapy
入口
@defer.inlineCallbacks
def crawl(self, *args, **kwargs):
assert not self.crawling, "Crawling already taking place"
self.spider = self._create_spider(*args, **kwargs)
self.engine = self._create_engine()
start_requests = iter(self.spider.start_requests())
yield self.engine.open_spider(self.spider, start_requests)
yield defer.maybeDeferred(self.engine.start)
在我們創建好spider
和engine
,就會調用start_requests
,在原生的Spider
類中這個函數會迭代start_urls
這個列表,也就是我們熟悉的爬蟲的入口url
,而在RedisMixin
中將其重寫,在redis
的數據結構中獲得爬蟲的起始頁面。
def start_requests(self):
return self.next_requests()
所以,我們在使用RedisSpider
類進行分布式爬蟲編寫的時候,就不需要定義start_urls
這個列表,相反需要增加一個redis_key = 'zhihu_redis:start_urls'
,并且我們在運行爬蟲的時候,需要手動在redis
的數據結構內push/add
一個邏輯上的start_url
,使其被next_requests
方法獲得
在start_requests
方法被調用,將回到crawl
調用engine.open_spider
方法,這個方法會初始化scheduler
并調用爬蟲中間件spidermw.process_start_requests(start_requests, spider)
,之后會調用scheduler.open
這個方法,這個方法會初始化我們的優先級隊列和過濾器。并注冊engine._next_request
到reactor loop
里面,當nextcall.schedule()
調用,engine._next_request
將會被調用
def _next_request(self, spider):
while not self._needs_backout(spider):
if not self._next_request_from_scheduler(spider):
break #第一次執行,scheduler中沒有request,會直接break
if slot.start_requests and not self._needs_backout(spider):
try:
request = next(slot.start_requests)
except StopIteration:
slot.start_requests = None
except Exception:
slot.start_requests = None
logger.error('Error while obtaining start requests',
exc_info=True, extra={'spider': spider})
else:
self.crawl(request, spider)
if self.spider_is_idle(spider) and slot.close_if_idle:
self._spider_idle(spider)
#對 RedisSpider 中的DontCloseSpider進行捕獲,捕獲到直接返回,回到`reactor loop`
由于第一次執行程序,redis
數據結構中(SPIDER_NAME:start_urls)沒有對應的url
,在這個注冊到reactor loop
內的方法,調用了_spider_idle
對RedisSpider
中的DontCloseSpider
進行捕獲。并直接返回到reactor loop
,直到從redis
中獲得url
,調用crawl
def crawl(self, request, spider):
assert spider in self.open_spiders, \
"Spider %r not opened when crawling: %s" % (spider.name, request)
self.schedule(request, spider)
self.slot.nextcall.schedule()
def schedule(self, request, spider):
self.signals.send_catch_log(signal=signals.request_scheduled,
request=request, spider=spider)
if not self.slot.scheduler.enqueue_request(request):
self.signals.send_catch_log(signal=signals.request_dropped,
request=request, spider=spider)
可以看到,在engine.cawl
中調用了engine.schedule
,這里終于調用了scheduler.enqueue_request
,也就是scrapy_redis
中的scheduler.py
模塊中的enqueue_request
經過
scheduler
調用dupfilter
的判重,會將request
送到優先級隊列當中,送給downloader
進行請求
scheduler
在將request
放入SPIDERNAME:requests
這個隊列時,關于requests
去重,Scrapy-Redis
還是使用原來的做法,提取指紋(hashlib.sha1
),放入一個集合,但是Scrapy-Redis
使用了Redis
的集合,這樣多臺主機可以共享一個用于去重的集合
def enqueue_request(self, request):
if not request.dont_filter and self.df.request_seen(request):
return False
self.queue.push(request)
return True
# dupfilter.py
def request_seen(self, request):
fp = self.request_fingerprint(request)
added = self.server.sadd(self.key, fp)
return added == 0
def next_request(self):
block_pop_timeout = self.idle_before_close
request = self.queue.pop(block_pop_timeout)
if request and self.stats:
self.stats.inc_value('scheduler/dequeued/redis', spider=self.spider)
return request
優先級調度隊列scrapy_redis:requests
是Queue.py
中被定義的模塊,對原生的隊列進行了重寫,使用Redis
的list
和zset
分別實現了三種隊列,LifoQueue
、FifoQueue
、PriorityQueue
。默認使用的是PriorityQueue
。
在request
進入優先級隊列之后,會調用nextcall.schedule
,也就是會調用engine._next_request
,當再次調用engine._next_request_from_scheduler
,再調用scheduler.next_request
就會將優先級隊列中的的url
取出。并進行下載了
def _next_request_from_scheduler(self, spider):
slot = self.slot
request = slot.scheduler.next_request()
if not request:
return
d = self._download(request, spider)
d.addBoth(self._handle_downloader_output, request, spider) #注冊 _downloader 回調
d.addErrback(lambda f: logger.info('Error while handling downloader output',
exc_info=failure_to_exc_info(f),
extra={'spider': spider}))
d.addBoth(lambda _: slot.remove_request(request))
d.addErrback(lambda f: logger.info('Error while removing request from slot',
exc_info=failure_to_exc_info(f),
extra={'spider': spider}))
d.addBoth(lambda _: slot.nextcall.schedule())
d.addErrback(lambda f: logger.info('Error while scheduling new request',
exc_info=failure_to_exc_info(f),
extra={'spider': spider}))
return d
經過請求后,
response
返回給Engine
,Engine
對結果進行處理,可能出現下一個request
,也可能解析出我們想要的數據,也就是item
,也有可能是失敗,如果請求后得到的是一個request
,則會通過scheduler
判斷request
是否重復,并將request
放入請求隊列
當downloader
返回的response
交給我們的engine
,engine
會判斷response
的類型
def _handle_downloader_output(self, response, request, spider):
assert isinstance(response, (Request, Response, Failure)), response
# downloader middleware can return requests (for example, redirects)
if isinstance(response, Request):
self.crawl(response, spider)
return
# response is a Response or Failure
d = self.scraper.enqueue_scrape(response, request, spider)
d.addErrback(lambda f: logger.error('Error while enqueuing downloader output',
exc_info=failure_to_exc_info(f),
extra={'spider': spider}))
return d
如果是一個Request
,如可能出現重定向等情況,則會再次調用crawl
進入scheduler
隊列。如果是response
或是錯誤則會調用scraper.enqueue_scrape(response, request, spider)
。在這個方法內會經過一系列的調用、注冊回調,會判斷如果不是錯誤則會調用call_spider
這個方法
def call_spider(self, result, request, spider):
result.request = request
dfd = defer_result(result)
dfd.addCallbacks(request.callback or spider.parse, request.errback)
return dfd.addCallback(iterate_spider_output)
可以看到在這個方法內,addCallbacks
注冊回調方法,取得request.callback
,如果未定義則調用Spider
的parse
方法,也就是我們自己爬蟲zhihu_spider
的邏輯,在我們的邏輯處理后會出現兩種,一種就是item
,一種就是request
,并將其yield
,此時會調用在_scrape
中注冊的回調函數handle_spider_output
對spider
返回的數據進行處理。在這個方法內,有調用了_process_spidermw_output
對我們的yield
出去的數據進行處理
def _process_spidermw_output(self, output, request, response, spider):
"""Process each Request/Item (given in the output parameter) returned
from the given spider
"""
if isinstance(output, Request):
self.crawler.engine.crawl(request=output, spider=spider)
elif isinstance(output, (BaseItem, dict)):
self.slot.itemproc_size += 1
dfd = self.itemproc.process_item(output, spider)
dfd.addBoth(self._itemproc_finished, output, response, spider)
return dfd
elif output is None:
pass
else:
typename = type(output).__name__
logger.error('Spider must return Request, BaseItem, dict or None, '
'got %(typename)r in %(request)s',
{'request': request, 'typename': typename},
extra={'spider': spider})
此時可以看到,在_process_spidermw_output
對output
進行了類型判斷,如果是Request
,則調用engine.crawl
,進入scheduler
的處理檢驗指紋并加入優先級隊列等等,如果是BaseItem
則會調用pipeline
的process_item
方法
經過spider邏輯處理后,會有兩種結果,可能出現下一個
request
,也可能解析出我們想要的數據,也就是item
Scrapy-redis
的pipeline
class RedisPipeline(object):
def __init__(self, server,
key=defaults.PIPELINE_KEY,
serialize_func=default_serialize):
self.server = server #Redis client instance.
self.key = key # redis key ,value stored serialized key
self.serialize = serialize_func #Items serializer function
@classmethod
def from_crawler(cls, crawler):
return cls.from_settings(crawler.settings)
#init pipeline from setting
def process_item(self, item, spider):
return deferToThread(self._process_item, item, spider)
# twisted 異步線程的接口,將item的處理變為異步的,防止源源不斷的item處理阻塞
def _process_item(self, item, spider):
key = self.item_key(item, spider)
data = self.serialize(item)
self.server.rpush(key, data)
return item
def item_key(self, item, spider):
return self.key % {'spider': spider.name}
遵循Scrapy pipeline
的寫法,在_process_spidermw_output
被調用,只是將Scrapy
處理得到的item
經過序列化之后放入Redis
的列表。
這樣造成的結果就是爬取到的數據都放入到了Redis
,如果我們想和之前標準的scrapy
一樣將數據放入其他的數據庫或做一些處理,我們需要重新編寫一個處理item
的一個文件
提醒
雖然scrapy-redis
源碼組成簡單,但是由于scrapy-redis
對Scrapy
的源碼依賴過多,需要更加細致的解讀只能讀者自行閱讀Scrapy
的源碼。關于Scrapy-Redis
的具體使用可以查閱源碼和官方倉庫中的setting.py
文件中的注釋。
實現
修改setting.py
文件
SCHEDULER = "scrapy_redis.scheduler.Scheduler"
# Ensure all spiders share same duplicates filter through redis.
DUPEFILTER_CLASS = "scrapy_redis.dupefilter.RFPDupeFilter"
ITEM_PIPELINES = {
# 'ScrapyRedisZhihu.pipelines.MysqlTwsitedPipeline': 300,
'scrapy_redis.pipelines.RedisPipeline': 301
}
DOWNLOADER_MIDDLEWARES = {
'scrapy.contrib.downloadermiddleware.useragent.UserAgentMiddleware' : None,
'ScrapyRedisZhihu.middlewares.ProxyMiddleware' :400
}
DOWNLOAD_DELAY = 0.4
MYSQL_HOST='x.x.x.x'
MYSQL_NAME='crawler'
MYSQL_USER='root'
MYSQL_PASS='xxxxxx'
REDIS_HOST='x.x.x.x'
REDIS_PORT=9999
注意
我們將SCHEDULER
、DUPEFILTER_CLASS
、ITEM_PIPELINES
都設置為Scrapy-Redis
提供的。其中SCHEDULER
被重新實現,使用Redis
的list
和zset
分別實現了三種隊列,LifoQueue
、FifoQueue
、PriorityQueue
。
dupefilter
使用了redis
的set
ITEM_PIPELINES
也將item
序列化后放入了redis
的list
。它不是必須的,但是我想嘗試下Redis
的威力,所以使用了這個pipeline
,如果使用RedisPipeline
的話,我們需要重新編寫一個文件來處理序列化(默認為json
)到Redis
里面的Item
,通常命名為process_item.py
修改zhihu_spider.py
文件
import scrapy
import json
from ScrapyRedisZhihu.items import ZhihuUserItem,ZhihuUserItemLoader
from datetime import datetime
from scrapy_redis.spiders import RedisSpider
following_api = "https://www.zhihu.com/api/v4/members/{}/followees?include=data[*].gender%2Cvoteup_count%2Cthanked_Count%2Cfollower_count%2Cfollowing_count%2Canswer_count%2Carticles_count%2Cfavorite_count%2Cfavorited_count%2Cthanked_count%2Cbadge[%3F(type%3Dbest_answerer)].topics&offset=0&limit=20"
class ZhihuSpider(RedisSpider):
name = 'zhihu_redis'
allowed_domains = ["www.zhihu.com"]
redis_key = 'zhihu_redis:start_urls'
def parse(self, response):
json_response = json.loads(response.body_as_unicode())
if not json_response['paging']['is_end']:
yield scrapy.Request(url=json_response['paging']['next'])
if json_response['data']:
for data in json_response['data']:
url_token = data.get('url_token')
if url_token:
yield scrapy.Request(url=following_api.format(url_token))
agreed_count = data['voteup_count']
thxd_count = data['thanked_count']
collected_count = data['favorited_count']
if thxd_count or collected_count:
item_loader = ZhihuUserItemLoader(item=ZhihuUserItem(), response=response)
item_loader.add_value('name',data['name'])
item_loader.add_value('id',data['id'])
item_loader.add_value('url_token',data['url_token'])
item_loader.add_value('headline',data['headline']
if data['headline'] else "無")
item_loader.add_value('answer_count',data['answer_count'])
item_loader.add_value('articles_count',data['articles_count'])
item_loader.add_value('gender',data['gender']
if data['gender'] else 0)
item_loader.add_value('avatar_url',data['avatar_url_template'].format(size='xl'))
item_loader.add_value('user_type',data['user_type'])
item_loader.add_value('badge',','.join([badge.get('description') for badge in data['badge']])
if data.get('badge') else "無")
item_loader.add_value('follower_count',data['follower_count'])
item_loader.add_value('following_count',data['following_count'])
item_loader.add_value('agreed_count',agreed_count)
item_loader.add_value('thxd_count',thxd_count)
item_loader.add_value('collected_count',collected_count)
# item_loader.add_value('craw_time',datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
zhihu_item = item_loader.load_item()
yield zhihu_item
即可
使用Docker部署
關于
Docker
的安裝和基本使用這里就不再贅述,后期應該會針對寫一篇關于容器的文章。
搭建私有倉庫
為了將我們的容器上傳到云服務器上而不是一個服務器一個服務器的打包,我們需要搭建一個私有的docker
倉庫,首先我們需要運行下面命令獲取registry
鏡像
sudo docker pull registry:2.1.1
然后啟動一個容器
sudo docker run -d -v /opt/registry:/var/lib/registry -p 5000:5000 --restart=always --name registry registry:2.1.1
Registry
服務默認會將上傳的鏡像保存在容器的/var/lib/registry
,我們將主機的/opt/registry
目錄掛載到該目錄,即可實現將鏡像保存
運行sudo docker ps
保證registry
鏡像啟動的容器正在運行即可
打包部署
在項目根目錄生成依賴文件:pip freeze > requirements.txt
編寫Dockerfile
文件,和main.py
在同一個目錄下。
FROM hub.c.163.com/sportscool/python3
MAINTAINER zhxfei <dylan@zhxfei.com>
ENV PATH /usr/bin:$PATH
ADD . /code
WORKDIR /code
RUN pip install -r requirements.txt -i https://pypi.douban.com/simple
CMD scrapy crawl zhihu_redis
生成鏡像: sudo docker build -t IP:PORT/NAME .
這個IP:PORT
應為私有倉庫的registry
服務監聽的地址和端口,啟動-t
就已經制定了鏡像對應的倉庫
push
鏡像到私有倉庫: sudo docker push IP:PORT/NAME
使用curl
來確認是否上傳:
zhxfei@HP-ENVY:~/just4fun$ curl 120.x.x.x:5000/v2/_catalog
{"repositories":["scrapy_redis"]}
有之前生成的NAME
即可,如此處應為scrapy_redis
之后在每臺云服務器上運行sudo docker pull IP:PORT/NAME
將鏡像下載到本地,再啟動一個容器即可,由于打包鏡像時指定了CMD
,運行鏡像即會運行python main.py
,爬蟲就已經啟動了
當然,也可以使用一些自動化運維的工具,在本機上遠程批量執行命令,如Ansible
,使用
sudo ansible ALL_HOSTS -m shell -a "docker pull IP:PORT/NAME && docker run -d -P IP:PORT/NAME" --sudo