Repository: scrapy/scrapy
Pull request #5979 (2023-07-18 10:33:03)

introduce CLOSESPIDER_TIMEOUT_NO_ITEM in CloseSpider
**Motivation:** Sometimes spiders still keep running without producing any items. This could be due to a variety of reasons like poor dupe URL filtration, bad crawling strategy, etc.

**Proposal:** Auto-close the spider if it hasn't produced any items in the past number of seconds.


Patch:
diff --git a/docs/topics/extensions.rst b/docs/topics/extensions.rst
index 96e0216b8..8d4749ab3 100644
--- a/docs/topics/extensions.rst
+++ b/docs/topics/extensions.rst
@@ -258,6 +258,7 @@ The conditions for closing a spider can be configured through the following
 settings:
 
 * :setting:`CLOSESPIDER_TIMEOUT`
+* :setting:`CLOSESPIDER_TIMEOUT_NO_ITEM`
 * :setting:`CLOSESPIDER_ITEMCOUNT`
 * :setting:`CLOSESPIDER_PAGECOUNT`
 * :setting:`CLOSESPIDER_ERRORCOUNT`
@@ -280,6 +281,18 @@ more than that number of second, it will be automatically closed with the
 reason ``closespider_timeout``. If zero (or non set), spiders won't be closed by
 timeout.
 
+.. setting:: CLOSESPIDER_TIMEOUT_NO_ITEM
+
+CLOSESPIDER_TIMEOUT_NO_ITEM
+"""""""""""""""""""""""""""
+
+Default: ``0``
+
+An integer which specifies a number of seconds. If the spider has not produced
+any items in the last number of seconds, it will be closed with the reason
+``closespider_timeout_no_item``. If zero (or non set), spiders won't be closed
+regardless if it hasn't produced any items.
+
 .. setting:: CLOSESPIDER_ITEMCOUNT
 
 CLOSESPIDER_ITEMCOUNT
diff --git a/scrapy/extensions/closespider.py b/scrapy/extensions/closespider.py
index bb6f832f2..4307b4170 100644
--- a/scrapy/extensions/closespider.py
+++ b/scrapy/extensions/closespider.py
@@ -4,11 +4,14 @@ conditions are met.
 See documentation in docs/topics/extensions.rst
 """
 
+import logging
 from collections import defaultdict
 
 from scrapy import signals
 from scrapy.exceptions import NotConfigured
 
+logger = logging.getLogger(__name__)
+
 
 class CloseSpider:
     def __init__(self, crawler):
@@ -19,6 +22,7 @@ class CloseSpider:
             "itemcount": crawler.settings.getint("CLOSESPIDER_ITEMCOUNT"),
             "pagecount": crawler.settings.getint("CLOSESPIDER_PAGECOUNT"),
             "errorcount": crawler.settings.getint("CLOSESPIDER_ERRORCOUNT"),
+            "timeout_no_item": crawler.settings.getint("CLOSESPIDER_TIMEOUT_NO_ITEM"),
         }
 
         if not any(self.close_on.values()):
@@ -34,6 +38,15 @@ class CloseSpider:
             crawler.signals.connect(self.spider_opened, signal=signals.spider_opened)
         if self.close_on.get("itemcount"):
             crawler.signals.connect(self.item_scraped, signal=signals.item_scraped)
+        if self.close_on.get("timeout_no_item"):
+            self.timeout_no_item = self.close_on["timeout_no_item"]
+            self.items_in_period = 0
+            crawler.signals.connect(
+                self.spider_opened_no_item, signal=signals.spider_opened
+            )
+            crawler.signals.connect(
+                self.item_scraped_no_item, signal=signals.item_scraped
+            )
         crawler.signals.connect(self.spider_closed, signal=signals.spider_closed)
 
     @classmethod
@@ -69,3 +82,31 @@ class CloseSpider:
         task = getattr(self, "task", False)
         if task and task.active():
             task.cancel()
+
+        task_no_item = getattr(self, "task_no_item", False)
+        if task_no_item and task_no_item.running:
+            task_no_item.stop()
+
+    def spider_opened_no_item(self, spider):
+        from twisted.internet import task
+
+        self.task_no_item = task.LoopingCall(self._count_items_produced, spider)
+        self.task_no_item.start(self.timeout_no_item, now=False)
+
+        logger.info(
+            f"Spider will stop when no items are produced after "
+            f"{self.timeout_no_item} seconds."
+        )
+
+    def item_scraped_no_item(self, item, spider):
+        self.items_in_period += 1
+
+    def _count_items_produced(self, spider):
+        if self.items_in_period >= 1:
+            self.items_in_period = 0
+        else:
+            logger.info(
+                f"Closing spider since no items were produced in the last "
+                f"{self.timeout_no_item} seconds."
+            )
+            self.crawler.engine.close_spider(spider, "closespider_timeout_no_item")


Test patch:
diff --git a/tests/spiders.py b/tests/spiders.py
index 6ff48f471..f29dea2a1 100644
--- a/tests/spiders.py
+++ b/tests/spiders.py
@@ -77,6 +77,22 @@ class DelaySpider(MetaSpider):
         self.t2_err = time.time()
 
 
+class SlowSpider(DelaySpider):
+    name = "slow"
+
+    def start_requests(self):
+        # 1st response is fast
+        url = self.mockserver.url("/delay?n=0&b=0")
+        yield Request(url, callback=self.parse, errback=self.errback)
+
+        # 2nd response is slow
+        url = self.mockserver.url(f"/delay?n={self.n}&b={self.b}")
+        yield Request(url, callback=self.parse, errback=self.errback)
+
+    def parse(self, response):
+        yield Item()
+
+
 class SimpleSpider(MetaSpider):
     name = "simple"
 
diff --git a/tests/test_closespider.py b/tests/test_closespider.py
index 9b39187d5..38ede70e4 100644
--- a/tests/test_closespider.py
+++ b/tests/test_closespider.py
@@ -3,7 +3,7 @@ from twisted.trial.unittest import TestCase
 
 from scrapy.utils.test import get_crawler
 from tests.mockserver import MockServer
-from tests.spiders import ErrorSpider, FollowAllSpider, ItemSpider
+from tests.spiders import ErrorSpider, FollowAllSpider, ItemSpider, SlowSpider
 
 
 class TestCloseSpider(TestCase):
@@ -54,3 +54,13 @@ class TestCloseSpider(TestCase):
         self.assertEqual(reason, "closespider_timeout")
         total_seconds = crawler.stats.get_value("elapsed_time_seconds")
         self.assertTrue(total_seconds >= close_on)
+
+    @defer.inlineCallbacks
+    def test_closespider_timeout_no_item(self):
+        timeout = 1
+        crawler = get_crawler(SlowSpider, {"CLOSESPIDER_TIMEOUT_NO_ITEM": timeout})
+        yield crawler.crawl(n=3, mockserver=self.mockserver)
+        reason = crawler.spider.meta["close_reason"]
+        self.assertEqual(reason, "closespider_timeout_no_item")
+        total_seconds = crawler.stats.get_value("elapsed_time_seconds")
+        self.assertTrue(total_seconds >= timeout)