scrapely-python 0.1.35__tar.gz → 0.1.38__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.35 → scrapely_python-0.1.38}/CHANGELOG.md +30 -0
- {scrapely_python-0.1.35 → scrapely_python-0.1.38}/PKG-INFO +20 -2
- {scrapely_python-0.1.35 → scrapely_python-0.1.38}/README.md +17 -0
- {scrapely_python-0.1.35 → scrapely_python-0.1.38}/pyproject.toml +7 -4
- {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/__init__.py +1 -1
- scrapely_python-0.1.38/src/scrapely/http/__init__.py +57 -0
- scrapely_python-0.1.38/src/scrapely/http/_capture.py +97 -0
- scrapely_python-0.1.38/src/scrapely/http/_client.py +1123 -0
- scrapely_python-0.1.38/src/scrapely/http/_compat.py +92 -0
- scrapely_python-0.1.38/src/scrapely/http/_detect.py +138 -0
- scrapely_python-0.1.38/src/scrapely/http/_headers.py +322 -0
- scrapely_python-0.1.38/src/scrapely/http/_options.py +169 -0
- scrapely_python-0.1.38/src/scrapely/http/_policy.py +233 -0
- scrapely_python-0.1.38/src/scrapely/http/_profiles.py +245 -0
- scrapely_python-0.1.38/src/scrapely/http/_response.py +157 -0
- scrapely_python-0.1.38/src/scrapely/http/_stats.py +163 -0
- scrapely_python-0.1.38/src/scrapely/http/_transport.py +343 -0
- scrapely_python-0.1.38/src/scrapely/http/profiles.json +600 -0
- {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/storage/api_client.py +8 -6
- {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely_python.egg-info/PKG-INFO +20 -2
- {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely_python.egg-info/SOURCES.txt +13 -0
- {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely_python.egg-info/requires.txt +3 -0
- {scrapely_python-0.1.35 → scrapely_python-0.1.38}/LICENSE +0 -0
- {scrapely_python-0.1.35 → scrapely_python-0.1.38}/LICENSE-APACHE +0 -0
- {scrapely_python-0.1.35 → scrapely_python-0.1.38}/MANIFEST.in +0 -0
- {scrapely_python-0.1.35 → scrapely_python-0.1.38}/NOTICE +0 -0
- {scrapely_python-0.1.35 → scrapely_python-0.1.38}/setup.cfg +0 -0
- {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/_browser_locations.py +0 -0
- {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/actor.py +0 -0
- {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/browser_configuration.py +0 -0
- {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/cdp_retry.py +0 -0
- {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/charging.py +0 -0
- {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/charging_client.py +0 -0
- {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/configuration.py +0 -0
- {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/crypto/__init__.py +0 -0
- {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/crypto/_secrets.py +0 -0
- {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/errors.py +0 -0
- {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/events/__init__.py +0 -0
- {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/events/_types.py +0 -0
- {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/events/event_manager.py +0 -0
- {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/log.py +0 -0
- {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/patch_browser_pool.py +0 -0
- {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/pricing_types.py +0 -0
- {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/proxy_configuration.py +0 -0
- {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/py.typed +0 -0
- {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/request_loaders/__init__.py +0 -0
- {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/request_loaders/_scrapely_request_list.py +0 -0
- {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/scrapy/__init__.py +0 -0
- {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/scrapy/_actor_runner.py +0 -0
- {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/scrapy/_async_thread.py +0 -0
- {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/scrapy/_logging_config.py +0 -0
- {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/scrapy/_serialization.py +0 -0
- {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/scrapy/_warnings.py +0 -0
- {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/scrapy/extensions/__init__.py +0 -0
- {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/scrapy/extensions/_graceful_stop.py +0 -0
- {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/scrapy/extensions/_httpcache.py +0 -0
- {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/scrapy/middlewares/__init__.py +0 -0
- {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/scrapy/middlewares/apify_proxy.py +0 -0
- {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/scrapy/pipelines/__init__.py +0 -0
- {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/scrapy/pipelines/actor_dataset_push.py +0 -0
- {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/scrapy/requests.py +0 -0
- {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/scrapy/scheduler.py +0 -0
- {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/scrapy/utils.py +0 -0
- {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/solver.py +0 -0
- {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/storage/__init__.py +0 -0
- {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/storage/_file_system/__init__.py +0 -0
- {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/storage/_file_system/_key_value_store_client.py +0 -0
- {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/storage/_file_system/_storage_client.py +0 -0
- {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/storage/_metadata.py +0 -0
- {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/storage/client.py +0 -0
- {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/storage/patch_storage.py +0 -0
- {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/storage/scrapely_dataset_client.py +0 -0
- {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/storage/scrapely_kvs_client.py +0 -0
- {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/storage/scrapely_rq_client.py +0 -0
- {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/storage/scrapely_storage_client.py +0 -0
- {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/storage/smart_scrapely_storage_client.py +0 -0
- {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/storage/types.py +0 -0
- {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/storage/utils.py +0 -0
- {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/storage_clients/__init__.py +0 -0
- {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/storages/__init__.py +0 -0
- {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/types.py +0 -0
- {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/webhook.py +0 -0
- {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely_python.egg-info/dependency_links.txt +0 -0
- {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely_python.egg-info/top_level.txt +0 -0
|
@@ -2,6 +2,36 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [Unreleased]
|
|
6
|
+
|
|
7
|
+
## [0.1.38] - 2026-10-27
|
|
8
|
+
|
|
9
|
+
0.1.37 was never published to PyPI. This release includes everything listed under 0.1.37 below, plus these changes to `scrapely.http`:
|
|
10
|
+
|
|
11
|
+
- **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.
|
|
12
|
+
- **No waiting.** By default the client no longer waits: `Retry-After` is ignored, and there is no per-host backoff or slow mode.
|
|
13
|
+
- **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.
|
|
14
|
+
- **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.
|
|
15
|
+
- **Stats.** `stats()`, the summary line and `HTTP_STATS` count rotations: in total, per reason or vendor, and per host.
|
|
16
|
+
|
|
17
|
+
## [0.1.37] - 2026-10-27 (not published)
|
|
18
|
+
|
|
19
|
+
- Added `scrapely.http`, an HTTP client that looks like a real browser and handles blocks (`AsyncClient`, sync `Client`, `session()` for multi-step flows).
|
|
20
|
+
- **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.
|
|
21
|
+
- **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.
|
|
22
|
+
- **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.
|
|
23
|
+
- **Retries.** Blocked requests are retried on a fresh identity. (0.1.38 changed the waits; see above.)
|
|
24
|
+
- **Accept-Language.** It follows the proxy country, unless you set `locales`.
|
|
25
|
+
- **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.
|
|
26
|
+
- **Options.** `fingerprint_options` takes snake_case keys, and also the camelCase `fingerprintOptions.fingerprintGeneratorOptions` form.
|
|
27
|
+
- **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`.
|
|
28
|
+
- **curl_cffi migration.** `AsyncSession` / `Session` accept `impersonate=`, `proxy=`, `proxies=`, `timeout=`, `headers=` and `verify=`, so switching from curl_cffi takes one line.
|
|
29
|
+
- 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.
|
|
30
|
+
|
|
31
|
+
## [0.1.36] - 2026-10-26
|
|
32
|
+
|
|
33
|
+
- 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.
|
|
34
|
+
|
|
5
35
|
## [0.1.35] - 2026-10-26
|
|
6
36
|
|
|
7
37
|
- 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.
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: scrapely-python
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.38
|
|
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.38"
|
|
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'"
|
|
@@ -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()
|