""" 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 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__) class RobotsTxtMiddleware: DOWNLOAD_PRIORITY: int = 1000 def __init__(self, crawler: Crawler): if not crawler.settings.getbool("ROBOTSTXT_OBEY"): raise NotConfigured 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"") @classmethod def from_crawler(cls, crawler: Crawler) -> Self: return cls(crawler) @_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 rp = await self.robot_parser(request) self.process_request_2(rp, request) 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") 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: if not isinstance(e, IgnoreRequest): logger.exception( f"Error downloading {request}", extra={"spider": self.crawler.spider}, ) self._robots_error(e, netloc) self._stats.inc_value("robotstxt/request_count") parser = self._parsers[netloc] if isinstance(parser, Deferred): # Awaiting a Deferred consumes its result, so every request waiting # for this robots.txt gets its own Deferred, fired by a callback # that also passes the result on to the next waiter. waiter: Deferred[RobotParser | None] = Deferred() def notify_waiter(rp: RobotParser | None) -> RobotParser | None: waiter.callback(rp) return rp parser.addCallback(notify_waiter) 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, ) 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) -> None: if not isinstance(exc, IgnoreRequest): 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)