Scrapy 永不停止的爬虫
Posted by clannadzsy ‐ Updated ‐ 3 min read
本文讨论通过 spider_idle 信号保留爬虫、等待下一批任务的方法。下面将演示限制为有限次数,便于本地验证;长时间运行时仍应设置空闲超时、请求速率和外部停止机制。
适用环境
本次示例在 Python 3.12.14、Scrapy 2.11.2、scrapy-redis 0.9.1 下验证,并固定 w3lib 2.2.1、Twisted 24.3.0,避免旧版 Scrapy 与新依赖的接口差异。Redis 队列测试另用 fakeredis 2.32.1,全部使用本地 data: 响应或模拟队列,不抓取外部网站。可在独立虚拟环境安装:
python -m pip install "Scrapy==2.11.2" "scrapy-redis==0.9.1" "w3lib==2.2.1" "Twisted==24.3.0" "fakeredis==2.32.1"
Scrapy 的 spider_idle 信号不支持异步处理器,不能在其中使用 time.sleep(60) 阻塞事件循环。抛出 DontCloseSpider 后,处理器应立即返回;下一次空闲检查会再次触发该信号。通过 CloseSpider 可以提供关闭原因。
在 Scrapy.Spider 中追加请求
保存为 idle_spider.py。这里使用内置的 data: 下载处理器生成本地响应,总共处理 3 次后正常结束。dont_filter=True 允许示例重复调度同一个 URL。
from scrapy import Request, Spider, signals
from scrapy.crawler import CrawlerProcess
from scrapy.exceptions import DontCloseSpider
class TestSpider(Spider):
name = 'test'
start_urls = ['data:text/plain,local-fixture']
max_requests = 3
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.sent = 1 # start_urls 提供第一次请求
def parse(self, response, **kwargs):
self.logger.info('local response: %s', response.text)
def next_request(self):
self.sent += 1
request = Request(
self.start_urls[0], callback=self.parse, dont_filter=True,
)
# Scrapy 2.6+ 不再传入 spider=self。
self.crawler.engine.crawl(request)
def spider_idle(self):
if self.sent >= self.max_requests:
return # 不再抛出 DontCloseSpider,允许正常关闭
self.next_request()
raise DontCloseSpider
@classmethod
def from_crawler(cls, crawler, *args, **kwargs):
spider = super().from_crawler(crawler, *args, **kwargs)
crawler.signals.connect(spider.spider_idle, signal=signals.spider_idle)
return spider
if __name__ == '__main__':
process = CrawlerProcess({
'LOG_LEVEL': 'INFO',
'TELNETCONSOLE_ENABLED': False,
'REQUEST_FINGERPRINTER_IMPLEMENTATION': '2.7',
})
crawler = process.create_crawler(TestSpider)
process.crawl(crawler)
process.start()
stats = crawler.stats.get_stats()
assert stats['downloader/response_count'] == 3
assert stats['finish_reason'] == 'finished'
print('local responses: 3; finish reason: finished')
运行 python idle_spider.py。最后的断言检查实际处理次数及关闭原因;日志中的响应来自本地固定文本,不能据此判断任何网站的抓取结果。
在 RedisSpider 中等待新任务
RedisSpider.start_requests() 从 Redis 队列读取任务,不能依赖普通 Spider 的 start_urls 自动产生初始请求。在 scrapy-redis 0.9.1 中,应覆盖 make_request_from_data,使用队列里取出的数据生成请求,而不是忽略参数、始终请求 self.start_urls[0]。
保存为 redis_wait_spider.py。示例约定队列元素为 {"url": "data:text/plain,first"} 这样的 JSON 字符串。它只演示从 Redis 获取入口任务,使用 Scrapy 默认调度器;多进程共享调度队列、持久化与去重需另行配置。
import json
import os
from time import monotonic
from scrapy import Request
from scrapy.exceptions import CloseSpider, DontCloseSpider
from scrapy_redis.spiders import RedisSpider
class RedisWaitSpider(RedisSpider):
name = 'redis_wait'
redis_key = f'{name}:start_urls'
poll_interval = 60
max_idle_seconds = 60 * 60
custom_settings = {
'REDIS_URL': os.environ.get('REDIS_URL', 'redis://127.0.0.1:6379/0'),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._clock = monotonic
self._empty_since = None
self._next_poll = 0.0
def make_request_from_data(self, data):
payload = json.loads(data.decode(self.redis_encoding))
return Request(payload['url'], callback=self.parse, dont_filter=True)
def parse(self, response, **kwargs):
self.logger.info('processed queued URL: %s', response.url)
def spider_idle(self):
now = self._clock()
if now >= self._next_poll:
requests = list(self.next_requests())
for request in requests:
self.crawler.engine.crawl(request)
self._next_poll = now + self.poll_interval
if requests:
self._empty_since = None
elif self._empty_since is None:
self._empty_since = now
if (self._empty_since is not None
and now - self._empty_since >= self.max_idle_seconds):
raise CloseSpider('redis_idle_timeout')
raise DontCloseSpider
RedisSpider.from_crawler() 已连接此实例的 spider_idle,无需重复连接。未到下一次轮询时间时,处理器立即返回;达到轮询时间才读取队列。实际时机还取决于 Scrapy 的空闲检查,因此不是精确的每分钟定时器。这里的超时从首次观察到空队列开始计算,取到新任务后重置。若没有空闲超时,空队列可能使进程一直保留;time.sleep() 则会阻塞其他下载和回调。
不启动 Redis 服务的有限验证
把下面代码保存为同目录下的 verify_redis_wait.py。它使用 fakeredis、模拟的引擎和可推进的时钟,不启动下载器,也不实际等待一小时。
import json
from types import SimpleNamespace
from unittest.mock import patch
import fakeredis
from scrapy.crawler import Crawler
from scrapy.exceptions import CloseSpider, DontCloseSpider
from scrapy.settings import Settings
from redis_wait_spider import RedisWaitSpider
server = fakeredis.FakeStrictRedis()
crawler = Crawler(RedisWaitSpider, Settings())
scheduled = []
crawler.engine = SimpleNamespace(crawl=scheduled.append)
with patch('scrapy_redis.connection.from_settings', return_value=server):
spider = RedisWaitSpider.from_crawler(crawler)
clock = [0.0]
spider._clock = lambda: clock[0]
server.rpush(spider.redis_key, json.dumps({'url': 'data:text/plain,initial'}))
initial = list(spider.start_requests())
assert [request.url for request in initial] == ['data:text/plain,initial']
def expect_wait():
try:
spider.spider_idle()
except DontCloseSpider:
return
raise AssertionError('expected DontCloseSpider')
server.rpush(spider.redis_key, json.dumps({'url': 'data:text/plain,first'}))
expect_wait()
assert [request.url for request in scheduled] == ['data:text/plain,first']
clock[0] = 1.0
server.rpush(spider.redis_key, json.dumps({'url': 'data:text/plain,second'}))
expect_wait()
assert server.llen(spider.redis_key) == 1 # 尚未达到下一轮询时间
clock[0] = 60.0
expect_wait()
assert scheduled[-1].url == 'data:text/plain,second'
assert len(scheduled) == 2
clock[0] = 120.0
expect_wait() # 首次观察到空队列
clock[0] = 180.0
server.rpush(spider.redis_key, json.dumps({'url': 'data:text/plain,third'}))
expect_wait()
assert scheduled[-1].url == 'data:text/plain,third'
assert spider._empty_since is None # 新任务重置空闲超时
clock[0] = 240.0
expect_wait() # 再次从空队列开始计时
clock[0] = 3839.0
expect_wait()
clock[0] = 3840.0
try:
spider.spider_idle()
except CloseSpider as exc:
assert exc.reason == 'redis_idle_timeout'
else:
raise AssertionError('expected idle timeout')
print('queue URLs, polling interval and idle timeout: OK')
运行 python verify_redis_wait.py,通过后输出 queue URLs, polling interval and idle timeout: OK。该检查覆盖初始任务、队列 URL 的使用、轮询间隔、新任务重置计时和超时分支;没有验证真实 Redis 连接、网络故障、分布式调度或外部网站抓取。实际运行前需根据目标服务与项目版本另行验证这些部分。