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.
Files changed (84) hide show
  1. {scrapely_python-0.1.35 → scrapely_python-0.1.38}/CHANGELOG.md +30 -0
  2. {scrapely_python-0.1.35 → scrapely_python-0.1.38}/PKG-INFO +20 -2
  3. {scrapely_python-0.1.35 → scrapely_python-0.1.38}/README.md +17 -0
  4. {scrapely_python-0.1.35 → scrapely_python-0.1.38}/pyproject.toml +7 -4
  5. {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/__init__.py +1 -1
  6. scrapely_python-0.1.38/src/scrapely/http/__init__.py +57 -0
  7. scrapely_python-0.1.38/src/scrapely/http/_capture.py +97 -0
  8. scrapely_python-0.1.38/src/scrapely/http/_client.py +1123 -0
  9. scrapely_python-0.1.38/src/scrapely/http/_compat.py +92 -0
  10. scrapely_python-0.1.38/src/scrapely/http/_detect.py +138 -0
  11. scrapely_python-0.1.38/src/scrapely/http/_headers.py +322 -0
  12. scrapely_python-0.1.38/src/scrapely/http/_options.py +169 -0
  13. scrapely_python-0.1.38/src/scrapely/http/_policy.py +233 -0
  14. scrapely_python-0.1.38/src/scrapely/http/_profiles.py +245 -0
  15. scrapely_python-0.1.38/src/scrapely/http/_response.py +157 -0
  16. scrapely_python-0.1.38/src/scrapely/http/_stats.py +163 -0
  17. scrapely_python-0.1.38/src/scrapely/http/_transport.py +343 -0
  18. scrapely_python-0.1.38/src/scrapely/http/profiles.json +600 -0
  19. {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/storage/api_client.py +8 -6
  20. {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely_python.egg-info/PKG-INFO +20 -2
  21. {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely_python.egg-info/SOURCES.txt +13 -0
  22. {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely_python.egg-info/requires.txt +3 -0
  23. {scrapely_python-0.1.35 → scrapely_python-0.1.38}/LICENSE +0 -0
  24. {scrapely_python-0.1.35 → scrapely_python-0.1.38}/LICENSE-APACHE +0 -0
  25. {scrapely_python-0.1.35 → scrapely_python-0.1.38}/MANIFEST.in +0 -0
  26. {scrapely_python-0.1.35 → scrapely_python-0.1.38}/NOTICE +0 -0
  27. {scrapely_python-0.1.35 → scrapely_python-0.1.38}/setup.cfg +0 -0
  28. {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/_browser_locations.py +0 -0
  29. {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/actor.py +0 -0
  30. {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/browser_configuration.py +0 -0
  31. {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/cdp_retry.py +0 -0
  32. {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/charging.py +0 -0
  33. {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/charging_client.py +0 -0
  34. {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/configuration.py +0 -0
  35. {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/crypto/__init__.py +0 -0
  36. {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/crypto/_secrets.py +0 -0
  37. {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/errors.py +0 -0
  38. {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/events/__init__.py +0 -0
  39. {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/events/_types.py +0 -0
  40. {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/events/event_manager.py +0 -0
  41. {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/log.py +0 -0
  42. {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/patch_browser_pool.py +0 -0
  43. {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/pricing_types.py +0 -0
  44. {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/proxy_configuration.py +0 -0
  45. {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/py.typed +0 -0
  46. {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/request_loaders/__init__.py +0 -0
  47. {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/request_loaders/_scrapely_request_list.py +0 -0
  48. {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/scrapy/__init__.py +0 -0
  49. {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/scrapy/_actor_runner.py +0 -0
  50. {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/scrapy/_async_thread.py +0 -0
  51. {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/scrapy/_logging_config.py +0 -0
  52. {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/scrapy/_serialization.py +0 -0
  53. {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/scrapy/_warnings.py +0 -0
  54. {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/scrapy/extensions/__init__.py +0 -0
  55. {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/scrapy/extensions/_graceful_stop.py +0 -0
  56. {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/scrapy/extensions/_httpcache.py +0 -0
  57. {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/scrapy/middlewares/__init__.py +0 -0
  58. {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/scrapy/middlewares/apify_proxy.py +0 -0
  59. {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/scrapy/pipelines/__init__.py +0 -0
  60. {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/scrapy/pipelines/actor_dataset_push.py +0 -0
  61. {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/scrapy/requests.py +0 -0
  62. {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/scrapy/scheduler.py +0 -0
  63. {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/scrapy/utils.py +0 -0
  64. {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/solver.py +0 -0
  65. {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/storage/__init__.py +0 -0
  66. {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/storage/_file_system/__init__.py +0 -0
  67. {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/storage/_file_system/_key_value_store_client.py +0 -0
  68. {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/storage/_file_system/_storage_client.py +0 -0
  69. {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/storage/_metadata.py +0 -0
  70. {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/storage/client.py +0 -0
  71. {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/storage/patch_storage.py +0 -0
  72. {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/storage/scrapely_dataset_client.py +0 -0
  73. {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/storage/scrapely_kvs_client.py +0 -0
  74. {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/storage/scrapely_rq_client.py +0 -0
  75. {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/storage/scrapely_storage_client.py +0 -0
  76. {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/storage/smart_scrapely_storage_client.py +0 -0
  77. {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/storage/types.py +0 -0
  78. {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/storage/utils.py +0 -0
  79. {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/storage_clients/__init__.py +0 -0
  80. {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/storages/__init__.py +0 -0
  81. {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/types.py +0 -0
  82. {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely/webhook.py +0 -0
  83. {scrapely_python-0.1.35 → scrapely_python-0.1.38}/src/scrapely_python.egg-info/dependency_links.txt +0 -0
  84. {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.35
3
+ Version: 0.1.38
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.35"
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 = "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.35'
230
+ __version__ = '0.1.38'
231
231
  __all__ = [
232
232
  # Core
233
233
  'Actor',
@@ -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()