scrapely-python 0.1.36__tar.gz → 0.1.40__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. scrapely_python-0.1.40/CHANGELOG.md +81 -0
  2. {scrapely_python-0.1.36 → scrapely_python-0.1.40}/PKG-INFO +20 -2
  3. {scrapely_python-0.1.36 → scrapely_python-0.1.40}/README.md +17 -0
  4. {scrapely_python-0.1.36 → scrapely_python-0.1.40}/pyproject.toml +7 -4
  5. {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/__init__.py +1 -1
  6. {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/charging.py +2 -1
  7. {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/events/event_manager.py +8 -6
  8. scrapely_python-0.1.40/src/scrapely/http/__init__.py +57 -0
  9. scrapely_python-0.1.40/src/scrapely/http/_capture.py +97 -0
  10. scrapely_python-0.1.40/src/scrapely/http/_client.py +1206 -0
  11. scrapely_python-0.1.40/src/scrapely/http/_compat.py +92 -0
  12. scrapely_python-0.1.40/src/scrapely/http/_detect.py +138 -0
  13. scrapely_python-0.1.40/src/scrapely/http/_headers.py +394 -0
  14. scrapely_python-0.1.40/src/scrapely/http/_options.py +169 -0
  15. scrapely_python-0.1.40/src/scrapely/http/_policy.py +234 -0
  16. scrapely_python-0.1.40/src/scrapely/http/_profiles.py +250 -0
  17. scrapely_python-0.1.40/src/scrapely/http/_response.py +157 -0
  18. scrapely_python-0.1.40/src/scrapely/http/_stats.py +163 -0
  19. scrapely_python-0.1.40/src/scrapely/http/_transport.py +343 -0
  20. scrapely_python-0.1.40/src/scrapely/http/profiles.json +951 -0
  21. {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/proxy_configuration.py +31 -2
  22. {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/storage/api_client.py +29 -5
  23. {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/storage/client.py +6 -0
  24. {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely_python.egg-info/PKG-INFO +20 -2
  25. {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely_python.egg-info/SOURCES.txt +13 -0
  26. {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely_python.egg-info/requires.txt +3 -0
  27. scrapely_python-0.1.36/CHANGELOG.md +0 -39
  28. {scrapely_python-0.1.36 → scrapely_python-0.1.40}/LICENSE +0 -0
  29. {scrapely_python-0.1.36 → scrapely_python-0.1.40}/LICENSE-APACHE +0 -0
  30. {scrapely_python-0.1.36 → scrapely_python-0.1.40}/MANIFEST.in +0 -0
  31. {scrapely_python-0.1.36 → scrapely_python-0.1.40}/NOTICE +0 -0
  32. {scrapely_python-0.1.36 → scrapely_python-0.1.40}/setup.cfg +0 -0
  33. {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/_browser_locations.py +0 -0
  34. {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/actor.py +0 -0
  35. {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/browser_configuration.py +0 -0
  36. {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/cdp_retry.py +0 -0
  37. {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/charging_client.py +0 -0
  38. {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/configuration.py +0 -0
  39. {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/crypto/__init__.py +0 -0
  40. {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/crypto/_secrets.py +0 -0
  41. {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/errors.py +0 -0
  42. {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/events/__init__.py +0 -0
  43. {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/events/_types.py +0 -0
  44. {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/log.py +0 -0
  45. {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/patch_browser_pool.py +0 -0
  46. {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/pricing_types.py +0 -0
  47. {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/py.typed +0 -0
  48. {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/request_loaders/__init__.py +0 -0
  49. {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/request_loaders/_scrapely_request_list.py +0 -0
  50. {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/scrapy/__init__.py +0 -0
  51. {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/scrapy/_actor_runner.py +0 -0
  52. {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/scrapy/_async_thread.py +0 -0
  53. {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/scrapy/_logging_config.py +0 -0
  54. {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/scrapy/_serialization.py +0 -0
  55. {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/scrapy/_warnings.py +0 -0
  56. {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/scrapy/extensions/__init__.py +0 -0
  57. {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/scrapy/extensions/_graceful_stop.py +0 -0
  58. {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/scrapy/extensions/_httpcache.py +0 -0
  59. {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/scrapy/middlewares/__init__.py +0 -0
  60. {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/scrapy/middlewares/apify_proxy.py +0 -0
  61. {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/scrapy/pipelines/__init__.py +0 -0
  62. {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/scrapy/pipelines/actor_dataset_push.py +0 -0
  63. {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/scrapy/requests.py +0 -0
  64. {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/scrapy/scheduler.py +0 -0
  65. {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/scrapy/utils.py +0 -0
  66. {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/solver.py +0 -0
  67. {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/storage/__init__.py +0 -0
  68. {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/storage/_file_system/__init__.py +0 -0
  69. {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/storage/_file_system/_key_value_store_client.py +0 -0
  70. {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/storage/_file_system/_storage_client.py +0 -0
  71. {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/storage/_metadata.py +0 -0
  72. {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/storage/patch_storage.py +0 -0
  73. {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/storage/scrapely_dataset_client.py +0 -0
  74. {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/storage/scrapely_kvs_client.py +0 -0
  75. {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/storage/scrapely_rq_client.py +0 -0
  76. {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/storage/scrapely_storage_client.py +0 -0
  77. {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/storage/smart_scrapely_storage_client.py +0 -0
  78. {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/storage/types.py +0 -0
  79. {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/storage/utils.py +0 -0
  80. {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/storage_clients/__init__.py +0 -0
  81. {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/storages/__init__.py +0 -0
  82. {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/types.py +0 -0
  83. {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/webhook.py +0 -0
  84. {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely_python.egg-info/dependency_links.txt +0 -0
  85. {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely_python.egg-info/top_level.txt +0 -0
@@ -0,0 +1,81 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ ## [Unreleased]
6
+
7
+ ## [0.1.40] - 2026-10-28
8
+
9
+ 0.1.39 was never published to PyPI. This release includes the 0.1.39 change below, plus these fixes to `scrapely.http` from the fingerprint test on staging (2026-10-27):
10
+
11
+ - **Accept-Language matches the exit IP.** Before, a client without a proxy country sent `en-US` on every identity: 8 of 20 residential sessions did so from Brazil, France, Spain, Mexico or Taiwan.
12
+ - **Residential platform proxy without a country:** each identity now picks an exit country, weighted like real web traffic, asks the proxy for it and sends that country's language.
13
+ - **Proxy with a country:** each identity gets one of the language setups common there (for example `de-DE,de;q=0.9` or `de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7`).
14
+ - **Several `locales`:** each identity uses one of them, not all of them in one header. With a residential proxy without a country, the exit follows the locale's country.
15
+ - **Unchanged:** no proxy, your own proxy URLs and the `DATACENTER` group (US exits only) keep `en-US,en;q=0.9`.
16
+ - `ProxyConfiguration.new_url()` takes an optional `country_code` for one URL (residential platform proxy without a country only). Sessions expose `accept_language` and `country`.
17
+ - **No more `400` on `https://host?q`.** A URL with a query and no path is sent with the path `/`. wreq 0.13 sent `:path: ?q` over HTTP/2, which Cloudflare and nginx reject.
18
+ - **Chrome 155 and 156.** The default profiles are Chrome 156, 155, 154 and 153, weighted 55 / 30 / 10 / 5. 155 and 156 use the Chrome 154 TLS and HTTP/2 profile, which is the same as real Chrome 156's, with their own User-Agent, `sec-ch-ua` and Accept (with `image/jxl`, which real 155 and 156 send).
19
+ - **Firefox 153 ESR.** Added Firefox 153 (ESR) next to 152. Both match real Firefox 153.6 ESR and 152 builds byte for byte in the TLS ClientHello and HTTP/2 settings, and are now marked verified.
20
+
21
+ ## [0.1.39] - 2026-10-27 (not published)
22
+
23
+ - A key-value store record write (`Actor.set_value`, `KeyValueStore.set_value`) is no longer lost to one failed answer. Each write now sends an `Idempotency-Key`, and when its answer is lost (a `502`/`504` page from the edge or load balancer, a dropped connection, a timeout) it is sent again with the same key; the API applies and counts it once. Before, the write was never re-sent, so one such page on the final `OUTPUT` write failed the whole run. An API that answers a keyed write without `X-Scrapely-Idempotency` (an older server) gets no re-sends, as before.
24
+
25
+ ## [0.1.38] - 2026-10-27
26
+
27
+ 0.1.37 was never published to PyPI. This release includes everything listed under 0.1.37 below, plus these changes to `scrapely.http`:
28
+
29
+ - **Instant rotation.** A `429`, block page, captcha, challenge, `511`, `5xx` or network error now moves the request to a brand-new identity at once: a new proxy session, a new profile (a different one from the blocked identity's when there is a choice) and an empty cookie jar.
30
+ - **No waiting.** By default the client no longer waits: `Retry-After` is ignored, and there is no per-host backoff or slow mode.
31
+ - **Cap.** `max_rotations` (default 6) caps the rotations per request; `max_retries` is still accepted as its older name. With a fixed proxy URL, or no proxy, a block rotates once.
32
+ - **Polite mode (opt in).** `polite=True` or `backoff=BackoffPolicy(...)` brings back `Retry-After` (capped at 60 s), the 1, 2, 4 … s per-host backoff, the PerimeterX same-identity retry and the circuit-breaker slow mode.
33
+ - **Stats.** `stats()`, the summary line and `HTTP_STATS` count rotations: in total, per reason or vendor, and per host.
34
+
35
+ ## [0.1.37] - 2026-10-27 (not published)
36
+
37
+ - Added `scrapely.http`, an HTTP client that looks like a real browser and handles blocks (`AsyncClient`, sync `Client`, `session()` for multi-step flows).
38
+ - **Fingerprint.** TLS, HTTP/2, User-Agent, client hints and header order all come from one current browser profile, listed in `scrapely/http/profiles.json`. The default desktop Chrome profiles send the same JA4, HTTP/2 settings and header order as real Chrome 156.
39
+ - **Identities.** An identity is a profile, a proxy session from `ProxyConfiguration.new_url(session_id)` and a cookie jar. They are pooled, concurrent requests never share one, and a blocked identity is retired as a whole.
40
+ - **Block detection.** Cloudflare, PerimeterX, DataDome, Akamai, `429`, `511` and `403` pages are detected, and so are blocks that your own `is_blocked` check reports. Beacons that real pages also carry are not counted as blocks.
41
+ - **Retries.** Blocked requests are retried on a fresh identity. (0.1.38 changed the waits; see above.)
42
+ - **Accept-Language.** It follows the proxy country, unless you set `locales`.
43
+ - **Headers you add** (referer, cookie, origin, content-type, `x-*` and so on) go where a real browser puts them. A User-Agent or client hint you pass is ignored, with one warning.
44
+ - **Options.** `fingerprint_options` takes snake_case keys, and also the camelCase `fingerprintOptions.fingerprintGeneratorOptions` form.
45
+ - **Stats.** When the client closes, it logs a one-line summary and writes the run's totals to the default key-value store as `HTTP_STATS`.
46
+ - **curl_cffi migration.** `AsyncSession` / `Session` accept `impersonate=`, `proxy=`, `proxies=`, `timeout=`, `headers=` and `verify=`, so switching from curl_cffi takes one line.
47
+ - New dependency: `wreq>=0.13,<0.14` (Python 3.11+). If `wreq` can't be imported, the client falls back to `curl_cffi` when it is installed, and logs one warning.
48
+
49
+ ## [0.1.36] - 2026-10-26
50
+
51
+ - Opening a named dataset, key-value store or request queue (`Actor.open_dataset('name')` and the like) retries when the API answers `503 storage-name-busy`. The API sends it when another request kept creating or renaming a storage of that name for over 15 s; nothing was changed, so the open is simply repeated. It used to fail the open.
52
+
53
+ ## [0.1.35] - 2026-10-26
54
+
55
+ - Once `Actor.fail()` has run, the run stays failed. A later `Actor.exit()` keeps the failure's exit code and status message, so the usual `try: ... await Actor.fail(...) finally: await Actor.exit()` pattern now ends the run as FAILED. Before, the `exit()` in `finally` replaced the failure's exit code 1 with 0 and the run ended as SUCCEEDED with the failure message as its status. The same holds after `fail()` whose `SystemExit` was caught, after `fail(ExitOptions(exit=False))` (also inside `async with Actor(...)` and `Actor.main()`), and for a second `fail()`, which keeps the first failure's code and message.
56
+ - `Actor.fail(ExitOptions(...))` without an exit code exits with 1. `ExitOptions` defaults `exit_code` to 0, which made such a failure exit with 0. A non-zero `exit_code` in the options, or an explicit `exit_code=` keyword, still wins.
57
+
58
+ ## [0.1.34] - 2026-10-23
59
+
60
+ - Debug captures (`Actor.debug_capture`, `Actor.debug_capture_sync`) store parsed XML/HTML elements as their markup instead of a repr such as `<Element 'change' at 0x...>`: `xml.etree.ElementTree` elements and trees, lxml elements and trees, BeautifulSoup tags, selectolax nodes and parsel / Scrapy selectors, passed alone or inside a dict or list. The text after an element (its tail) is not included. Other objects are still stored as their `str()`. Captures are only active on the platform's own diagnostic runs (`SCRAPELY_DEBUG_CAPTURE=1`).
61
+
62
+ ## [0.1.33] - 2026-10-22
63
+
64
+ - Debug captures (`Actor.debug_capture`, `Actor.debug_capture_sync`) keep payloads over 256 KiB whole. They are stored gzip-compressed and base64-encoded as `{key, payload, encoding: 'gzip-base64', length, truncated}` instead of being cut at 262,144 characters; up to 8 Mi characters are kept. A payload that does not compress enough keeps its first 256 KiB, now marked `truncated: True` with its full `length`. Dict and list payloads are dumped as compact JSON, and `bytes` payloads are stored as UTF-8 text (they used to be stored as their `repr`). Captures are only active on the platform's own diagnostic runs (`SCRAPELY_DEBUG_CAPTURE=1`).
65
+
66
+ ## [0.1.32] - 2026-10-20
67
+
68
+ - Added `scrapely.scrapy`, the Scrapy integration: `run_scrapy_actor`, `initialize_logging`, `apply_apify_settings`, a scheduler backed by the run's request queue, a pipeline that pushes items to the default dataset, a proxy middleware, an HTTP cache storage and an extension that stops the crawl gracefully when the run is aborted. Queued Scrapy requests and cached responses are stored as JSON, never pickled, and a stored request only rebuilds as a `scrapy.Request` subclass. A request leaves the queue as handled only once Scrapy has finished it, so a migrated or aborted run does not lose or repeat it. Scrapy is imported only when these are used.
69
+
70
+ ## [0.1.31] - 2026-10-20
71
+
72
+ - The storage client percent-encodes request ids and key-value store record keys as one URL path segment and refuses `''`, `.` and `..`. A request id such as `..` read from a queue used to send the call to another resource.
73
+
74
+ ## [0.1.30] - 2026-10-20
75
+
76
+ - Removed `apify_margin_percentage` from the pricing info models; the API no longer returns it.
77
+ - Added Apache-2.0 change notices to the storage clients, storage utils and log module derived from apify-sdk-python, and listed them in NOTICE.
78
+
79
+ ## [0.1.29] - 2026-10-19
80
+
81
+ - Initial public release.
@@ -1,8 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scrapely-python
3
- Version: 0.1.36
3
+ Version: 0.1.40
4
4
  Summary: Scrapely Python SDK - A toolkit for building Actors on the Scrapely platform
5
- Author-email: Scrapely <support@scrape.ly>
5
+ Author-email: Scrapely <hello@scrape.ly>
6
6
  License-Expression: MIT AND Apache-2.0
7
7
  Project-URL: Homepage, https://scrape.ly
8
8
  Project-URL: Documentation, https://scrape.ly/docs/sdk-python
@@ -29,6 +29,7 @@ Requires-Dist: websockets>=12.0
29
29
  Requires-Dist: scrapely-client-python<2,>=1.0.2
30
30
  Requires-Dist: pydantic>=2.0.0
31
31
  Requires-Dist: cryptography>=42.0.0
32
+ Requires-Dist: wreq<0.14,>=0.13; python_version >= "3.11"
32
33
  Provides-Extra: dev
33
34
  Requires-Dist: pytest>=8.0.0; extra == "dev"
34
35
  Requires-Dist: pytest-asyncio>=1.0.0; extra == "dev"
@@ -75,6 +76,21 @@ if __name__ == "__main__":
75
76
 
76
77
  `Actor.main()` initializes the Actor, awaits your callback, closes events and storage, and exits the process. For embedded applications and tests, use `async with Actor:` instead.
77
78
 
79
+ ## HTTP client
80
+
81
+ ```python
82
+ from scrapely import Actor
83
+ from scrapely.http import AsyncClient
84
+
85
+ async with Actor:
86
+ proxy = await Actor.create_proxy_configuration(groups=["RESIDENTIAL"], country_code="DE")
87
+ async with AsyncClient(proxy_configuration=proxy) as http:
88
+ r = await http.get("https://www.example.com/")
89
+ data = (await http.get("https://www.example.com/api/items", kind="json")).json()
90
+ ```
91
+
92
+ Every request uses a current browser profile: TLS, HTTP/2, User-Agent, client hints and header order all come from it. Each request runs on an identity, which is a profile, a proxy session and a cookie jar. When a site blocks an identity (429, block page, captcha), the client retires it and retries the request at once on a fresh one, with a new IP, a new profile and an empty jar. It doesn't wait; `polite=True` turns on Retry-After and backoff. Moving from curl_cffi takes one line: `from scrapely.http import AsyncSession`.
93
+
78
94
  ## Highlights
79
95
 
80
96
  - Local file-system storage and Scrapely API-backed datasets, key-value stores, and request queues
@@ -83,6 +99,8 @@ if __name__ == "__main__":
83
99
  - Automatic remote Chromium CDP wiring through `CDP_URL`
84
100
  - Actor start, call, abort, reboot, and metamorph operations
85
101
  - Ad-hoc webhooks, proxy configuration, and charging/pricing types
102
+ - `scrapely.http`: an HTTP client with current browser fingerprints (TLS, HTTP/2, headers), identity rotation, block detection and retries; drop-in `AsyncSession` / `Session` for curl_cffi code
103
+ - Scrapy integration in `scrapely.scrapy` (`run_scrapy_actor`, a request-queue scheduler, a dataset pipeline); install Scrapy separately
86
104
  - Legacy environment variable aliases and asynchronous client access
87
105
 
88
106
  See the [complete Python SDK guide](https://scrape.ly/docs/sdk-python) for lifecycle details, option objects, storage selection, events, CDP behavior, run control, environment variables, errors, and the full public export list.
@@ -34,6 +34,21 @@ if __name__ == "__main__":
34
34
 
35
35
  `Actor.main()` initializes the Actor, awaits your callback, closes events and storage, and exits the process. For embedded applications and tests, use `async with Actor:` instead.
36
36
 
37
+ ## HTTP client
38
+
39
+ ```python
40
+ from scrapely import Actor
41
+ from scrapely.http import AsyncClient
42
+
43
+ async with Actor:
44
+ proxy = await Actor.create_proxy_configuration(groups=["RESIDENTIAL"], country_code="DE")
45
+ async with AsyncClient(proxy_configuration=proxy) as http:
46
+ r = await http.get("https://www.example.com/")
47
+ data = (await http.get("https://www.example.com/api/items", kind="json")).json()
48
+ ```
49
+
50
+ Every request uses a current browser profile: TLS, HTTP/2, User-Agent, client hints and header order all come from it. Each request runs on an identity, which is a profile, a proxy session and a cookie jar. When a site blocks an identity (429, block page, captcha), the client retires it and retries the request at once on a fresh one, with a new IP, a new profile and an empty jar. It doesn't wait; `polite=True` turns on Retry-After and backoff. Moving from curl_cffi takes one line: `from scrapely.http import AsyncSession`.
51
+
37
52
  ## Highlights
38
53
 
39
54
  - Local file-system storage and Scrapely API-backed datasets, key-value stores, and request queues
@@ -42,6 +57,8 @@ if __name__ == "__main__":
42
57
  - Automatic remote Chromium CDP wiring through `CDP_URL`
43
58
  - Actor start, call, abort, reboot, and metamorph operations
44
59
  - Ad-hoc webhooks, proxy configuration, and charging/pricing types
60
+ - `scrapely.http`: an HTTP client with current browser fingerprints (TLS, HTTP/2, headers), identity rotation, block detection and retries; drop-in `AsyncSession` / `Session` for curl_cffi code
61
+ - Scrapy integration in `scrapely.scrapy` (`run_scrapy_actor`, a request-queue scheduler, a dataset pipeline); install Scrapy separately
45
62
  - Legacy environment variable aliases and asynchronous client access
46
63
 
47
64
  See the [complete Python SDK guide](https://scrape.ly/docs/sdk-python) for lifecycle details, option objects, storage selection, events, CDP behavior, run control, environment variables, errors, and the full public export list.
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "scrapely-python"
7
- version = "0.1.36"
7
+ version = "0.1.40"
8
8
  description = "Scrapely Python SDK - A toolkit for building Actors on the Scrapely platform"
9
9
  readme = "README.md"
10
10
  license = "MIT AND Apache-2.0"
11
11
  license-files = ["LICENSE", "LICENSE-APACHE", "NOTICE"]
12
12
  authors = [
13
- {name = "Scrapely", email = "support@scrape.ly"}
13
+ {name = "Scrapely", email = "hello@scrape.ly"}
14
14
  ]
15
15
  classifiers = [
16
16
  "Development Status :: 3 - Alpha",
@@ -31,6 +31,7 @@ dependencies = [
31
31
  "scrapely-client-python>=1.0.2,<2",
32
32
  "pydantic>=2.0.0",
33
33
  "cryptography>=42.0.0",
34
+ "wreq>=0.13,<0.14; python_version>='3.11'",
34
35
  ]
35
36
 
36
37
  [project.optional-dependencies]
@@ -56,7 +57,7 @@ Changelog = "https://github.com/scrapely-app/scrapely-python/blob/main/CHANGELOG
56
57
  where = ["src"]
57
58
 
58
59
  [tool.setuptools.package-data]
59
- scrapely = ["py.typed"]
60
+ scrapely = ["py.typed", "http/profiles.json"]
60
61
 
61
62
  [tool.black]
62
63
  line-length = 100
@@ -84,4 +85,6 @@ strict_equality = true
84
85
 
85
86
  [tool.pytest.ini_options]
86
87
  asyncio_mode = "auto"
87
- testpaths = ["tests"]
88
+ testpaths = ["tests"]
89
+ markers = ["network: needs internet access (run with -m network)"]
90
+ addopts = "-m 'not network'"
@@ -227,7 +227,7 @@ if TYPE_CHECKING: # Static analysers and IDEs see the lazy names as plain impor
227
227
  set_api_storage,
228
228
  )
229
229
 
230
- __version__ = '0.1.36'
230
+ __version__ = '0.1.40'
231
231
  __all__ = [
232
232
  # Core
233
233
  'Actor',
@@ -308,7 +308,8 @@ class ChargingManager:
308
308
 
309
309
  def _remaining_budget_usd(self) -> Decimal:
310
310
  """maxTotalChargeUsd minus this SDK's charges and the platform's other run spend."""
311
- assert self._max_total_charge_usd is not None
311
+ if self._max_total_charge_usd is None:
312
+ raise RuntimeError('maxTotalChargeUsd is not set')
312
313
  return self._max_total_charge_usd - self._total_charged_usd - self._platform_other_usd
313
314
 
314
315
  def _sync_platform_budget(self) -> None:
@@ -122,13 +122,15 @@ class ScrapelyEventManager(CrawleeEventManager):
122
122
  # Run tasks but don't await them
123
123
  ws_url = self._configuration.get('actor_events_ws_url')
124
124
  if ws_url:
125
- # Append authentication parameters to the WebSocket URL
126
- # This matches the Node SDK behavior in events-client.ts
125
+ # Authenticate with the token in a header and the runId in the URL (URLs end up in access
126
+ # logs). This matches the Node SDK behavior in events-client.ts
127
127
  token = self._configuration.get('token')
128
128
  run_id = self._configuration.get('actor_run_id')
129
+ headers: dict[str, str] = {}
129
130
  if token and run_id:
130
131
  separator = '&' if '?' in ws_url else '?'
131
- ws_url = f"{ws_url}{separator}{urlencode({'token': token, 'runId': run_id})}"
132
+ ws_url = f"{ws_url}{separator}{urlencode({'runId': run_id})}"
133
+ headers['Authorization'] = f'Bearer {token}'
132
134
  logger.debug(f'Connecting to platform events websocket with runId={run_id}')
133
135
  else:
134
136
  logger.warning(
@@ -139,7 +141,7 @@ class ScrapelyEventManager(CrawleeEventManager):
139
141
  # Connect in the background without blocking init, matching the Node SDK: a slow or failed
140
142
  # connection is retried and must not crash the Actor.
141
143
  self._process_platform_messages_task = asyncio.create_task(
142
- self._process_platform_messages(ws_url)
144
+ self._process_platform_messages(ws_url, headers)
143
145
  )
144
146
  else:
145
147
  logger.debug(
@@ -170,7 +172,7 @@ class ScrapelyEventManager(CrawleeEventManager):
170
172
 
171
173
  await super().__aexit__(exc_type, exc_value, exc_traceback)
172
174
 
173
- async def _process_platform_messages(self, ws_url: str) -> None:
175
+ async def _process_platform_messages(self, ws_url: str, headers: Optional[dict[str, str]] = None) -> None:
174
176
  """Process messages from the platform WebSocket with reconnect logic."""
175
177
  try:
176
178
  import websockets.asyncio.client
@@ -189,7 +191,7 @@ class ScrapelyEventManager(CrawleeEventManager):
189
191
  try:
190
192
  # Used as an async iterator, `connect` reconnects with exponential backoff on failed connection attempts,
191
193
  # including the first one (timeouts, network errors and 5xx responses are retried).
192
- async for websocket in websockets.asyncio.client.connect(ws_url):
194
+ async for websocket in websockets.asyncio.client.connect(ws_url, additional_headers=headers or None):
193
195
  self._platform_events_websocket = websocket
194
196
  if self._connected_to_platform_websocket and not self._connected_to_platform_websocket.done():
195
197
  self._connected_to_platform_websocket.set_result(True)
@@ -0,0 +1,57 @@
1
+ """scrapely.http: an HTTP client that looks like a real browser and handles blocks.
2
+
3
+ Every request goes out with a current browser fingerprint whose TLS, HTTP/2,
4
+ User-Agent, client hints and header order all come from one backend profile.
5
+ Requests use identities (profile + proxy session + cookie jar) that are
6
+ retired together when a site blocks them; the request is retried on a fresh
7
+ identity at once, without waiting: the block was tied to the old identity.
8
+ ``polite=True`` turns on Retry-After, per-host backoff and a slow mode for
9
+ actors that must be gentle with a site.
10
+
11
+ Quick start::
12
+
13
+ from scrapely import Actor
14
+ from scrapely.http import AsyncClient
15
+
16
+ proxy = await Actor.create_proxy_configuration(groups=["RESIDENTIAL"], country_code="DE")
17
+ async with AsyncClient(proxy_configuration=proxy) as http:
18
+ r = await http.get("https://example.com/")
19
+ data = (await http.get("https://example.com/api/items", kind="json")).json()
20
+
21
+ Migrating from curl_cffi is one line: ``from scrapely.http import AsyncSession``.
22
+ """
23
+
24
+ from ._client import AsyncClient, AsyncHttpSession, Client, HttpSession
25
+ from ._compat import AsyncSession, Session
26
+ from ._detect import detect_block
27
+ from ._options import FingerprintOptions
28
+ from ._policy import BackoffPolicy
29
+ from ._profiles import BrowserProfile, BrowserSpec, NoMatchingProfile, load_table
30
+ from ._response import Headers, HTTPError, Response
31
+ from ._stats import HttpStats
32
+ from ._transport import TransportError
33
+
34
+ # curl_cffi / requests-style name for transport errors.
35
+ RequestsError = TransportError
36
+
37
+ __all__ = [
38
+ 'AsyncClient',
39
+ 'AsyncHttpSession',
40
+ 'AsyncSession',
41
+ 'BackoffPolicy',
42
+ 'BrowserProfile',
43
+ 'BrowserSpec',
44
+ 'Client',
45
+ 'FingerprintOptions',
46
+ 'HTTPError',
47
+ 'Headers',
48
+ 'HttpSession',
49
+ 'HttpStats',
50
+ 'NoMatchingProfile',
51
+ 'RequestsError',
52
+ 'Response',
53
+ 'Session',
54
+ 'TransportError',
55
+ 'detect_block',
56
+ 'load_table',
57
+ ]
@@ -0,0 +1,97 @@
1
+ """Read the headers a backend profile really sends, over loopback HTTP/1.1.
2
+
3
+ Used by the profile-table tests and to regenerate the ``headers`` of each
4
+ profile in ``profiles.json`` after a backend upgrade::
5
+
6
+ python -m scrapely.http._capture > /tmp/native.json
7
+
8
+ No network access: a one-shot server on 127.0.0.1 records the request.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import socket
15
+ import sys
16
+ import threading
17
+ from typing import Dict, List, Tuple
18
+
19
+
20
+ class LoopbackServer:
21
+ """Accepts connections on 127.0.0.1 and records each request's header lines in order."""
22
+
23
+ def __init__(self, response: bytes = b'HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok') -> None:
24
+ self.response = response
25
+ self.requests: List[str] = []
26
+ self._sock = socket.socket()
27
+ self._sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
28
+ self._sock.bind(('127.0.0.1', 0))
29
+ self._sock.listen(64)
30
+ self.port = self._sock.getsockname()[1]
31
+ threading.Thread(target=self._loop, daemon=True).start()
32
+
33
+ @property
34
+ def url(self) -> str:
35
+ return f'http://127.0.0.1:{self.port}'
36
+
37
+ def _loop(self) -> None:
38
+ while True:
39
+ try:
40
+ conn, _ = self._sock.accept()
41
+ except OSError:
42
+ return
43
+ threading.Thread(target=self._one, args=(conn,), daemon=True).start()
44
+
45
+ def _one(self, conn: socket.socket) -> None:
46
+ data = b''
47
+ try:
48
+ while b'\r\n\r\n' not in data:
49
+ chunk = conn.recv(65536)
50
+ if not chunk:
51
+ break
52
+ data += chunk
53
+ self.requests.append(data.decode('latin-1'))
54
+ conn.sendall(self.response)
55
+ finally:
56
+ conn.close()
57
+
58
+ def close(self) -> None:
59
+ self._sock.close()
60
+
61
+
62
+ def parse_headers(raw: str) -> List[Tuple[str, str]]:
63
+ head = raw.split('\r\n\r\n', 1)[0]
64
+ out = []
65
+ for line in head.split('\r\n')[1:]:
66
+ if ':' in line:
67
+ k, v = line.split(':', 1)
68
+ out.append((k.strip().lower(), v.strip()))
69
+ return out
70
+
71
+
72
+ def native_headers(wreq_profile: str, wreq_platform: str) -> List[Tuple[str, str]]:
73
+ """Header (name, value) pairs wreq sends for a profile, in its native order (host excluded)."""
74
+ import wreq
75
+ from wreq.blocking import Client
76
+
77
+ srv = LoopbackServer()
78
+ try:
79
+ emu = wreq.Emulation(profile=getattr(wreq.Profile, wreq_profile), platform=getattr(wreq.Platform, wreq_platform))
80
+ Client(emulation=emu).get(srv.url + '/').text()
81
+ return [(k, v) for k, v in parse_headers(srv.requests[-1]) if k != 'host']
82
+ finally:
83
+ srv.close()
84
+
85
+
86
+ def main() -> None:
87
+ from ._profiles import load_table
88
+
89
+ out: Dict[str, Dict[str, str]] = {}
90
+ for p in load_table().profiles:
91
+ out[p.id] = dict(native_headers(p.wreq_profile, p.wreq_platform))
92
+ json.dump(out, sys.stdout, indent=2)
93
+ sys.stdout.write('\n')
94
+
95
+
96
+ if __name__ == '__main__':
97
+ main()