Skip to content

Commit b4161f7

Browse files
committed
fix: Avoid mutating Scrapy request userData during conversion
`Request.from_url()` injects a live `CrawleeRequestData` into the `user_data` dict it receives, which was the spider's own `meta['userData']`. Serialize the Scrapy request before `from_url()` and pass it a copy so the spider's request stays untouched and the stored blob is free of redundant Crawlee internals.
1 parent 0daca28 commit b4161f7

2 files changed

Lines changed: 33 additions & 2 deletions

File tree

src/apify/scrapy/requests.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,12 +80,21 @@ def to_apify_request(scrapy_request: ScrapyRequest, spider: Spider) -> ApifyRequ
8080
elif scrapy_request.meta.get('apify_request_unique_key'):
8181
request_kwargs['unique_key'] = scrapy_request.meta['apify_request_unique_key']
8282

83+
# Serialize the Scrapy request now, before `Request.from_url()` runs below. `from_url()` mutates the
84+
# `user_data` dict it receives in place (it injects a live `CrawleeRequestData` under `__crawlee`), and that
85+
# dict can be the spider's own `meta['userData']`. Capturing `to_dict()` first keeps the stored blob free of
86+
# those injected internals, and copying `user_data` below leaves the spider's request untouched.
87+
scrapy_request_dict = scrapy_request.to_dict(spider=spider)
88+
8389
user_data = scrapy_request.meta.get('userData', {})
8490

8591
# Convert UserData Pydantic model to a plain dict to prevent CrawleeRequestData objects from leaking
86-
# into Request.from_url() during Scrapy-Apify roundtrips.
92+
# into Request.from_url() during Scrapy-Apify roundtrips. `model_dump()` already returns a fresh dict; the
93+
# plain-dict case is copied so the `pop` and `from_url()` mutations below never touch the spider's meta.
8794
if isinstance(user_data, UserData):
8895
user_data = user_data.model_dump(by_alias=True)
96+
elif isinstance(user_data, dict):
97+
user_data = dict(user_data)
8998

9099
# Remove internal Crawlee data since it's managed by Request.from_url() and values from previous roundtrips
91100
# cause incorrect state.
@@ -117,7 +126,6 @@ def to_apify_request(scrapy_request: ScrapyRequest, spider: Spider) -> ApifyRequ
117126
)
118127

119128
apify_request = ApifyRequest.from_url(**request_kwargs)
120-
scrapy_request_dict = scrapy_request.to_dict(spider=spider)
121129

122130
except Exception as exc:
123131
logger.warning(f'Conversion of Scrapy request {scrapy_request} to Apify request failed; {exc}')

tests/unit/scrapy/requests/test_to_apify_request.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import json
34
import logging
45
from typing import cast
56

@@ -140,6 +141,28 @@ def test_roundtrip_follow_up_request_with_propagated_userdata(spider: Spider) ->
140141
assert follow_up_apify_request.url == 'https://example.com/image.png'
141142

142143

144+
def test_does_not_mutate_spider_request_user_data(spider: Spider) -> None:
145+
"""Conversion must not mutate the spider's own `meta['userData']` by injecting Crawlee internals."""
146+
user_data = {'some_user_data': 'test'}
147+
scrapy_request = Request(url='https://example.com', meta={'userData': user_data})
148+
149+
to_apify_request(scrapy_request, spider)
150+
151+
assert user_data == {'some_user_data': 'test'}
152+
assert '__crawlee' not in user_data
153+
154+
155+
def test_serialized_request_omits_injected_crawlee_data(spider: Spider) -> None:
156+
"""The stored `scrapy_request` blob must not embed the `__crawlee` data `Request.from_url()` injects."""
157+
scrapy_request = Request(url='https://example.com', meta={'userData': {'some_user_data': 'test'}})
158+
159+
apify_request = to_apify_request(scrapy_request, spider)
160+
assert apify_request is not None
161+
162+
stored = json.loads(cast('str', apify_request.user_data['scrapy_request']))
163+
assert '__crawlee' not in stored['meta'].get('userData', {})
164+
165+
143166
def test_dont_filter_request_is_always_enqueued(spider: Spider) -> None:
144167
"""A `dont_filter=True` request is always enqueued: each conversion gets a fresh unique key, bypassing dedup."""
145168
first = to_apify_request(Request(url='https://example.com', dont_filter=True), spider)

0 commit comments

Comments
 (0)