11from __future__ import annotations
22
33import codecs
4- import pickle
4+ import sys
55from logging import getLogger
66from typing import Any , cast
77
1313from crawlee ._request import UserData
1414from crawlee ._types import HttpHeaders
1515
16+ from ._serialization import decode_from_json , encode_to_json
1617from apify import Request as ApifyRequest
1718
1819logger = getLogger (__name__ )
1920
2021
22+ def _ensure_known_request_class (request_dict : dict [str , Any ]) -> None :
23+ """Validate the optional `_class` entry before `request_from_dict` resolves it.
24+
25+ `scrapy.utils.request.request_from_dict` resolves a `_class` entry via `load_object`, which
26+ imports the dotted path it is given. To keep reconstruction self-contained — importing nothing
27+ that the running spider has not already imported — we only accept a `_class` that is already
28+ present in `sys.modules` and is a `scrapy.Request` subclass.
29+
30+ A spider that reads its own requests always has its request classes imported by the time the
31+ requests are reconstructed, so this does not restrict legitimate use.
32+ """
33+ class_path = request_dict .get ('_class' )
34+ if class_path is None :
35+ return
36+
37+ if not isinstance (class_path , str ):
38+ raise TypeError (f'Invalid scrapy_request `_class`, expected a string, got { type (class_path )} ' )
39+
40+ module_name , _ , class_name = class_path .rpartition ('.' )
41+ module = sys .modules .get (module_name ) if module_name else None
42+ request_cls = getattr (module , class_name , None ) if module is not None else None
43+
44+ if not (isinstance (request_cls , type ) and issubclass (request_cls , ScrapyRequest )):
45+ raise TypeError (
46+ f'Refusing to reconstruct a Scrapy request of type { class_path !r} : it is not an already-imported '
47+ f'scrapy.Request subclass.'
48+ )
49+
50+
2151def to_apify_request (scrapy_request : ScrapyRequest , spider : Spider ) -> ApifyRequest | None :
2252 """Convert a Scrapy request to an Apify request.
2353
@@ -35,6 +65,13 @@ def to_apify_request(scrapy_request: ScrapyRequest, spider: Spider) -> ApifyRequ
3565 logger .debug (f'to_apify_request was called (scrapy_request={ scrapy_request } )...' )
3666
3767 # Configuration to behave as similarly as possible to Scrapy's default RFPDupeFilter.
68+ #
69+ # `payload` carries the request body, which is used both for platform processing and for
70+ # computing the extended unique key. The body is also part of the serialized Scrapy request
71+ # stored further below, where it is needed to faithfully reconstruct the request. Both copies
72+ # originate from `scrapy_request.body` and are kept intentionally: dropping `payload` would
73+ # change deduplication, and dropping the serialized copy would couple reconstruction to the
74+ # Apify payload.
3875 request_kwargs : dict [str , Any ] = {
3976 'url' : scrapy_request .url ,
4077 'method' : scrapy_request .method ,
@@ -67,29 +104,49 @@ def to_apify_request(scrapy_request: ScrapyRequest, spider: Spider) -> ApifyRequ
67104
68105 request_kwargs ['user_data' ] = user_data if isinstance (user_data , dict ) else {}
69106
70- # Convert Scrapy's headers to a HttpHeaders and store them in the apify_request
107+ # Convert Scrapy's headers to HttpHeaders and store them on the apify_request. This is only
108+ # the Apify-platform-level view of the headers; the authoritative copy, with exact bytes,
109+ # travels inside the serialized scrapy_request below. `to_unicode_dict()` decodes as UTF-8
110+ # and raises on non-UTF-8 header values, so it is guarded: a request with binary headers
111+ # keeps them in the serialized payload instead of being dropped entirely.
71112 if isinstance (scrapy_request .headers , Headers ):
72- headers = cast ('dict[str, str]' , dict (scrapy_request .headers .to_unicode_dict ()))
73- request_kwargs ['headers' ] = HttpHeaders (headers )
113+ try :
114+ headers = cast ('dict[str, str]' , dict (scrapy_request .headers .to_unicode_dict ()))
115+ request_kwargs ['headers' ] = HttpHeaders (headers )
116+ except UnicodeDecodeError :
117+ logger .warning (
118+ 'Could not represent Scrapy request headers as Apify request headers (non-UTF-8 values); '
119+ 'they are preserved in the serialized request instead.'
120+ )
74121 else :
75122 logger .warning (
76123 f'Invalid scrapy_request.headers type, not scrapy.http.headers.Headers: { scrapy_request .headers } '
77124 )
78125
79126 apify_request = ApifyRequest .from_url (** request_kwargs )
80-
81- # Serialize the Scrapy ScrapyRequest and store it in the apify_request.
82- # - This process involves converting the Scrapy ScrapyRequest object into a dictionary, encoding it to base64,
83- # and storing it as 'scrapy_request' within the 'userData' dictionary of the apify_request.
84- # - The serialization process can be referenced at: https://stackoverflow.com/questions/30469575/.
85127 scrapy_request_dict = scrapy_request .to_dict (spider = spider )
86- scrapy_request_dict_encoded = codecs .encode (pickle .dumps (scrapy_request_dict ), 'base64' ).decode ()
87- apify_request .user_data ['scrapy_request' ] = scrapy_request_dict_encoded
88128
89129 except Exception as exc :
90130 logger .warning (f'Conversion of Scrapy request { scrapy_request } to Apify request failed; { exc } ' )
91131 return None
92132
133+ # Serialize the Scrapy request and store it (base64-encoded JSON) under 'scrapy_request' in the
134+ # Apify request's user data. This is intentionally outside the broad except above so that a
135+ # non-JSON-serializable value in `meta`/`cb_kwargs` is reported loudly rather than hidden as a
136+ # generic warning. The failure is logged with a full traceback and the request is skipped (None
137+ # is returned, honoring this function's contract) instead of crashing the whole crawl. See
138+ # `_serialization` for the encoding details.
139+ try :
140+ scrapy_request_json = encode_to_json (scrapy_request_dict )
141+ except TypeError :
142+ logger .exception (
143+ f'Failed to serialize Scrapy request { scrapy_request } for storage on the Apify platform; skipping it. '
144+ 'Ensure all values in `meta` and `cb_kwargs` are JSON-serializable.'
145+ )
146+ return None
147+
148+ apify_request .user_data ['scrapy_request' ] = codecs .encode (scrapy_request_json .encode ('utf-8' ), 'base64' ).decode ()
149+
93150 logger .debug (f'scrapy_request was converted to the apify_request={ apify_request } ' )
94151 return apify_request
95152
@@ -102,14 +159,15 @@ def to_scrapy_request(apify_request: ApifyRequest, spider: Spider) -> ScrapyRequ
102159 spider: The Scrapy spider that the request is associated with.
103160
104161 Raises:
105- TypeError: If the Apify request is not an instance of the `ApifyRequest` class.
106- ValueError: If the Apify request does not contain the required keys.
162+ TypeError: If `apify_request` is not an `ApifyRequest`, if the stored Scrapy request payload
163+ is malformed, or if its `_class` does not refer to an already-imported `scrapy.Request`
164+ subclass.
107165
108166 Returns:
109167 The converted Scrapy request.
110168 """
111169 if not isinstance (cast ('Any' , apify_request ), ApifyRequest ):
112- raise TypeError ('apify_request must be a crawlee.ScrapyRequest instance' )
170+ raise TypeError ('apify_request must be an apify.Request instance' )
113171
114172 logger .debug (f'to_scrapy_request was called (apify_request={ apify_request } )...' )
115173
@@ -124,10 +182,14 @@ def to_scrapy_request(apify_request: ApifyRequest, spider: Spider) -> ScrapyRequ
124182 if not isinstance (scrapy_request_dict_encoded , str ):
125183 raise TypeError ('scrapy_request_dict_encoded must be a string' )
126184
127- scrapy_request_dict = pickle .loads (codecs .decode (scrapy_request_dict_encoded .encode (), 'base64' ))
185+ scrapy_request_json = codecs .decode (scrapy_request_dict_encoded .encode (), 'base64' ).decode ('utf-8' )
186+ scrapy_request_dict = decode_from_json (scrapy_request_json )
128187 if not isinstance (scrapy_request_dict , dict ):
129188 raise TypeError ('scrapy_request_dict must be a dictionary' )
130189
190+ # Validate any `_class` entry before request_from_dict resolves and imports it.
191+ _ensure_known_request_class (scrapy_request_dict )
192+
131193 scrapy_request = request_from_dict (scrapy_request_dict , spider = spider )
132194 if not isinstance (scrapy_request , ScrapyRequest ):
133195 raise TypeError ('scrapy_request must be an instance of the ScrapyRequest class' )
0 commit comments