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.
- scrapely_python-0.1.40/CHANGELOG.md +81 -0
- {scrapely_python-0.1.36 → scrapely_python-0.1.40}/PKG-INFO +20 -2
- {scrapely_python-0.1.36 → scrapely_python-0.1.40}/README.md +17 -0
- {scrapely_python-0.1.36 → scrapely_python-0.1.40}/pyproject.toml +7 -4
- {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/__init__.py +1 -1
- {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/charging.py +2 -1
- {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/events/event_manager.py +8 -6
- scrapely_python-0.1.40/src/scrapely/http/__init__.py +57 -0
- scrapely_python-0.1.40/src/scrapely/http/_capture.py +97 -0
- scrapely_python-0.1.40/src/scrapely/http/_client.py +1206 -0
- scrapely_python-0.1.40/src/scrapely/http/_compat.py +92 -0
- scrapely_python-0.1.40/src/scrapely/http/_detect.py +138 -0
- scrapely_python-0.1.40/src/scrapely/http/_headers.py +394 -0
- scrapely_python-0.1.40/src/scrapely/http/_options.py +169 -0
- scrapely_python-0.1.40/src/scrapely/http/_policy.py +234 -0
- scrapely_python-0.1.40/src/scrapely/http/_profiles.py +250 -0
- scrapely_python-0.1.40/src/scrapely/http/_response.py +157 -0
- scrapely_python-0.1.40/src/scrapely/http/_stats.py +163 -0
- scrapely_python-0.1.40/src/scrapely/http/_transport.py +343 -0
- scrapely_python-0.1.40/src/scrapely/http/profiles.json +951 -0
- {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/proxy_configuration.py +31 -2
- {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/storage/api_client.py +29 -5
- {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/storage/client.py +6 -0
- {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely_python.egg-info/PKG-INFO +20 -2
- {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely_python.egg-info/SOURCES.txt +13 -0
- {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely_python.egg-info/requires.txt +3 -0
- scrapely_python-0.1.36/CHANGELOG.md +0 -39
- {scrapely_python-0.1.36 → scrapely_python-0.1.40}/LICENSE +0 -0
- {scrapely_python-0.1.36 → scrapely_python-0.1.40}/LICENSE-APACHE +0 -0
- {scrapely_python-0.1.36 → scrapely_python-0.1.40}/MANIFEST.in +0 -0
- {scrapely_python-0.1.36 → scrapely_python-0.1.40}/NOTICE +0 -0
- {scrapely_python-0.1.36 → scrapely_python-0.1.40}/setup.cfg +0 -0
- {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/_browser_locations.py +0 -0
- {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/actor.py +0 -0
- {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/browser_configuration.py +0 -0
- {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/cdp_retry.py +0 -0
- {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/charging_client.py +0 -0
- {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/configuration.py +0 -0
- {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/crypto/__init__.py +0 -0
- {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/crypto/_secrets.py +0 -0
- {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/errors.py +0 -0
- {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/events/__init__.py +0 -0
- {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/events/_types.py +0 -0
- {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/log.py +0 -0
- {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/patch_browser_pool.py +0 -0
- {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/pricing_types.py +0 -0
- {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/py.typed +0 -0
- {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/request_loaders/__init__.py +0 -0
- {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/request_loaders/_scrapely_request_list.py +0 -0
- {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/scrapy/__init__.py +0 -0
- {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/scrapy/_actor_runner.py +0 -0
- {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/scrapy/_async_thread.py +0 -0
- {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/scrapy/_logging_config.py +0 -0
- {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/scrapy/_serialization.py +0 -0
- {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/scrapy/_warnings.py +0 -0
- {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/scrapy/extensions/__init__.py +0 -0
- {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/scrapy/extensions/_graceful_stop.py +0 -0
- {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/scrapy/extensions/_httpcache.py +0 -0
- {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/scrapy/middlewares/__init__.py +0 -0
- {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/scrapy/middlewares/apify_proxy.py +0 -0
- {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/scrapy/pipelines/__init__.py +0 -0
- {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/scrapy/pipelines/actor_dataset_push.py +0 -0
- {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/scrapy/requests.py +0 -0
- {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/scrapy/scheduler.py +0 -0
- {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/scrapy/utils.py +0 -0
- {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/solver.py +0 -0
- {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/storage/__init__.py +0 -0
- {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/storage/_file_system/__init__.py +0 -0
- {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/storage/_file_system/_key_value_store_client.py +0 -0
- {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/storage/_file_system/_storage_client.py +0 -0
- {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/storage/_metadata.py +0 -0
- {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/storage/patch_storage.py +0 -0
- {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/storage/scrapely_dataset_client.py +0 -0
- {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/storage/scrapely_kvs_client.py +0 -0
- {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/storage/scrapely_rq_client.py +0 -0
- {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/storage/scrapely_storage_client.py +0 -0
- {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/storage/smart_scrapely_storage_client.py +0 -0
- {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/storage/types.py +0 -0
- {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/storage/utils.py +0 -0
- {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/storage_clients/__init__.py +0 -0
- {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/storages/__init__.py +0 -0
- {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/types.py +0 -0
- {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely/webhook.py +0 -0
- {scrapely_python-0.1.36 → scrapely_python-0.1.40}/src/scrapely_python.egg-info/dependency_links.txt +0 -0
- {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.
|
|
3
|
+
Version: 0.1.40
|
|
4
4
|
Summary: Scrapely Python SDK - A toolkit for building Actors on the Scrapely platform
|
|
5
|
-
Author-email: Scrapely <
|
|
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.
|
|
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 = "
|
|
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'"
|
|
@@ -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
|
-
|
|
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
|
-
#
|
|
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({'
|
|
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()
|