Files
wehub-resource-sync 247153575d
Tests / tests (map[TOXENV:py310], macos-latest, 3.10) (push) Has been cancelled
Tests / tests (map[TOXENV:py311], macos-latest, 3.11) (push) Has been cancelled
Tests / tests (map[TOXENV:py312], macos-latest, 3.12) (push) Has been cancelled
Tests / tests (map[TOXENV:py313], macos-latest, 3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:01:50 +08:00

135 lines
5.6 KiB
Python

"""Scrapy integration.
Decorate Scrapy spider callbacks with `scrapling_response` to receive a Scrapling `Response`
object instead of the Scrapy response, so you get Scrapling's full parsing API inside existing
Scrapy projects without changing how the spider crawls.
"""
from functools import partial, wraps
from inspect import isasyncgenfunction, iscoroutinefunction, isgeneratorfunction
from scrapling.core._types import Any, Callable, Dict, Optional, Tuple
from scrapling.engines.toolbelt.custom import Response, StatusText
try:
from scrapy.http import Response as ScrapyResponse
except (ImportError, ModuleNotFoundError) as e:
raise ModuleNotFoundError(
"This integration requires Scrapy installed, please install it first with `pip install scrapy`"
) from e
__all__ = ["scrapling_response", "convert_response"]
def convert_response(response: ScrapyResponse, **selector_config: Any) -> Response:
"""Convert a Scrapy response to a Scrapling `Response` object.
Can be used directly anywhere you have a Scrapy response at hand (middlewares, pipelines, ...).
:param response: The Scrapy response to convert.
:param selector_config: Configuration options passed to the `Response` constructor, like
`huge_tree`, `keep_comments`, `keep_cdata`, `adaptive`, `storage`, `storage_args`, and `adaptive_domain`.
:return: A Scrapling `Response` object ready for parsing.
"""
request = response.request
cookies: Dict[str, str] = {}
# `to_unicode_dict` below joins duplicate headers with commas, which corrupts multiple
# `Set-Cookie` headers, so cookies are parsed from the raw header lines instead.
for line in response.headers.getlist(b"Set-Cookie"):
pair = line.split(b";", 1)[0]
if b"=" in pair:
name, _, value = pair.decode("latin-1").partition("=")
cookies[name.strip()] = value.strip()
return Response(
url=response.url,
content=response.body,
status=response.status,
reason=StatusText.get(response.status),
cookies=cookies,
headers=dict(response.headers.to_unicode_dict()),
request_headers=dict(request.headers.to_unicode_dict()) if request else {},
encoding=getattr(response, "encoding", "utf-8"), # Binary responses don't have an encoding
method=request.method if request else "GET",
meta=dict(response.meta) if request else {}, # `meta` raises AttributeError without a request
**selector_config,
)
def scrapling_response(func: Optional[Callable] = None, **selector_config: Any) -> Callable:
"""Decorator that converts the Scrapy response passed to a spider callback into a Scrapling `Response`.
Works bare or parameterized, on any callback kind Scrapy supports (regular, generator,
coroutine, and async generator functions)::
class MySpider(scrapy.Spider):
@scrapling_response
def parse(self, response): # `response` is a Scrapling Response
product = response.find_by_text("Vans Old Skool", partial=True)
for similar in product.find_similar():
yield {"name": similar.get_all_text(strip=True)}
@scrapling_response(adaptive=True)
def parse_product(self, response):
...
:param func: The decorated callback. Left empty in the parameterized form.
:param selector_config: Configuration options passed to the `Response` constructor, like
`huge_tree`, `keep_comments`, `keep_cdata`, `adaptive`, `storage`, `storage_args`, and `adaptive_domain`.
:return: The wrapped callback. The wrapper keeps the callback's kind, name, and docstring,
so Scrapy's callback introspection keeps working.
"""
if func is None:
return partial(scrapling_response, **selector_config)
def _convert_arguments(args: Tuple[Any, ...], kwargs: Dict[str, Any]) -> Tuple[Tuple[Any, ...], Dict[str, Any]]:
converted = list(args)
for index, argument in enumerate(converted):
if isinstance(argument, ScrapyResponse):
converted[index] = convert_response(argument, **selector_config)
return tuple(converted), kwargs
for key, value in kwargs.items():
if isinstance(value, ScrapyResponse):
kwargs[key] = convert_response(value, **selector_config)
return tuple(converted), kwargs
raise TypeError(f"No Scrapy response found in the arguments of '{getattr(func, '__name__', func)}'")
# Each callback kind gets a wrapper of the same kind because Scrapy inspects the callback
# function itself, not just what it returns.
if isasyncgenfunction(func):
@wraps(func)
async def async_gen_wrapper(*args: Any, **kwargs: Any) -> Any:
args, kwargs = _convert_arguments(args, kwargs)
async for result in func(*args, **kwargs):
yield result
return async_gen_wrapper
elif iscoroutinefunction(func):
@wraps(func)
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
args, kwargs = _convert_arguments(args, kwargs)
return await func(*args, **kwargs)
return async_wrapper
elif isgeneratorfunction(func):
@wraps(func)
def gen_wrapper(*args: Any, **kwargs: Any) -> Any:
args, kwargs = _convert_arguments(args, kwargs)
yield from func(*args, **kwargs)
return gen_wrapper
@wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> Any:
args, kwargs = _convert_arguments(args, kwargs)
return func(*args, **kwargs)
return wrapper