Upstream scrapy/scrapy state of the touched files before c9f94dc564

This commit is contained in:
upstream committed 2026-10-10 12:37:20 +02:00
commit 6675a8b884
2 files changed
+458

No files matched your search

+142
View File
@@ -0,0 +1,142 @@
"""
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):
return await maybe_deferred_to_future(parser)
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)
@@ -0,0 +1,316 @@
from __future__ import annotations
import asyncio
from unittest import mock
import pytest
from twisted.internet.defer import Deferred, DeferredList
from twisted.python import failure
from scrapy import signals
from scrapy.downloadermiddlewares.robotstxt import RobotsTxtMiddleware
from scrapy.exceptions import CannotResolveHostError, IgnoreRequest, NotConfigured
from scrapy.http import Request, Response, TextResponse
from scrapy.http.request import NO_CALLBACK
from scrapy.settings import Settings
from scrapy.utils.asyncio import call_later
from scrapy.utils.defer import deferred_from_coro, maybe_deferred_to_future
from scrapy.utils.misc import build_from_crawler
from tests.utils.decorators import coroutine_test
from tests.utils.robotstxt import rerp_available
class TestRobotsTxtMiddleware:
def setup_method(self) -> None:
self.crawler: mock.MagicMock = mock.MagicMock()
self.crawler.settings = Settings()
self.crawler.engine.download_async = mock.AsyncMock()
self.crawler.signals.send_catch_log_async = mock.AsyncMock(return_value=[])
def teardown_method(self):
del self.crawler
def test_robotstxt_settings(self):
self.crawler.settings = Settings()
self.crawler.settings.set("USER_AGENT", "CustomAgent")
with pytest.raises(NotConfigured):
build_from_crawler(RobotsTxtMiddleware, self.crawler)
def _get_successful_crawler(self) -> mock.MagicMock:
crawler = self.crawler
crawler.settings.set("ROBOTSTXT_OBEY", True)
ROBOTS = """
User-Agent: *
Disallow: /admin/
Disallow: /static/
# taken from https://en.wikipedia.org/robots.txt
Disallow: /wiki/K%C3%A4ytt%C3%A4j%C3%A4:
Disallow: /wiki/Käyttäjä:
User-Agent: UnicödeBöt
Disallow: /some/randome/page.html
""".encode()
response = TextResponse("http://site.local/robots.txt", body=ROBOTS)
async def return_response(request: Request) -> Response:
deferred: Deferred[Response] = Deferred()
call_later(0, deferred.callback, response)
return await maybe_deferred_to_future(deferred)
crawler.engine.download_async.side_effect = return_response
return crawler
@coroutine_test
async def test_robotstxt(self):
middleware = build_from_crawler(
RobotsTxtMiddleware, self._get_successful_crawler()
)
await self.assertNotIgnored(Request("http://site.local/allowed"), middleware)
self.assertRobotsTxtRequested("http://site.local")
await self.assertIgnored(Request("http://site.local/admin/main"), middleware)
await self.assertIgnored(Request("http://site.local/static/"), middleware)
await self.assertIgnored(
Request("http://site.local/wiki/K%C3%A4ytt%C3%A4j%C3%A4:"), middleware
)
await self.assertIgnored(
Request("http://site.local/wiki/Käyttäjä:"), middleware
)
@coroutine_test
async def test_robotstxt_emits_robots_parsed_signal(self):
crawler = self._get_successful_crawler()
middleware = RobotsTxtMiddleware(crawler)
request = Request("http://site.local/allowed")
await self.assertNotIgnored(request, middleware)
calls = [
kwargs
for _, kwargs in crawler.signals.send_catch_log_async.call_args_list
if kwargs.get("signal") is signals.robots_parsed
]
assert len(calls) == 1
assert calls[0]["request"] is request
assert calls[0]["robotparser"] is not None
@coroutine_test
async def test_robotstxt_multiple_reqs(self) -> None:
middleware = build_from_crawler(
RobotsTxtMiddleware, self._get_successful_crawler()
)
d1 = deferred_from_coro(
middleware.process_request(Request("http://site.local/allowed1"))
)
d2 = deferred_from_coro(
middleware.process_request(Request("http://site.local/allowed2"))
)
await maybe_deferred_to_future(DeferredList([d1, d2], fireOnOneErrback=True))
@pytest.mark.only_asyncio
@coroutine_test
async def test_robotstxt_multiple_reqs_asyncio(self) -> None:
middleware = build_from_crawler(
RobotsTxtMiddleware, self._get_successful_crawler()
)
c1 = middleware.process_request(Request("http://site.local/allowed1"))
c2 = middleware.process_request(Request("http://site.local/allowed2"))
await asyncio.gather(c1, c2)
@coroutine_test
async def test_robotstxt_ready_parser(self):
middleware = build_from_crawler(
RobotsTxtMiddleware, self._get_successful_crawler()
)
await self.assertNotIgnored(Request("http://site.local/allowed"), middleware)
await self.assertNotIgnored(Request("http://site.local/allowed"), middleware)
@coroutine_test
async def test_robotstxt_meta(self):
middleware = build_from_crawler(
RobotsTxtMiddleware, self._get_successful_crawler()
)
meta = {"dont_obey_robotstxt": True}
await self.assertNotIgnored(
Request("http://site.local/allowed", meta=meta), middleware
)
await self.assertNotIgnored(
Request("http://site.local/admin/main", meta=meta), middleware
)
await self.assertNotIgnored(
Request("http://site.local/static/", meta=meta), middleware
)
def _get_garbage_crawler(self) -> mock.MagicMock:
crawler = self.crawler
crawler.settings.set("ROBOTSTXT_OBEY", True)
response = Response(
"http://site.local/robots.txt", body=b"GIF89a\xd3\x00\xfe\x00\xa2"
)
async def return_response(request: Request) -> Response:
deferred: Deferred[Response] = Deferred()
call_later(0, deferred.callback, response)
return await maybe_deferred_to_future(deferred)
crawler.engine.download_async.side_effect = return_response
return crawler
@coroutine_test
async def test_robotstxt_garbage(self):
# garbage response should be discarded, equal 'allow all'
middleware = build_from_crawler(
RobotsTxtMiddleware, self._get_garbage_crawler()
)
await self.assertNotIgnored(Request("http://site.local"), middleware)
await self.assertNotIgnored(Request("http://site.local/allowed"), middleware)
await self.assertNotIgnored(Request("http://site.local/admin/main"), middleware)
await self.assertNotIgnored(Request("http://site.local/static/"), middleware)
def _get_emptybody_crawler(self) -> mock.MagicMock:
crawler = self.crawler
crawler.settings.set("ROBOTSTXT_OBEY", True)
response = Response("http://site.local/robots.txt")
async def return_response(request: Request) -> Response:
deferred: Deferred[Response] = Deferred()
call_later(0, deferred.callback, response)
return await maybe_deferred_to_future(deferred)
crawler.engine.download_async.side_effect = return_response
return crawler
@coroutine_test
async def test_robotstxt_empty_response(self):
# empty response should equal 'allow all'
middleware = build_from_crawler(
RobotsTxtMiddleware, self._get_emptybody_crawler()
)
await self.assertNotIgnored(Request("http://site.local/allowed"), middleware)
await self.assertNotIgnored(Request("http://site.local/admin/main"), middleware)
await self.assertNotIgnored(Request("http://site.local/static/"), middleware)
@coroutine_test
async def test_robotstxt_error(self, caplog: pytest.LogCaptureFixture) -> None:
self.crawler.settings.set("ROBOTSTXT_OBEY", True)
err = CannotResolveHostError("Robotstxt address not found")
async def return_failure(request: Request) -> Response:
deferred: Deferred[Response] = Deferred()
call_later(0, deferred.errback, failure.Failure(err))
return await maybe_deferred_to_future(deferred)
self.crawler.engine.download_async.side_effect = return_failure
middleware = build_from_crawler(RobotsTxtMiddleware, self.crawler)
await middleware.process_request(Request("http://site.local"))
assert "Robotstxt address not found" in caplog.text
@coroutine_test
async def test_robotstxt_immediate_error(self):
self.crawler.settings.set("ROBOTSTXT_OBEY", True)
err = CannotResolveHostError("Robotstxt address not found")
async def immediate_failure(request):
raise err
self.crawler.engine.download_async.side_effect = immediate_failure
middleware = build_from_crawler(RobotsTxtMiddleware, self.crawler)
await self.assertNotIgnored(Request("http://site.local"), middleware)
@coroutine_test
async def test_ignore_robotstxt_request(self):
self.crawler.settings.set("ROBOTSTXT_OBEY", True)
async def ignore_request(request: Request) -> Response:
deferred: Deferred[Response] = Deferred()
call_later(0, deferred.errback, failure.Failure(IgnoreRequest()))
return await maybe_deferred_to_future(deferred)
self.crawler.engine.download_async.side_effect = ignore_request
middleware = build_from_crawler(RobotsTxtMiddleware, self.crawler)
with mock.patch(
"scrapy.downloadermiddlewares.robotstxt.logger"
) as mw_module_logger:
await self.assertNotIgnored(
Request("http://site.local/allowed"), middleware
)
assert not mw_module_logger.error.called
def test_robotstxt_user_agent_setting(self):
crawler = self._get_successful_crawler()
crawler.settings.set("ROBOTSTXT_USER_AGENT", "Examplebot")
crawler.settings.set("USER_AGENT", "Mozilla/5.0 (X11; Linux x86_64)")
middleware = build_from_crawler(RobotsTxtMiddleware, crawler)
rp = mock.MagicMock(return_value=True)
middleware.process_request_2(rp, Request("http://site.local/allowed"))
rp.allowed.assert_called_once_with("http://site.local/allowed", "Examplebot")
@coroutine_test
async def test_robotstxt_user_agent_none(self):
middleware = build_from_crawler(
RobotsTxtMiddleware, self._get_successful_crawler()
)
headers = {"User-Agent": None}
await self.assertNotIgnored(
Request("http://site.local/allowed", headers=headers), middleware
)
await self.assertIgnored(
Request("http://site.local/admin/main", headers=headers), middleware
)
@coroutine_test
async def test_robotstxt_local_file(self):
middleware = build_from_crawler(
RobotsTxtMiddleware, self._get_emptybody_crawler()
)
middleware.process_request_2 = mock.MagicMock() # type: ignore[method-assign]
await middleware.process_request(Request("data:text/plain,Hello World data"))
assert not middleware.process_request_2.called
await middleware.process_request(
Request("file:///tests/sample_data/test_site/nothinghere.html")
)
assert not middleware.process_request_2.called
await middleware.process_request(Request("http://site.local/allowed"))
assert middleware.process_request_2.called
@coroutine_test
async def test_robotstxt_download_handler(self) -> None:
middleware = RobotsTxtMiddleware(self._get_successful_crawler())
await self.assertNotIgnored(
Request("http://site.local/allowed", meta={"download_handler": "named"}),
middleware,
)
robotsreq = self.crawler.engine.download_async.call_args_list[0][0][0]
assert robotsreq.meta["download_handler"] == "named"
assert robotsreq.meta["is_robotstxt_request"] is True
async def assertNotIgnored(
self, request: Request, middleware: RobotsTxtMiddleware
) -> None:
try:
await middleware.process_request(request)
except IgnoreRequest:
pytest.fail("IgnoreRequest was raised unexpectedly")
async def assertIgnored(
self, request: Request, middleware: RobotsTxtMiddleware
) -> None:
with pytest.raises(IgnoreRequest):
await middleware.process_request(request)
def assertRobotsTxtRequested(self, base_url: str) -> None:
calls = self.crawler.engine.download_async.call_args_list
request = calls[0][0][0]
assert request.url == f"{base_url}/robots.txt"
assert request.callback == NO_CALLBACK
@pytest.mark.skipif(not rerp_available(), reason="Rerp parser is not installed")
class TestRobotsTxtMiddlewareWithRerp(TestRobotsTxtMiddleware):
def setup_method(self):
super().setup_method()
self.crawler.settings.set(
"ROBOTSTXT_PARSER", "scrapy.robotstxt.RerpRobotParser"
)