This commit is contained in:
Adrian 2026-07-14 14:52:16 +07:00 committed by GitHub
commit c06a1fbfdf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 156 additions and 25 deletions

View File

@ -470,16 +470,17 @@ class ExecutionEngine:
""" """
if self.spider is None: if self.spider is None:
raise RuntimeError(f"No open spider to crawl: {request}") raise RuntimeError(f"No open spider to crawl: {request}")
try: while True:
response_or_request = await maybe_deferred_to_future( try:
self._download(request) response_or_request = await maybe_deferred_to_future(
) self._download(request)
finally: )
assert self._slot is not None finally:
self._slot.remove_request(request) assert self._slot is not None
if isinstance(response_or_request, Request): self._slot.remove_request(request)
return await self.download_async(response_or_request) if not isinstance(response_or_request, Request):
return response_or_request return response_or_request
request = response_or_request
@inlineCallbacks @inlineCallbacks
def _download( def _download(

View File

@ -61,7 +61,7 @@ class OffsiteMiddleware:
) )
self.stats.inc_value("offsite/domains") self.stats.inc_value("offsite/domains")
self.stats.inc_value("offsite/filtered") self.stats.inc_value("offsite/filtered")
raise IgnoreRequest raise IgnoreRequest(f"Filtered offsite request to {domain!r}")
def should_follow(self, request: Request, spider: Spider) -> bool: def should_follow(self, request: Request, spider: Spider) -> bool:
regex = self.host_regex regex = self.host_regex

View File

@ -79,6 +79,17 @@ class FileException(Exception):
"""General media error exception""" """General media error exception"""
class _MediaRequestFiltered(FileException):
"""Raised internally by media pipelines when a media request is filtered
out (e.g. as an offsite request) instead of being downloaded.
It is a subclass of :exc:`FileException` for backward compatibility, but
unlike an actual download error it is logged at the ``DEBUG`` level and
without a traceback, since filtering a request is expected behavior rather
than an error.
"""
class StatInfo(TypedDict, total=False): class StatInfo(TypedDict, total=False):
checksum: str checksum: str
last_modified: float last_modified: float
@ -597,20 +608,20 @@ class FilesPipeline(MediaPipeline):
def media_failed( def media_failed(
self, failure: Failure, request: Request, info: MediaPipeline.SpiderInfo self, failure: Failure, request: Request, info: MediaPipeline.SpiderInfo
) -> NoReturn: ) -> NoReturn:
if not isinstance(failure.value, IgnoreRequest): referer = referer_str(request)
referer = referer_str(request) if isinstance(failure.value, IgnoreRequest):
logger.warning( logger.debug(
"File (unknown-error): Error downloading %(medianame)s from " f"File (filtered): Not downloading {self.MEDIA_NAME} from "
"%(request)s referred in <%(referer)s>: %(exception)s", f"{request} referred in <{referer}>: {failure.value}",
{
"medianame": self.MEDIA_NAME,
"request": request,
"referer": referer,
"exception": failure.value,
},
extra={"spider": info.spider}, extra={"spider": info.spider},
) )
raise _MediaRequestFiltered(str(failure.value)) from failure.value
logger.warning(
f"File (unknown-error): Error downloading {self.MEDIA_NAME} from "
f"{request} referred in <{referer}>: {failure.value}",
extra={"spider": info.spider},
)
raise FileException raise FileException
async def media_downloaded( async def media_downloaded(

View File

@ -55,6 +55,12 @@ FileInfoOrError: TypeAlias = (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _media_request_filtered(failure: Failure) -> bool:
from scrapy.pipelines.files import _MediaRequestFiltered # noqa: PLC0415
return isinstance(failure.value, _MediaRequestFiltered)
class MediaPipeline(ABC): class MediaPipeline(ABC):
LOG_FAILED_RESULTS: bool = True LOG_FAILED_RESULTS: bool = True
@ -193,7 +199,8 @@ class MediaPipeline(ABC):
result = await self._check_media_to_download(request, info, item=item) result = await self._check_media_to_download(request, info, item=item)
except Exception: except Exception:
result = Failure() result = Failure()
logger.exception(result) if not _media_request_filtered(result):
logger.exception(result)
self._cache_result_and_execute_waiters(result, fp, info) self._cache_result_and_execute_waiters(result, fp, info)
return await maybe_deferred_to_future(wad) # it must return wad at last return await maybe_deferred_to_future(wad) # it must return wad at last
@ -304,6 +311,8 @@ class MediaPipeline(ABC):
for ok, value in results: for ok, value in results:
if not ok: if not ok:
assert isinstance(value, Failure) assert isinstance(value, Failure)
if _media_request_filtered(value):
continue
logger.error( logger.error(
"%(class)s found errors processing %(item)s", "%(class)s found errors processing %(item)s",
{"class": self.__class__.__name__, "item": item}, {"class": self.__class__.__name__, "item": item},

View File

@ -1,3 +1,5 @@
import re
import pytest import pytest
from scrapy import Request, Spider from scrapy import Request, Spider
@ -231,3 +233,15 @@ def test_repeated_offsite_domain():
mw.process_request(req2) mw.process_request(req2)
assert crawler.stats.get_value("offsite/domains") == 1 # not incremented again assert crawler.stats.get_value("offsite/domains") == 1 # not incremented again
assert crawler.stats.get_value("offsite/filtered") == 2 assert crawler.stats.get_value("offsite/filtered") == 2
def test_ignore_request_reason():
crawler = get_crawler(Spider)
crawler.spider = crawler._create_spider(name="a", allowed_domains=["example.com"])
mw = OffsiteMiddleware.from_crawler(crawler)
mw.spider_opened(crawler.spider)
request = Request("http://other.org/1")
with pytest.raises(
IgnoreRequest, match=re.escape("Filtered offsite request to 'other.org'")
):
mw.process_request(request)

View File

@ -574,6 +574,25 @@ class TestEngineDownloadAsync:
[call(original_request), call(redirect_request)] [call(original_request), call(redirect_request)]
) )
@coroutine_test
async def test_download_async_many_redirects(self, engine):
"""A long chain of requests being replaced by new ones is handled
iteratively, without hitting the recursion limit."""
count = sys.getrecursionlimit() * 2
requests = [Request(f"http://example.com/{i}") for i in range(count)]
final_response = Response("http://example.com/final", body=b"done")
engine.downloader.fetch.side_effect = [
*(defer.succeed(request) for request in requests[1:]),
defer.succeed(final_response),
]
engine.spider = Mock()
engine._slot.add_request = Mock()
engine._slot.remove_request = Mock()
result = await self._download(engine, requests[0])
assert result == final_response
assert engine.downloader.fetch.call_count == count
@coroutine_test @coroutine_test
async def test_download_async_no_spider(self, engine): async def test_download_async_no_spider(self, engine):
"""Test async download attempt when no spider is available.""" """Test async download attempt when no spider is available."""

View File

@ -1,6 +1,7 @@
import dataclasses import dataclasses
import os import os
import random import random
import re
import time import time
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from datetime import datetime from datetime import datetime
@ -18,17 +19,21 @@ from urllib.parse import urlparse
import attr import attr
import pytest import pytest
from itemadapter import ItemAdapter from itemadapter import ItemAdapter
from testfixtures import LogCapture
from twisted.internet.defer import Deferred from twisted.internet.defer import Deferred
from twisted.python.failure import Failure
from scrapy.exceptions import NotConfigured from scrapy.exceptions import IgnoreRequest, NotConfigured
from scrapy.http import Request, Response from scrapy.http import Request, Response
from scrapy.item import Field, Item from scrapy.item import Field, Item
from scrapy.pipelines.files import ( from scrapy.pipelines.files import (
FileException,
FilesPipeline, FilesPipeline,
FSFilesStore, FSFilesStore,
FTPFilesStore, FTPFilesStore,
GCSFilesStore, GCSFilesStore,
S3FilesStore, S3FilesStore,
_MediaRequestFiltered,
) )
from scrapy.settings import Settings from scrapy.settings import Settings
from scrapy.utils.asyncio import call_later from scrapy.utils.asyncio import call_later
@ -297,6 +302,65 @@ class TestFilesPipeline:
request = Request("http://example.com") request = Request("http://example.com")
assert file_path(request, item=item) == "full/path-to-store-file" assert file_path(request, item=item) == "full/path-to-store-file"
def test_media_failed_filtered_request(self):
"""A filtered media request (IgnoreRequest) is reported as a
_MediaRequestFiltered exception and logged at the DEBUG level, instead
of as a download error with a traceback."""
request = Request("http://example.com/file.pdf")
reason = "Filtered offsite request to 'example.com'"
failure = Failure(IgnoreRequest(reason))
with (
LogCapture() as log,
pytest.raises(_MediaRequestFiltered, match=re.escape(reason)),
):
self.pipeline.media_failed(failure, request, self.pipeline.spiderinfo)
assert len(log.records) == 1
record = log.records[0]
assert record.levelname == "DEBUG"
assert record.exc_info is None
assert reason in record.getMessage()
def test_media_failed_download_error(self):
"""A genuine download error is reported as a FileException and logged as
a warning."""
request = Request("http://example.com/file.pdf")
failure = Failure(Exception("boom"))
with LogCapture() as log, pytest.raises(FileException):
self.pipeline.media_failed(failure, request, self.pipeline.spiderinfo)
assert len(log.records) == 1
assert log.records[0].levelname == "WARNING"
@coroutine_test
async def test_process_item_filtered_request(self):
"""A filtered (e.g. offsite) media request is processed as a failed
result without being logged as an error with a traceback."""
item_url = "http://example.com/file.pdf"
item = _create_item_with_files(item_url)
request = Request(
item_url,
meta={
"response": IgnoreRequest("Filtered offsite request to 'example.com'")
},
)
with (
LogCapture() as log,
mock.patch.object(
FilesPipeline, "get_media_requests", return_value=[request]
),
):
result = await self.pipeline.process_item(item)
assert result["files"] == []
assert not any(r.levelname in ("WARNING", "ERROR") for r in log.records)
assert any(
"Filtered offsite request to 'example.com'" in r.getMessage()
for r in log.records
)
@pytest.mark.parametrize( @pytest.mark.parametrize(
"bad_type", "bad_type",
[ [

View File

@ -10,7 +10,7 @@ from scrapy import signals
from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.exceptions import ScrapyDeprecationWarning
from scrapy.http import Request, Response from scrapy.http import Request, Response
from scrapy.http.request import NO_CALLBACK from scrapy.http.request import NO_CALLBACK
from scrapy.pipelines.files import FileException from scrapy.pipelines.files import FileException, _MediaRequestFiltered
from scrapy.pipelines.media import MediaPipeline from scrapy.pipelines.media import MediaPipeline
from scrapy.utils.defer import _defer_sleep_async from scrapy.utils.defer import _defer_sleep_async
from scrapy.utils.log import failure_to_exc_info from scrapy.utils.log import failure_to_exc_info
@ -162,6 +162,19 @@ class TestBaseMediaPipeline:
assert new_item is item assert new_item is item
assert len(log.records) == 0 assert len(log.records) == 0
def test_item_completed_filtered_request_not_logged(self):
"""Filtered media requests (e.g. offsite ones) are not logged as errors
by item_completed(), as they are not download errors."""
item = {"name": "name"}
fail = Failure(_MediaRequestFiltered("Filtered offsite request"))
results = [(True, 1), (False, fail)]
with LogCapture() as log:
new_item = self.pipe.item_completed(results, item, self.info)
assert new_item is item
assert len(log.records) == 0
@coroutine_test @coroutine_test
async def test_default_process_item(self): async def test_default_process_item(self):
item = {"name": "name"} item = {"name": "name"}