我給 Scrapy Redis 開源庫發的 PR 被合併了

這是「進擊的 Coder」的第 366 篇技術分享

作者:崔慶才

來源:崔慶才丨靜覓

不知道大家基於 Scrapy-Redis 開發分佈式爬蟲的時候有沒有遇到一個比較尷尬的問題,且聽我一一道來。

大家在運行 Scrapy 的的時候肯定見過類似這樣輸出吧:

12021-03-15 21:52:06 [scrapy.extensions.logstats] INFO: Crawled 33 pages (at 33 pages/min), scraped 172 items (at 172 items/min)
2...
3
4

這裏就是一些統計輸出結果對不對,它告訴我們當前這個爬蟲的爬取速度和總的爬取情況。

另外在爬取完成之後最後輸出的的統計信息是這樣的:

 1{'downloader/request_bytes': 2925,
 2 'downloader/request_count': 11,
 3 'downloader/request_method_count/GET': 11,
 4 'downloader/response_bytes': 23406,
 5 'downloader/response_count': 11,
 6 'downloader/response_status_count/200': 10,
 7 'downloader/response_status_count/404': 1,
 8 'elapsed_time_seconds': 3.917599,
 9 'finish_reason': 'finished',
10 'finish_time': datetime.datetime(2021, 3, 15, 14, 1, 36, 275427),
11 'item_scraped_count': 100,
12 'log_count/DEBUG': 111,
13 'log_count/INFO': 10,
14 'memusage/max': 55242752,
15 'memusage/startup': 55242752,
16 'request_depth_max': 9,
17 'response_received_count': 11,
18 'robotstxt/request_count': 1,
19 'robotstxt/response_count': 1,
20 'robotstxt/response_status_count/404': 1,
21 'scheduler/dequeued': 10,
22 'scheduler/dequeued/memory': 10,
23 'scheduler/enqueued': 10,
24 'scheduler/enqueued/memory': 10,
25 'start_time': datetime.datetime(2021, 3, 15, 14, 1, 32, 357828)}
262021-03-15 22:01:36 [scrapy.core.engine] INFO: Spider closed (finished)
27
28

然而這個信息,當我們使用基於 Scrapy-Redis 來實現的時候,你會發現每個爬蟲都在做自己的統計,比如其中一個 Spider 機器性能和網絡比較好,爬取速度快,那麼它的統計結果就更高,表現不太好的 Spider 它的統計結果就差一些。這些 Spider 的統計信息都是獨立的互不影響的,數據也各不相同。

這是個麻煩事啊,統計信息不同步而且很分散,我想知道總共爬取了多少條數據也不知道,那怎麼辦呢?另外我還想對這些統計數據做數據分析和報表,根本不知道咋合併統計。

所以,我在想,如果這個統計信息也能基於 Redis 實現多爬蟲同步不就好了嗎?

實現

統計信息首先應該怎麼寫呢,先查下官方文檔,找到這個:https://docs.scrapy.org/en/latest/topics/stats.html

這裏介紹了一個 Stats Collection,官方介紹如下:

Scrapy provides a convenient facility for collecting stats in the form of key/values, where values are often counters. The facility is called the Stats Collector, and can be accessed through the stats attribute of the Crawler API, as illustrated by the examples in the Common Stats Collector uses section below.

However, the Stats Collector is always available, so you can always import it in your module and use its API (to increment or set new stat keys), regardless of whether the stats collection is enabled or not. If it’s disabled, the API will still work but it won’t collect anything. This is aimed at simplifying the stats collector usage: you should spend no more than one line of code for collecting stats in your spider, Scrapy extension, or whatever code you’re using the Stats Collector from.

Another feature of the Stats Collector is that it’s very efficient (when enabled) and extremely efficient (almost unnoticeable) when disabled.

The Stats Collector keeps a stats table per open spider which is automatically opened when the spider is opened, and closed when the spider is closed.

OK,沒問題,這個就是一個信息收集器,然後我們可以根據它的一些接口來實現自己的信息收集器。

比如設置統計值:

1stats.set_value('hostname', socket.gethostname())
2
3

比如增加統計值:

1stats.inc_value('custom_count')
2
3

另外扒了一下源碼,看到了默認的收集器就是 MemoryStatsCollector,就是基於內存的,源碼見:https://docs.scrapy.org/en/latest/_modules/scrapy/statscollectors.html#MemoryStatsCollector。因爲是基於內存的,所以每個爬蟲一定是獨立的,所以我只需要把它們的共享隊列改成 Redis 就能實現分佈式信息收集的同步了。

另外看來這些統計信息,基本上就是表示爲 key-value 信息,存到 Redis 最合適的當然是 Hash 了。

OK,說幹就幹,改寫了下 Memory,把存儲換成 Redis,其他的實現基本差不多,實現了一個 RedisStatsCollector 如下:

 1from scrapy.statscollectors import StatsCollector
 2from .connection import from_settings as redis_from_settings
 3from .defaults import STATS_KEY, SCHEDULER_PERSIST
 4
 5
 6class RedisStatsCollector(StatsCollector):
 7    """
 8    Stats Collector based on Redis
 9    """
10
11    def __init__(self, crawler, spider=None):
12        super().__init__(crawler)
13        self.server = redis_from_settings(crawler.settings)
14        self.spider = spider
15        self.spider_name = spider.name if spider else crawler.spidercls.name
16        self.stats_key = crawler.settings.get('STATS_KEY', STATS_KEY)
17        self.persist = crawler.settings.get(
18            'SCHEDULER_PERSIST', SCHEDULER_PERSIST)
19
20    def _get_key(self, spider=None):
21        """Return the hash name of stats"""
22        if spider:
23            self.stats_key % {'spider': spider.name}
24        if self.spider:
25            return self.stats_key % {'spider': self.spider.name}
26        return self.stats_key % {'spider': self.spider_name or 'scrapy'}
27
28    @classmethod
29    def from_crawler(cls, crawler):
30        return cls(crawler)
31
32    def get_value(self, key, default=None, spider=None):
33        """Return the value of hash stats"""
34        if self.server.hexists(self._get_key(spider), key):
35            return int(self.server.hget(self._get_key(spider), key))
36        else:
37            return default
38
39    def get_stats(self, spider=None):
40        """Return the all of the values of hash stats"""
41        return self.server.hgetall(self._get_key(spider))
42
43    def set_value(self, key, value, spider=None):
44        """Set the value according to hash key of stats"""
45        self.server.hset(self._get_key(spider), key, value)
46
47    def set_stats(self, stats, spider=None):
48        """Set all the hash stats"""
49        self.server.hmset(self._get_key(spider), stats)
50
51    def inc_value(self, key, count=1, start=0, spider=None):
52        """Set increment of value according to key"""
53        if not self.server.hexists(self._get_key(spider), key):
54            self.set_value(key, start)
55        self.server.hincrby(self._get_key(spider), key, count)
56
57    def max_value(self, key, value, spider=None):
58        """Set max value between current and new value"""
59        self.set_value(key, max(self.get_value(key, value), value))
60
61    def min_value(self, key, value, spider=None):
62        """Set min value between current and new value"""
63        self.set_value(key, min(self.get_value(key, value), value))
64
65    def clear_stats(self, spider=None):
66        """Clarn all the hash stats"""
67        self.server.delete(self._get_key(spider))
68
69    def open_spider(self, spider):
70        """Set spider to self"""
71        if spider:
72            self.spider = spider
73
74    def close_spider(self, spider, reason):
75        """Clear spider and clear stats"""
76        self.spider = None
77        if not self.persist:
78            self.clear_stats(spider)
79
80

OK,我把這個代碼放到 Scrapy-Redis 的源碼裏面,新建了一個 stats.py 的文件夾,然後本地重新安裝下 Scrapy-Redis 這個包:

切換到 Scrapy-Redis 源碼目錄,執行安裝命令如下:

1pip3 install .
2
3

輸出結果類似如下:

1...
2Installing collected packages: scrapy-redis
3  Attempting uninstall: scrapy-redis
4    Found existing installation: scrapy-redis 0.7.0.dev0
5    Uninstalling scrapy-redis-0.7.0.dev0:
6      Successfully uninstalled scrapy-redis-0.7.0.dev0
7Successfully installed scrapy-redis-0.7.0.dev0
8
9

這樣本地就裝好最新版的 Scrapy-Redis 了。

然後本地測試下,切到 example-project/example 目錄下,添加了一行代碼:

1STATS_CLASS = "scrapy_redis.stats.RedisStatsCollector"
2
3

意思就是信息收集器這個類使用我剛纔創建的 RedisStatsCollector,然後運行:

1scrapy crawl dmoz
2
3

運行起來了,然後我再開另外的命令行運行同樣的命令,啓動多個爬蟲。

這時候我打開 Redis Desktop Manager,就看到了如下的四個鍵名:

看到了吧,這裏多了一個 dmoz:stats,這個就是統計信息,打開之後內容如下:

可以看到所有的統計數據就被存到 Redis 了,而且每個 Spider 都會讀取和寫入,實現了多個 Spider 統計信息的同步。

發 PR

這個 Feature 我後來就給 Scrapy-Redis 的作者發了 PR,https://github.com/rmax/scrapy-redis/pull/186,幸運的是,今天發現已經被 Approve 並 merge 了:

作者 rmax 還說了聲 Nice Feature:

激動!開心!

另外我還和作者聯繫了下,瞭解到他現在正在尋找 Scrapy-Redis 這個項目的 maintainer,然後我就跟他說我樂意幫忙維護這個項目,他給我加了一些權限。

後續 Scrapy-Redis 的維護我應該也會參與進來了。比如剛剛我發的 Feature,後續會發新版本的 Scrapy-Redis 的 Release。

這裏不得不說一句,Scrapy-Redis 距離上次發新版本已經三年多了,新的改動都在 master,一直沒有 release,我給作者提了 Issue 反饋了這個問題不過也一直沒有發新版,後續應該我會幫忙發佈一個新的 Release,把最新的 Feature 和 Bug Fix 都上了。

如果大家想體驗剛纔介紹的最新的 Feature 的話,可以直接安裝 master 版本,命令如下:

1pip3 install git+https://github.com/rmax/scrapy-redis.git
2
3

如有問題希望大家及時反饋和提 Issue,感謝支持!

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/vWItUkw-IGHEbEqiR9fRTw