Skip to content

Commit ec5e79c

Browse files
committed
fix!: use Scrapy's fingerprint for Scrapy request unique keys
BREAKING CHANGE: Unique keys for requests converted from Scrapy are now derived from Scrapy's own request fingerprint (case-sensitive URL, `utm_*` params kept, headers ignored) instead of Crawlee's URL normalization (which lowercased the whole URL and stripped `utm_*`). Requests that vanilla Scrapy treats as distinct are no longer silently deduplicated away. Request queues keyed under the old normalized-URL scheme will not deduplicate new requests against pre-existing entries after upgrading.
1 parent 785da90 commit ec5e79c

2 files changed

Lines changed: 58 additions & 13 deletions

File tree

src/apify/scrapy/requests.py

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from scrapy import Spider
99
from scrapy.http.headers import Headers
1010
from scrapy.utils.misc import load_object
11-
from scrapy.utils.request import request_from_dict
11+
from scrapy.utils.request import RequestFingerprinter, request_from_dict
1212

1313
from crawlee._request import UserData
1414
from crawlee._types import HttpHeaders
@@ -61,23 +61,28 @@ def to_apify_request(scrapy_request: ScrapyRequest, spider: Spider) -> ApifyRequ
6161
logger.warning('Failed to convert to Apify request: Scrapy request must be a ScrapyRequest instance.')
6262
return None
6363

64-
# Configuration to behave as similarly as possible to Scrapy's default RFPDupeFilter.
65-
#
66-
# The body is stored twice on purpose: as `payload` (used for the extended unique key) and inside the serialized
67-
# Scrapy request below (used to reconstruct it). Both come from `scrapy_request.body`.
64+
# The body is stored as `payload` for the Apify-platform view of the request; the authoritative copy used to
65+
# reconstruct the Scrapy request travels inside the serialized blob below. Both come from `scrapy_request.body`.
6866
request_kwargs: dict[str, Any] = {
6967
'url': scrapy_request.url,
7068
'method': scrapy_request.method,
7169
'payload': scrapy_request.body,
72-
'use_extended_unique_key': True,
73-
'keep_url_fragment': False,
7470
}
7571

7672
try:
7773
if scrapy_request.dont_filter:
7874
request_kwargs['always_enqueue'] = True
7975
elif scrapy_request.meta.get('apify_request_unique_key'):
8076
request_kwargs['unique_key'] = scrapy_request.meta['apify_request_unique_key']
77+
else:
78+
# Deduplicate exactly like Scrapy's own RFPDupeFilter, whose fingerprint canonicalizes the URL
79+
# case-sensitively (`w3lib.url.canonicalize_url`), keeps `utm_*` params, and ignores headers. Left to
80+
# Crawlee, the unique key would instead come from `normalize_url`, which lowercases the whole URL and
81+
# strips `utm_*` — silently collapsing distinct pages Scrapy would crawl — while its extended key hashes
82+
# headers Scrapy ignores. A custom `REQUEST_FINGERPRINTER_CLASS` is honored when the spider has a crawler.
83+
crawler = getattr(spider, 'crawler', None)
84+
fingerprinter = getattr(crawler, 'request_fingerprinter', None) or RequestFingerprinter()
85+
request_kwargs['unique_key'] = fingerprinter.fingerprint(scrapy_request).hex()
8186

8287
# Serialize the Scrapy request now, before `Request.from_url()` runs below. `from_url()` mutates the
8388
# `user_data` dict it receives in place (it injects a live `CrawleeRequestData` under `__crawlee`), and that
@@ -105,12 +110,8 @@ def to_apify_request(scrapy_request: ScrapyRequest, spider: Spider) -> ApifyRequ
105110

106111
# Store an Apify-platform view of the headers. The authoritative copy with exact bytes travels in
107112
# the serialized scrapy_request below, so non-UTF-8 headers (which make `to_unicode_dict()` raise) are
108-
# tolerated rather than dropping the whole request.
109-
#
110-
# Trade-off: with `use_extended_unique_key=True` the unique key includes the headers, so when non-UTF-8
111-
# headers are omitted here two requests differing only in those headers share a unique key and one is
112-
# deduplicated away. This is rare (header values are normally ASCII/UTF-8) and still strictly better than
113-
# the old behavior, which dropped such requests entirely.
113+
# omitted here rather than dropping the whole request. Dedup is unaffected: the unique key comes from
114+
# Scrapy's fingerprint, which ignores headers.
114115
if isinstance(scrapy_request.headers, Headers):
115116
try:
116117
headers = cast('dict[str, str]', dict(scrapy_request.headers.to_unicode_dict()))

tests/unit/scrapy/requests/test_to_apify_request.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import pytest
88
from scrapy import Request, Spider
99
from scrapy.http.headers import Headers
10+
from scrapy.utils.request import fingerprint
1011

1112
from crawlee._types import HttpHeaders
1213

@@ -176,6 +177,49 @@ def test_dont_filter_request_is_always_enqueued(spider: Spider) -> None:
176177
assert first.unique_key != second.unique_key
177178

178179

180+
# Unique-key deduplication (Scrapy fingerprint parity)
181+
182+
183+
def test_unique_key_matches_scrapy_fingerprint(spider: Spider) -> None:
184+
"""The computed unique key equals Scrapy's own request fingerprint, matching RFPDupeFilter dedup semantics."""
185+
scrapy_request = Request(url='https://example.com/Products/Item-A?b=2&a=1')
186+
187+
apify_request = to_apify_request(scrapy_request, spider)
188+
189+
assert apify_request is not None
190+
assert apify_request.unique_key == fingerprint(scrapy_request).hex()
191+
192+
193+
def test_case_variant_urls_get_distinct_unique_keys(spider: Spider) -> None:
194+
"""Case-variant paths must not collapse; Scrapy distinguishes them, so the unique keys must differ."""
195+
upper = to_apify_request(Request(url='https://example.com/Products/Item-A'), spider)
196+
lower = to_apify_request(Request(url='https://example.com/products/item-a'), spider)
197+
198+
assert upper is not None
199+
assert lower is not None
200+
assert upper.unique_key != lower.unique_key
201+
202+
203+
def test_utm_params_kept_in_unique_key(spider: Spider) -> None:
204+
"""`utm_*` tracking params must not be stripped; Scrapy keeps them, so the two URLs get distinct keys."""
205+
with_utm = to_apify_request(Request(url='https://example.com/x?utm_source=x&id=1'), spider)
206+
without_utm = to_apify_request(Request(url='https://example.com/x?id=1'), spider)
207+
208+
assert with_utm is not None
209+
assert without_utm is not None
210+
assert with_utm.unique_key != without_utm.unique_key
211+
212+
213+
def test_header_only_difference_shares_unique_key(spider: Spider) -> None:
214+
"""Scrapy ignores headers when deduplicating, so requests differing only in headers share a unique key."""
215+
en = to_apify_request(Request(url='https://example.com', headers={'Accept-Language': 'en'}), spider)
216+
de = to_apify_request(Request(url='https://example.com', headers={'Accept-Language': 'de'}), spider)
217+
218+
assert en is not None
219+
assert de is not None
220+
assert en.unique_key == de.unique_key
221+
222+
179223
def test_apify_request_id_in_meta_is_ignored(spider: Spider) -> None:
180224
"""An `apify_request_id` in `meta` is ignored and does not break conversion; the unique key still applies."""
181225
scrapy_request = Request(

0 commit comments

Comments
 (0)