Source code for scrapy.downloadermiddlewares.robotstxt

"""
This is a middleware to respect robots.txt policies. To activate it you must
enable this middleware and enable the ROBOTSTXT_OBEY setting.

"""

from __future__ import annotations

import logging
from typing import TYPE_CHECKING, Any, Self

from twisted.internet.defer import Deferred

from scrapy import signals
from scrapy.exceptions import CloseSpider, IgnoreRequest, NotConfigured
from scrapy.http import Request, Response
from scrapy.http.request import NO_CALLBACK
from scrapy.utils.decorators import _warn_spider_arg
from scrapy.utils.defer import maybe_deferred_to_future
from scrapy.utils.httpobj import urlparse_cached
from scrapy.utils.misc import build_from_crawler, load_object

if TYPE_CHECKING:
    from scrapy import Spider
    from scrapy.crawler import Crawler
    from scrapy.robotstxt import RobotParser
    from scrapy.statscollectors import StatsCollector


logger = logging.getLogger(__name__)


[docs] class RobotsTxtMiddleware: """This middleware filters out requests forbidden by the robots.txt exclusion standard. To make sure Scrapy respects robots.txt make sure the middleware is enabled and the :setting:`ROBOTSTXT_OBEY` setting is enabled. The :setting:`ROBOTSTXT_USER_AGENT` setting can be used to specify the user agent string to use for matching in the robots.txt_ file. If it is ``None``, the User-Agent header you are sending with the request or the :setting:`USER_AGENT` setting (in that order) will be used for determining the user agent to use in the robots.txt_ file. This middleware has to be combined with a robots.txt_ parser. Scrapy ships with support for the following robots.txt_ parsers: * :ref:`Protego <protego-parser>` (default) * :ref:`RobotFileParser <python-robotfileparser>` * :ref:`Robotexclusionrulesparser <rerp-parser>` You can change the robots.txt_ parser with the :setting:`ROBOTSTXT_PARSER` setting. Or you can also :ref:`implement support for a new parser <support-for-new-robots-parser>`. If no :ref:`start request <start-requests>` can be crawled, and robots.txt rules denied at least one of them, the crawl stops with the ``robotstxt_denied`` :stat:`finish_reason`, as long as :class:`~scrapy.spidermiddlewares.start.StartSpiderMiddleware` is enabled. """ DOWNLOAD_PRIORITY: int = 1000 def __init__(self, crawler: Crawler): if not crawler.settings.getbool("ROBOTSTXT_OBEY"): raise NotConfigured self._start_request_crawled = False self._start_request_denied = False self._default_useragent: str = crawler.settings["USER_AGENT"] self._robotstxt_useragent: str | None = crawler.settings["ROBOTSTXT_USER_AGENT"] self.crawler: Crawler = crawler self._stats: StatsCollector = crawler.stats self._parsers: dict[str, RobotParser | Deferred[RobotParser | None] | None] = {} self._parserimpl: type[RobotParser] = load_object( crawler.settings.get("ROBOTSTXT_PARSER") ) # check if parser dependencies are met, this should throw an error otherwise. build_from_crawler(self._parserimpl, self.crawler, b"") crawler.signals.connect( self._response_received, signal=signals.response_received ) crawler.signals.connect(self._spider_idle, signal=signals.spider_idle) @classmethod def from_crawler(cls, crawler: Crawler) -> Self: return cls(crawler) def _response_received(self, request: Request) -> None: if request.meta.get("is_start_request"): self._start_request_crawled = True def _spider_idle(self) -> None: if self._start_request_crawled or not self._start_request_denied: return logger.error( "Stopping the crawl: no start request could be crawled, and at " "least one of them was rejected based on robots.txt rules. See " "https://docs.scrapy.org/en/latest/topics/downloader-middleware.html#topics-dlmw-robots" ) raise CloseSpider("robotstxt_denied") @_warn_spider_arg async def process_request( self, request: Request, spider: Spider | None = None ) -> None: if request.meta.get("dont_obey_robotstxt"): return if request.url.startswith("data:") or request.url.startswith("file:"): return netloc = urlparse_cached(request).netloc if netloc not in self._parsers and self._is_robotstxt_request(request): # Let this request download normally and use its own response as # this host's robots.txt, instead of triggering a second, # separate download of the same URL. self._parsers[netloc] = Deferred() self._stats.inc_value("robotstxt/request_count") return rp = await self.robot_parser(request) self.process_request_2(rp, request) @_warn_spider_arg async def process_response( self, request: Request, response: Response, spider: Spider | None = None ) -> Response: netloc = urlparse_cached(request).netloc parser = self._parsers.get(netloc) if ( isinstance(parser, Deferred) and not parser.called and self._is_robotstxt_request(request) ): await self._parse_robots(response, netloc, request) return response @_warn_spider_arg async def process_exception( self, request: Request, exception: Exception, spider: Spider | None = None ) -> None: netloc = urlparse_cached(request).netloc parser = self._parsers.get(netloc) if ( isinstance(parser, Deferred) and not parser.called and self._is_robotstxt_request(request) ): self._robots_error(exception, netloc, request) @staticmethod def _is_robotstxt_request(request: Request) -> bool: # robot_parser() handles its own internal robots.txt request directly, # without going through process_response()/process_exception(). if request.meta.get("dont_obey_robotstxt"): return False url = urlparse_cached(request) return request.url == f"{url.scheme}://{url.netloc}/robots.txt" def process_request_2(self, rp: RobotParser | None, request: Request) -> None: if rp is None: return useragent: str | bytes | None = self._robotstxt_useragent if not useragent: useragent = request.headers.get(b"User-Agent", self._default_useragent) assert useragent is not None if not rp.allowed(request.url, useragent): logger.debug( "Forbidden by robots.txt: %(request)s", {"request": request}, extra={"spider": self.crawler.spider}, ) self._stats.inc_value("robotstxt/forbidden") if request.meta.get("is_start_request"): self._start_request_denied = True raise IgnoreRequest("Forbidden by robots.txt") async def robot_parser(self, request: Request) -> RobotParser | None: url = urlparse_cached(request) netloc = url.netloc if netloc not in self._parsers: self._parsers[netloc] = Deferred() robotsurl = f"{url.scheme}://{url.netloc}/robots.txt" meta: dict[str, Any] = { "dont_obey_robotstxt": True, "is_robotstxt_request": True, } if "download_handler" in request.meta: meta["download_handler"] = request.meta["download_handler"] robotsreq = Request( robotsurl, priority=self.DOWNLOAD_PRIORITY, meta=meta, callback=NO_CALLBACK, ) try: resp = await self.crawler.engine.download_async(robotsreq) await self._parse_robots(resp, netloc, request) except Exception as e: self._robots_error(e, netloc, request) self._stats.inc_value("robotstxt/request_count") parser = self._parsers[netloc] if isinstance(parser, Deferred): # Every caller needs to get its own Deferred, so that awaiting or # cancelling one doesn't affect others. waiter: Deferred[RobotParser | None] = Deferred() def cb(result: RobotParser | None) -> RobotParser | None: waiter.callback(result) return result parser.addCallback(cb) return await maybe_deferred_to_future(waiter) return parser async def _parse_robots( self, response: Response, netloc: str, request: Request ) -> None: self._stats.inc_value("robotstxt/response_count") self._stats.inc_value(f"robotstxt/response_status_count/{response.status}") rp = build_from_crawler(self._parserimpl, self.crawler, response.body) await self.crawler.signals.send_catch_log_async( signal=signals.robots_parsed, robotparser=rp, request=request, response=response, ) rp_dfd = self._parsers[netloc] assert isinstance(rp_dfd, Deferred) self._parsers[netloc] = rp rp_dfd.callback(rp) def _robots_error(self, exc: Exception, netloc: str, request: Request) -> None: if not isinstance(exc, IgnoreRequest): logger.error( f"Error downloading {request}", exc_info=exc, extra={"spider": self.crawler.spider}, ) key = f"robotstxt/exception_count/{type(exc)}" self._stats.inc_value(key) rp_dfd = self._parsers[netloc] assert isinstance(rp_dfd, Deferred) self._parsers[netloc] = None rp_dfd.callback(None)