scrapely-python 0.1.41__tar.gz → 0.1.43__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.41 → scrapely_python-0.1.43}/CHANGELOG.md +28 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/PKG-INFO +1 -1
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/pyproject.toml +1 -1
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely/__init__.py +182 -84
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely/charging_client.py +21 -5
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely/http/_client.py +71 -15
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely/patch_browser_pool.py +174 -72
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely/solver.py +364 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely_python.egg-info/PKG-INFO +1 -1
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/LICENSE +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/LICENSE-APACHE +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/MANIFEST.in +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/NOTICE +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/README.md +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/setup.cfg +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely/_browser_locations.py +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely/actor.py +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely/browser_configuration.py +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely/cdp_retry.py +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely/charging.py +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely/configuration.py +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely/crypto/__init__.py +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely/crypto/_secrets.py +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely/errors.py +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely/events/__init__.py +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely/events/_types.py +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely/events/event_manager.py +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely/http/__init__.py +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely/http/_capture.py +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely/http/_compat.py +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely/http/_detect.py +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely/http/_headers.py +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely/http/_options.py +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely/http/_policy.py +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely/http/_profiles.py +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely/http/_response.py +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely/http/_stats.py +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely/http/_transport.py +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely/http/profiles.json +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely/log.py +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely/pricing_types.py +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely/proxy_configuration.py +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely/py.typed +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely/request_loaders/__init__.py +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely/request_loaders/_scrapely_request_list.py +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely/scrapy/__init__.py +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely/scrapy/_actor_runner.py +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely/scrapy/_async_thread.py +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely/scrapy/_logging_config.py +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely/scrapy/_serialization.py +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely/scrapy/_warnings.py +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely/scrapy/extensions/__init__.py +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely/scrapy/extensions/_graceful_stop.py +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely/scrapy/extensions/_httpcache.py +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely/scrapy/middlewares/__init__.py +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely/scrapy/middlewares/apify_proxy.py +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely/scrapy/pipelines/__init__.py +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely/scrapy/pipelines/actor_dataset_push.py +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely/scrapy/requests.py +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely/scrapy/scheduler.py +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely/scrapy/utils.py +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely/storage/__init__.py +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely/storage/_file_system/__init__.py +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely/storage/_file_system/_key_value_store_client.py +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely/storage/_file_system/_storage_client.py +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely/storage/_metadata.py +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely/storage/api_client.py +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely/storage/client.py +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely/storage/patch_storage.py +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely/storage/scrapely_dataset_client.py +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely/storage/scrapely_kvs_client.py +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely/storage/scrapely_rq_client.py +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely/storage/scrapely_storage_client.py +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely/storage/smart_scrapely_storage_client.py +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely/storage/types.py +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely/storage/utils.py +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely/storage_clients/__init__.py +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely/storages/__init__.py +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely/types.py +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely/webhook.py +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely_python.egg-info/SOURCES.txt +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely_python.egg-info/dependency_links.txt +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely_python.egg-info/requires.txt +0 -0
- {scrapely_python-0.1.41 → scrapely_python-0.1.43}/src/scrapely_python.egg-info/top_level.txt +0 -0
|
@@ -4,6 +4,34 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.1.43] - 2026-11-05
|
|
8
|
+
|
|
9
|
+
- **Python actor processes start without loading the SDK.** The base images import `scrapely.patch_browser_pool` (from `sitecustomize`) in every Python process. Until now that loaded the whole SDK at each interpreter start: `scrapely.actor`, charging, the `scrapely_client` models and Playwright. On `actor-python:3.11-0.1.42`, `python -c pass` took 0.89-0.95 s of CPU, which is 4.2 s of wall time at a 0.25-core quota. It now takes 0.016 s. Each import below gets the same start-up cut:
|
|
10
|
+
- `import scrapely` is lazy (PEP 562). Every public name and submodule attribute resolves on first use, so every `from scrapely import X` and `scrapely.X` that worked before still works.
|
|
11
|
+
- `scrapely_client` loads only when an API client is created (`Actor.apify_client`, `create_charging_client`). `USES_PUSH_DATA_INTERCEPTION` and `push_data_charging_context` are still the client's own objects.
|
|
12
|
+
- **Browser patches are applied through an import hook.** `scrapely.patch_browser_pool` imports no browser library any more; it imports builtin modules only. Each patch now runs the moment its library finishes loading, before the import that loaded it returns. That covers the Crawlee BrowserPool / PlaywrightBrowserPlugin / PlaywrightCrawler, raw Playwright (`async_playwright` and `sync_playwright`, each when its module loads), Camoufox `AsyncNewBrowser` and pyppeteer `launch`. The patches behave exactly as before. The hook also:
|
|
13
|
+
- survives `importlib.util.find_spec()` availability checks and a failed first import;
|
|
14
|
+
- logs a warning when a patch fails instead of breaking the actor's import.
|
|
15
|
+
- **Start-up measured on staging** (fresh builds of the same commit on the same base, old SDK vs 0.1.43; "boot" = container started to the SDK's "Initializing API storage" line; paired median): web-scraper -0.74 s, capterra-scraper (raw Playwright over the remote browser) -0.89 s, contact-info-scraper (512 MB) -0.51 s. All 88 scenario runs passed on both sides. Locally at 1 CPU, a full `from apify import Actor` + `Actor.init` + `push_data` + exit went from 1.19-1.36 s to 0.92-1.02 s of CPU.
|
|
16
|
+
- Base images (released with this version): `sitecustomize` no longer imports `pathlib` (~70 ms per start), and the `apify` shim resolves names on first use instead of `from scrapely import *`.
|
|
17
|
+
|
|
18
|
+
## [0.1.42] - 2026-11-02
|
|
19
|
+
|
|
20
|
+
- **Sessions that keep their identity: `http.session(keep_identity=True)`.** A pinned `scrapely.http` session used to retire its identity on the first block (a 403, a challenge page, a network error). The session then moved to a new exit, a new profile and an empty cookie jar. That breaks every challenge solve: the challenge page, the solver's script and submission requests and the page fetched again must all share one identity. With `keep_identity=True`:
|
|
21
|
+
- a block comes back to the caller (`response.blocked` names it) and an error is raised, with no rotation;
|
|
22
|
+
- the next request goes out on the same profile, exit and jar, with the cookies the block set;
|
|
23
|
+
- `max_usage` / `max_age` do not retire it;
|
|
24
|
+
- `session.rotate()` moves the session to a fresh identity when you decide to.
|
|
25
|
+
|
|
26
|
+
The default (`keep_identity=False`) is unchanged. The option is available on `AsyncClient.session()` and `Client.session()`.
|
|
27
|
+
- **`scrapely.solver.AkamaiSensor`: Akamai Bot Manager on your own session, the way the solver provider's examples do it.**
|
|
28
|
+
- `solve_sbsd(session, url, html)` handles a page that loads an SBSD bundle. It fetches the bundle on your session, gets the ledger from the solver (`/akamai/sbsd/generate-session`), then POSTs the ledger rows to the bundle path on your session.
|
|
29
|
+
- `solve_abck(session, url, html)` runs the `_abck` sensor over the solver's WebSocket session (`/akamai/session`, which the platform relays for runs). The solver generates each sensor; it is POSTed on your session with your client's own headers, and rounds continue until `_abck` is accepted (`~0~`).
|
|
30
|
+
- `solve()` does SBSD when the page carries a bundle, then `_abck`.
|
|
31
|
+
|
|
32
|
+
Use it with a `keep_identity=True` session (or one curl_cffi or httpx client), and set `profile_id` to the browser your client claims, e.g. `chrome-154-macos` for scrapely.http Chrome 154 on macOS. Submissions outside the target site are refused, and a refused session raises the platform's code (`VendorUnavailable`, `SolverRateLimited`, ...). Also new: `find_sensor_script()`, `find_sbsd_bundle()`, `abck_state()`, `SensorResult`.
|
|
33
|
+
- **`AkamaiSensor.solve_pixel(session, url, html)`: Akamai's third channel, the pixel collector.** A page that loads `/akam/<n>/<hex>` (often next to an inline `bazadebezolkohpepadr=` value) expects that script to POST to `/akam/<n>/pixel_<hex>`. The collector is fetched on your session, the solver writes the POST the collector would send (`/akamai/pixel/generate`), and it is POSTed to the collector's own endpoint on your session. Also new: `find_pixel_collector()`, `pixel_target()`.
|
|
34
|
+
|
|
7
35
|
## [0.1.41] - 2026-10-29
|
|
8
36
|
|
|
9
37
|
- **A refused charge names the limit that refused it.** When a charge is refused or cut short, the platform now says why (HTTP 402 `error.reason`, and `limitedBy` on a partial acceptance): the run's item limit (`maxItems`), its maximum total charge (`maxTotalChargeUsd`) or the account balance. The SDK used to call all of them the spending limit; a run stopped by its item limit logged "The spending limit of this run (maxTotalChargeUsd = $3.0) has been reached". Now:
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "scrapely-python"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.43"
|
|
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"
|
|
@@ -10,95 +10,114 @@ Usage:
|
|
|
10
10
|
await Actor.push_data({'url': actor_input['url']})
|
|
11
11
|
"""
|
|
12
12
|
|
|
13
|
-
from
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
# ``typing`` is not imported at run time (see below): this package loads at
|
|
16
|
+
# every interpreter start in actor images.
|
|
17
|
+
TYPE_CHECKING = False
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from typing import Any
|
|
14
20
|
|
|
15
21
|
# Side-effect: patch browser launches (Crawlee BrowserPool, PlaywrightCrawler,
|
|
16
22
|
# raw Playwright, Camoufox, pyppeteer) to use the remote CDP browser (CDP_URL).
|
|
17
23
|
# Must run on package import so `from scrapely import Actor` enables the patch
|
|
18
|
-
# before any browser is launched (Node SDK parity). It
|
|
19
|
-
#
|
|
24
|
+
# before any browser is launched (Node SDK parity). It imports none of those
|
|
25
|
+
# libraries: each patch is applied the moment its library finishes loading.
|
|
20
26
|
from .patch_browser_pool import set_cdp_url # noqa: F401
|
|
21
27
|
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
26
|
-
#
|
|
27
|
-
from
|
|
28
|
-
from .configuration import Configuration
|
|
29
|
-
from .charging_client import (
|
|
30
|
-
USES_PUSH_DATA_INTERCEPTION,
|
|
31
|
-
create_charging_client,
|
|
32
|
-
push_data_charging_context,
|
|
33
|
-
)
|
|
34
|
-
from .proxy_configuration import ProxyConfiguration
|
|
35
|
-
from .browser_configuration import BrowserConfiguration
|
|
36
|
-
from .charging import (
|
|
37
|
-
ChargingManager,
|
|
38
|
-
ChargeUncertainError,
|
|
39
|
-
ChargeRejectedError,
|
|
40
|
-
has_default_dataset_item_event_in_pricing,
|
|
41
|
-
is_default_dataset_item_event,
|
|
42
|
-
merge_charge_results,
|
|
43
|
-
push_data_and_charge,
|
|
44
|
-
resolve_default_dataset_item_event_name,
|
|
45
|
-
APIFY_DEFAULT_DATASET_ITEM_EVENT,
|
|
46
|
-
SCRAPELY_DEFAULT_DATASET_ITEM_EVENT,
|
|
47
|
-
DEFAULT_DATASET_ITEM_EVENT,
|
|
48
|
-
)
|
|
49
|
-
|
|
50
|
-
from .types import (
|
|
51
|
-
ActorInput,
|
|
52
|
-
ActorPermissionLevel,
|
|
53
|
-
ActorRun,
|
|
54
|
-
ApifyEnv,
|
|
55
|
-
AbortOptions,
|
|
56
|
-
CallOptions,
|
|
57
|
-
ExitOptions,
|
|
58
|
-
InitOptions,
|
|
59
|
-
MetamorphOptions,
|
|
60
|
-
OpenStorageOptions,
|
|
61
|
-
ProxyInfo,
|
|
62
|
-
RebootOptions,
|
|
63
|
-
RecordOptions,
|
|
64
|
-
SetStatusMessageOptions,
|
|
65
|
-
StartOptions,
|
|
66
|
-
WebhookOptions,
|
|
67
|
-
)
|
|
28
|
+
# Nothing else is imported here. The base images' sitecustomize imports
|
|
29
|
+
# ``scrapely.patch_browser_pool`` (and so this package) in EVERY Python process
|
|
30
|
+
# of an actor image; loading the whole SDK there cost ~0.75 s per interpreter
|
|
31
|
+
# start at 1 CPU (5.7 s at 0.25 CPU). Every public name is resolved on first
|
|
32
|
+
# attribute access by ``__getattr__`` below and cached in the module namespace,
|
|
33
|
+
# so every ``from scrapely import X`` that worked before still works.
|
|
68
34
|
|
|
69
|
-
|
|
70
|
-
Webhook,
|
|
71
|
-
WebhookEventType,
|
|
72
|
-
)
|
|
73
|
-
from .pricing_types import (
|
|
74
|
-
PricingModel,
|
|
75
|
-
FreeActorPricingInfo,
|
|
76
|
-
FlatPricePerMonthActorPricingInfo,
|
|
77
|
-
PricePerDatasetItemActorPricingInfo,
|
|
78
|
-
PayPerEventActorPricingInfo,
|
|
79
|
-
PricingPerEvent,
|
|
80
|
-
ActorPricingInfo,
|
|
81
|
-
ActorChargeEvent,
|
|
82
|
-
ChargeResult,
|
|
83
|
-
ActorPricingInfoResult,
|
|
84
|
-
)
|
|
85
|
-
from .errors import (
|
|
86
|
-
ScrapelyClientError,
|
|
87
|
-
ScrapelyApiError,
|
|
88
|
-
InvalidResponseBodyError,
|
|
89
|
-
InvalidRequestError,
|
|
90
|
-
UnauthorizedError,
|
|
91
|
-
ForbiddenError,
|
|
92
|
-
NotFoundError,
|
|
93
|
-
ConflictError,
|
|
94
|
-
RateLimitError,
|
|
95
|
-
ServerError,
|
|
96
|
-
raise_for_status,
|
|
97
|
-
)
|
|
98
|
-
|
|
99
|
-
# Public name -> (module, attribute) for names that need Crawlee. Loaded on
|
|
100
|
-
# first access and cached in the module namespace.
|
|
35
|
+
# Public name -> (module, attribute). Loaded on first access.
|
|
101
36
|
_LAZY_ATTRS = {
|
|
37
|
+
'Actor': ('scrapely.actor', 'Actor'),
|
|
38
|
+
'Configuration': ('scrapely.configuration', 'Configuration'),
|
|
39
|
+
'ProxyConfiguration': ('scrapely.proxy_configuration', 'ProxyConfiguration'),
|
|
40
|
+
'BrowserConfiguration': ('scrapely.browser_configuration', 'BrowserConfiguration'),
|
|
41
|
+
# Charging client
|
|
42
|
+
**{
|
|
43
|
+
name: ('scrapely.charging_client', name)
|
|
44
|
+
for name in ('USES_PUSH_DATA_INTERCEPTION', 'create_charging_client', 'push_data_charging_context')
|
|
45
|
+
},
|
|
46
|
+
# Charging
|
|
47
|
+
**{
|
|
48
|
+
name: ('scrapely.charging', name)
|
|
49
|
+
for name in (
|
|
50
|
+
'ChargingManager',
|
|
51
|
+
'ChargeUncertainError',
|
|
52
|
+
'ChargeRejectedError',
|
|
53
|
+
'has_default_dataset_item_event_in_pricing',
|
|
54
|
+
'is_default_dataset_item_event',
|
|
55
|
+
'merge_charge_results',
|
|
56
|
+
'push_data_and_charge',
|
|
57
|
+
'resolve_default_dataset_item_event_name',
|
|
58
|
+
'APIFY_DEFAULT_DATASET_ITEM_EVENT',
|
|
59
|
+
'SCRAPELY_DEFAULT_DATASET_ITEM_EVENT',
|
|
60
|
+
'DEFAULT_DATASET_ITEM_EVENT',
|
|
61
|
+
)
|
|
62
|
+
},
|
|
63
|
+
# Types
|
|
64
|
+
**{
|
|
65
|
+
name: ('scrapely.types', name)
|
|
66
|
+
for name in (
|
|
67
|
+
'ActorInput',
|
|
68
|
+
'ActorPermissionLevel',
|
|
69
|
+
'ActorRun',
|
|
70
|
+
'ApifyEnv',
|
|
71
|
+
'AbortOptions',
|
|
72
|
+
'CallOptions',
|
|
73
|
+
'ExitOptions',
|
|
74
|
+
'InitOptions',
|
|
75
|
+
'MetamorphOptions',
|
|
76
|
+
'OpenStorageOptions',
|
|
77
|
+
'ProxyInfo',
|
|
78
|
+
'RebootOptions',
|
|
79
|
+
'RecordOptions',
|
|
80
|
+
'SetStatusMessageOptions',
|
|
81
|
+
'StartOptions',
|
|
82
|
+
'WebhookOptions',
|
|
83
|
+
)
|
|
84
|
+
},
|
|
85
|
+
# Webhook
|
|
86
|
+
'Webhook': ('scrapely.webhook', 'Webhook'),
|
|
87
|
+
'WebhookEventType': ('scrapely.webhook', 'WebhookEventType'),
|
|
88
|
+
# Pricing
|
|
89
|
+
**{
|
|
90
|
+
name: ('scrapely.pricing_types', name)
|
|
91
|
+
for name in (
|
|
92
|
+
'PricingModel',
|
|
93
|
+
'FreeActorPricingInfo',
|
|
94
|
+
'FlatPricePerMonthActorPricingInfo',
|
|
95
|
+
'PricePerDatasetItemActorPricingInfo',
|
|
96
|
+
'PayPerEventActorPricingInfo',
|
|
97
|
+
'PricingPerEvent',
|
|
98
|
+
'ActorPricingInfo',
|
|
99
|
+
'ActorChargeEvent',
|
|
100
|
+
'ChargeResult',
|
|
101
|
+
'ActorPricingInfoResult',
|
|
102
|
+
)
|
|
103
|
+
},
|
|
104
|
+
# Errors
|
|
105
|
+
**{
|
|
106
|
+
name: ('scrapely.errors', name)
|
|
107
|
+
for name in (
|
|
108
|
+
'ScrapelyClientError',
|
|
109
|
+
'ScrapelyApiError',
|
|
110
|
+
'InvalidResponseBodyError',
|
|
111
|
+
'InvalidRequestError',
|
|
112
|
+
'UnauthorizedError',
|
|
113
|
+
'ForbiddenError',
|
|
114
|
+
'NotFoundError',
|
|
115
|
+
'ConflictError',
|
|
116
|
+
'RateLimitError',
|
|
117
|
+
'ServerError',
|
|
118
|
+
'raise_for_status',
|
|
119
|
+
)
|
|
120
|
+
},
|
|
102
121
|
# Crawlee re-exports
|
|
103
122
|
'Request': ('crawlee', 'Request'),
|
|
104
123
|
'RequestLoader': ('crawlee.request_loaders', 'RequestLoader'),
|
|
@@ -150,15 +169,28 @@ _LAZY_ATTRS = {
|
|
|
150
169
|
}
|
|
151
170
|
|
|
152
171
|
# Submodules that used to be loaded (and so bound as attributes) by this
|
|
153
|
-
# package's eager imports; ``scrapely.events`` etc. keep
|
|
154
|
-
# explicit ``import scrapely
|
|
172
|
+
# package's eager imports; ``scrapely.actor``, ``scrapely.events`` etc. keep
|
|
173
|
+
# working without an explicit ``import scrapely.<name>``.
|
|
155
174
|
_LAZY_SUBMODULES = frozenset({
|
|
175
|
+
'_browser_locations',
|
|
176
|
+
'actor',
|
|
177
|
+
'browser_configuration',
|
|
178
|
+
'cdp_retry',
|
|
179
|
+
'charging',
|
|
180
|
+
'charging_client',
|
|
181
|
+
'configuration',
|
|
182
|
+
'crypto',
|
|
183
|
+
'errors',
|
|
156
184
|
'events',
|
|
157
185
|
'log',
|
|
186
|
+
'pricing_types',
|
|
187
|
+
'proxy_configuration',
|
|
158
188
|
'request_loaders',
|
|
159
189
|
'storage',
|
|
160
190
|
'storage_clients',
|
|
161
191
|
'storages',
|
|
192
|
+
'types',
|
|
193
|
+
'webhook',
|
|
162
194
|
})
|
|
163
195
|
|
|
164
196
|
|
|
@@ -193,6 +225,40 @@ if TYPE_CHECKING: # Static analysers and IDEs see the lazy names as plain impor
|
|
|
193
225
|
from crawlee.storage_clients.models import DatasetItemsListPage
|
|
194
226
|
from crawlee.storages import Dataset, KeyValueStore, RequestQueue
|
|
195
227
|
|
|
228
|
+
from .actor import Actor
|
|
229
|
+
from .browser_configuration import BrowserConfiguration
|
|
230
|
+
from .charging import (
|
|
231
|
+
APIFY_DEFAULT_DATASET_ITEM_EVENT,
|
|
232
|
+
DEFAULT_DATASET_ITEM_EVENT,
|
|
233
|
+
SCRAPELY_DEFAULT_DATASET_ITEM_EVENT,
|
|
234
|
+
ChargeRejectedError,
|
|
235
|
+
ChargeUncertainError,
|
|
236
|
+
ChargingManager,
|
|
237
|
+
has_default_dataset_item_event_in_pricing,
|
|
238
|
+
is_default_dataset_item_event,
|
|
239
|
+
merge_charge_results,
|
|
240
|
+
push_data_and_charge,
|
|
241
|
+
resolve_default_dataset_item_event_name,
|
|
242
|
+
)
|
|
243
|
+
from .charging_client import (
|
|
244
|
+
USES_PUSH_DATA_INTERCEPTION,
|
|
245
|
+
create_charging_client,
|
|
246
|
+
push_data_charging_context,
|
|
247
|
+
)
|
|
248
|
+
from .configuration import Configuration
|
|
249
|
+
from .errors import (
|
|
250
|
+
ConflictError,
|
|
251
|
+
ForbiddenError,
|
|
252
|
+
InvalidRequestError,
|
|
253
|
+
InvalidResponseBodyError,
|
|
254
|
+
NotFoundError,
|
|
255
|
+
RateLimitError,
|
|
256
|
+
ScrapelyApiError,
|
|
257
|
+
ScrapelyClientError,
|
|
258
|
+
ServerError,
|
|
259
|
+
UnauthorizedError,
|
|
260
|
+
raise_for_status,
|
|
261
|
+
)
|
|
196
262
|
from .events import (
|
|
197
263
|
AbortingEvent,
|
|
198
264
|
ActorEventTypes,
|
|
@@ -216,6 +282,19 @@ if TYPE_CHECKING: # Static analysers and IDEs see the lazy names as plain impor
|
|
|
216
282
|
SystemInfoEventData,
|
|
217
283
|
UnknownEvent,
|
|
218
284
|
)
|
|
285
|
+
from .pricing_types import (
|
|
286
|
+
ActorChargeEvent,
|
|
287
|
+
ActorPricingInfo,
|
|
288
|
+
ActorPricingInfoResult,
|
|
289
|
+
ChargeResult,
|
|
290
|
+
FlatPricePerMonthActorPricingInfo,
|
|
291
|
+
FreeActorPricingInfo,
|
|
292
|
+
PayPerEventActorPricingInfo,
|
|
293
|
+
PricePerDatasetItemActorPricingInfo,
|
|
294
|
+
PricingModel,
|
|
295
|
+
PricingPerEvent,
|
|
296
|
+
)
|
|
297
|
+
from .proxy_configuration import ProxyConfiguration
|
|
219
298
|
from .request_loaders import ScrapelyRequestList
|
|
220
299
|
from .storage import (
|
|
221
300
|
ApiStorageClient,
|
|
@@ -226,8 +305,27 @@ if TYPE_CHECKING: # Static analysers and IDEs see the lazy names as plain impor
|
|
|
226
305
|
StorageClient,
|
|
227
306
|
set_api_storage,
|
|
228
307
|
)
|
|
308
|
+
from .types import (
|
|
309
|
+
AbortOptions,
|
|
310
|
+
ActorInput,
|
|
311
|
+
ActorPermissionLevel,
|
|
312
|
+
ActorRun,
|
|
313
|
+
ApifyEnv,
|
|
314
|
+
CallOptions,
|
|
315
|
+
ExitOptions,
|
|
316
|
+
InitOptions,
|
|
317
|
+
MetamorphOptions,
|
|
318
|
+
OpenStorageOptions,
|
|
319
|
+
ProxyInfo,
|
|
320
|
+
RebootOptions,
|
|
321
|
+
RecordOptions,
|
|
322
|
+
SetStatusMessageOptions,
|
|
323
|
+
StartOptions,
|
|
324
|
+
WebhookOptions,
|
|
325
|
+
)
|
|
326
|
+
from .webhook import Webhook, WebhookEventType
|
|
229
327
|
|
|
230
|
-
__version__ = '0.1.
|
|
328
|
+
__version__ = '0.1.43'
|
|
231
329
|
__all__ = [
|
|
232
330
|
# Core
|
|
233
331
|
'Actor',
|
|
@@ -11,15 +11,29 @@ from __future__ import annotations
|
|
|
11
11
|
|
|
12
12
|
from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Sequence
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from scrapely_client import USES_PUSH_DATA_INTERCEPTION, ApifyClientAsync, push_data_charging_context
|
|
16
|
+
|
|
17
|
+
from .charging import ChargingManager
|
|
15
18
|
|
|
19
|
+
# ``scrapely_client`` (219 pydantic models) loads only when a client is created
|
|
20
|
+
# or one of its names below is used: ``Actor`` imports this module, and most
|
|
21
|
+
# runs never create an API client.
|
|
22
|
+
#
|
|
16
23
|
# Re-exported so the SDK and the client share one marker attribute and one
|
|
17
24
|
# context var. Identity matters: the client's intercepted ``push_items()``
|
|
18
25
|
# reads/writes these, and the SDK detects interception via the attribute.
|
|
19
|
-
|
|
26
|
+
_CLIENT_NAMES = frozenset({'ApifyClientAsync', 'USES_PUSH_DATA_INTERCEPTION', 'push_data_charging_context'})
|
|
20
27
|
|
|
21
|
-
|
|
22
|
-
|
|
28
|
+
|
|
29
|
+
def __getattr__(name: str) -> Any:
|
|
30
|
+
if name in _CLIENT_NAMES:
|
|
31
|
+
import scrapely_client
|
|
32
|
+
|
|
33
|
+
value = getattr(scrapely_client, name)
|
|
34
|
+
globals()[name] = value
|
|
35
|
+
return value
|
|
36
|
+
raise AttributeError(f'module {__name__!r} has no attribute {name!r}')
|
|
23
37
|
|
|
24
38
|
__all__ = [
|
|
25
39
|
'USES_PUSH_DATA_INTERCEPTION',
|
|
@@ -81,7 +95,7 @@ def create_charging_client(
|
|
|
81
95
|
charging_manager: Optional['ChargingManager'] = None,
|
|
82
96
|
default_dataset_id: Optional[str] = None,
|
|
83
97
|
**kwargs: Any,
|
|
84
|
-
) -> ApifyClientAsync:
|
|
98
|
+
) -> 'ApifyClientAsync':
|
|
85
99
|
"""Create an ``ApifyClientAsync`` configured with the SDK's charging hooks.
|
|
86
100
|
|
|
87
101
|
Args:
|
|
@@ -101,6 +115,8 @@ def create_charging_client(
|
|
|
101
115
|
if api_public_url:
|
|
102
116
|
kwargs['api_public_url'] = api_public_url
|
|
103
117
|
|
|
118
|
+
from scrapely_client import ApifyClientAsync
|
|
119
|
+
|
|
104
120
|
charging_hooks = _ChargingManagerHooks(charging_manager) if charging_manager is not None else None
|
|
105
121
|
|
|
106
122
|
return ApifyClientAsync(
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
|
+
import dataclasses
|
|
6
7
|
import logging
|
|
7
8
|
import random
|
|
8
9
|
import threading
|
|
@@ -102,14 +103,30 @@ def _proxy_from_proxies(proxies: Any) -> Optional[str]:
|
|
|
102
103
|
|
|
103
104
|
|
|
104
105
|
class _Pin:
|
|
105
|
-
"""Holder of a pinned identity (a session).
|
|
106
|
+
"""Holder of a pinned identity (a session).
|
|
106
107
|
|
|
107
|
-
|
|
108
|
+
``keep``: the identity is never retired or swapped by the client (no
|
|
109
|
+
rotation on a block or error, no usage/age expiry); only ``rotate()`` or
|
|
110
|
+
closing the session moves it to a new one.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
__slots__ = ('identity', 'alock', 'tlock', 'keep')
|
|
108
114
|
|
|
109
|
-
def __init__(self) -> None:
|
|
115
|
+
def __init__(self, keep: bool = False) -> None:
|
|
110
116
|
self.identity: Optional[Identity] = None
|
|
111
117
|
self.alock: Optional[asyncio.Lock] = None
|
|
112
118
|
self.tlock = threading.Lock()
|
|
119
|
+
self.keep = keep
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _no_rotation(decision: Any) -> Any:
|
|
123
|
+
"""The policy's decision with the rotation taken out (kept identities): stop here, retire nothing."""
|
|
124
|
+
try:
|
|
125
|
+
return dataclasses.replace(decision, retry=False, retire=False, delay=0.0)
|
|
126
|
+
except TypeError:
|
|
127
|
+
decision.retry = False
|
|
128
|
+
decision.retire = False
|
|
129
|
+
return decision
|
|
113
130
|
|
|
114
131
|
|
|
115
132
|
class CookieStore:
|
|
@@ -522,7 +539,7 @@ class AsyncClient(_Base):
|
|
|
522
539
|
pin.alock = asyncio.Lock()
|
|
523
540
|
async with pin.alock:
|
|
524
541
|
ident = pin.identity
|
|
525
|
-
if ident is not None and ident.expired(self._clock(), self.max_usage, self.max_age):
|
|
542
|
+
if ident is not None and not pin.keep and ident.expired(self._clock(), self.max_usage, self.max_age):
|
|
526
543
|
pin.identity = None
|
|
527
544
|
await self._retire(ident)
|
|
528
545
|
ident = None
|
|
@@ -558,6 +575,8 @@ class AsyncClient(_Base):
|
|
|
558
575
|
async def _release(self, pin: Optional[_Pin], ident: Identity, retire: bool) -> None:
|
|
559
576
|
ident.in_flight -= 1
|
|
560
577
|
if pin is not None:
|
|
578
|
+
if pin.keep and pin.identity is ident:
|
|
579
|
+
return
|
|
561
580
|
if retire or not self.sticky:
|
|
562
581
|
if pin.identity is ident:
|
|
563
582
|
pin.identity = None
|
|
@@ -658,12 +677,16 @@ class AsyncClient(_Base):
|
|
|
658
677
|
# Cancelled or failed in our own code: nothing says the identity is burnt.
|
|
659
678
|
await self._release(_pin, ident, retire=False)
|
|
660
679
|
raise
|
|
680
|
+
if decision.retry and not decision.same_identity and _pin is not None and _pin.keep:
|
|
681
|
+
# A kept identity is never swapped: the block or error goes back to the caller as is.
|
|
682
|
+
decision = _no_rotation(decision)
|
|
661
683
|
if not decision.retry:
|
|
662
684
|
await self._release(_pin, ident, retire=decision.retire)
|
|
663
685
|
ok = resp is not None and reason is None and resp.status_code < 500
|
|
664
686
|
self._stats.record_request(ok)
|
|
665
687
|
if resp is None:
|
|
666
|
-
|
|
688
|
+
if error is None:
|
|
689
|
+
raise RuntimeError('scrapely.http: request ended with no response and no error')
|
|
667
690
|
raise error
|
|
668
691
|
resp.blocked = reason
|
|
669
692
|
return resp
|
|
@@ -703,14 +726,21 @@ class AsyncClient(_Base):
|
|
|
703
726
|
async def options(self, url: str, **kwargs: Any) -> Response:
|
|
704
727
|
return await self.request('OPTIONS', url, **kwargs)
|
|
705
728
|
|
|
706
|
-
def session(self) -> 'AsyncHttpSession':
|
|
729
|
+
def session(self, keep_identity: bool = False) -> 'AsyncHttpSession':
|
|
707
730
|
"""A pinned identity for multi-step flows (homepage → API with its cookies).
|
|
708
731
|
|
|
709
732
|
All requests of the session use one profile, proxy session and cookie
|
|
710
733
|
jar. On a block the whole identity is retired and the session continues
|
|
711
734
|
on a fresh one (new IP, new profile, empty jar).
|
|
735
|
+
|
|
736
|
+
``keep_identity=True`` keeps the identity and its jar on a block or an
|
|
737
|
+
error too: the blocked response comes back (``response.blocked`` names
|
|
738
|
+
the block) and the next request goes out on the same profile, exit and
|
|
739
|
+
cookies. That is what a challenge solve needs (the SBSD bundle, the
|
|
740
|
+
sensor submissions and the page fetches must share one identity).
|
|
741
|
+
``rotate()`` moves the session to a fresh identity when you decide to.
|
|
712
742
|
"""
|
|
713
|
-
return AsyncHttpSession(self)
|
|
743
|
+
return AsyncHttpSession(self, keep_identity=keep_identity)
|
|
714
744
|
|
|
715
745
|
async def aclose(self) -> None:
|
|
716
746
|
if self._closed:
|
|
@@ -733,11 +763,21 @@ class AsyncClient(_Base):
|
|
|
733
763
|
class AsyncHttpSession:
|
|
734
764
|
"""A pinned identity on an ``AsyncClient`` (see ``AsyncClient.session()``)."""
|
|
735
765
|
|
|
736
|
-
def __init__(self, client: AsyncClient) -> None:
|
|
766
|
+
def __init__(self, client: AsyncClient, keep_identity: bool = False) -> None:
|
|
737
767
|
self._client = client
|
|
738
|
-
self._pin = _Pin()
|
|
768
|
+
self._pin = _Pin(keep=keep_identity)
|
|
739
769
|
self.cookies = _SessionCookies(self)
|
|
740
770
|
|
|
771
|
+
@property
|
|
772
|
+
def keep_identity(self) -> bool:
|
|
773
|
+
return self._pin.keep
|
|
774
|
+
|
|
775
|
+
async def rotate(self) -> None:
|
|
776
|
+
"""Retire the current identity; the next request opens a fresh one (new exit, profile and jar)."""
|
|
777
|
+
if self._pin.identity is not None:
|
|
778
|
+
ident, self._pin.identity = self._pin.identity, None
|
|
779
|
+
await self._client._retire(ident, force=ident.in_flight <= 0)
|
|
780
|
+
|
|
741
781
|
@property
|
|
742
782
|
def profile(self) -> Optional[str]:
|
|
743
783
|
return self._pin.identity.profile.label if self._pin.identity else None
|
|
@@ -873,7 +913,7 @@ class Client(_Base):
|
|
|
873
913
|
if pin is not None:
|
|
874
914
|
with pin.tlock:
|
|
875
915
|
ident = pin.identity
|
|
876
|
-
if ident is not None and ident.expired(self._clock(), self.max_usage, self.max_age):
|
|
916
|
+
if ident is not None and not pin.keep and ident.expired(self._clock(), self.max_usage, self.max_age):
|
|
877
917
|
pin.identity = None
|
|
878
918
|
self._retire(ident)
|
|
879
919
|
ident = None
|
|
@@ -918,6 +958,8 @@ class Client(_Base):
|
|
|
918
958
|
with self._lock:
|
|
919
959
|
ident.in_flight -= 1
|
|
920
960
|
if pin is not None:
|
|
961
|
+
if pin.keep and pin.identity is ident:
|
|
962
|
+
return
|
|
921
963
|
if retire or not self.sticky:
|
|
922
964
|
with pin.tlock:
|
|
923
965
|
if pin.identity is ident:
|
|
@@ -1023,12 +1065,15 @@ class Client(_Base):
|
|
|
1023
1065
|
# Failed in our own code: nothing says the identity is burnt.
|
|
1024
1066
|
self._release(_pin, ident, retire=False)
|
|
1025
1067
|
raise
|
|
1068
|
+
if decision.retry and not decision.same_identity and _pin is not None and _pin.keep:
|
|
1069
|
+
decision = _no_rotation(decision)
|
|
1026
1070
|
if not decision.retry:
|
|
1027
1071
|
self._release(_pin, ident, retire=decision.retire)
|
|
1028
1072
|
ok = resp is not None and reason is None and resp.status_code < 500
|
|
1029
1073
|
self._stats.record_request(ok)
|
|
1030
1074
|
if resp is None:
|
|
1031
|
-
|
|
1075
|
+
if error is None:
|
|
1076
|
+
raise RuntimeError('scrapely.http: request ended with no response and no error')
|
|
1032
1077
|
raise error
|
|
1033
1078
|
resp.blocked = reason
|
|
1034
1079
|
return resp
|
|
@@ -1067,9 +1112,9 @@ class Client(_Base):
|
|
|
1067
1112
|
def options(self, url: str, **kwargs: Any) -> Response:
|
|
1068
1113
|
return self.request('OPTIONS', url, **kwargs)
|
|
1069
1114
|
|
|
1070
|
-
def session(self) -> 'HttpSession':
|
|
1115
|
+
def session(self, keep_identity: bool = False) -> 'HttpSession':
|
|
1071
1116
|
"""A pinned identity for multi-step flows (see ``AsyncClient.session``)."""
|
|
1072
|
-
return HttpSession(self)
|
|
1117
|
+
return HttpSession(self, keep_identity=keep_identity)
|
|
1073
1118
|
|
|
1074
1119
|
def close(self) -> None:
|
|
1075
1120
|
if self._closed:
|
|
@@ -1090,11 +1135,22 @@ class Client(_Base):
|
|
|
1090
1135
|
class HttpSession:
|
|
1091
1136
|
"""A pinned identity on a sync ``Client`` (see ``Client.session()``)."""
|
|
1092
1137
|
|
|
1093
|
-
def __init__(self, client: Client) -> None:
|
|
1138
|
+
def __init__(self, client: Client, keep_identity: bool = False) -> None:
|
|
1094
1139
|
self._client = client
|
|
1095
|
-
self._pin = _Pin()
|
|
1140
|
+
self._pin = _Pin(keep=keep_identity)
|
|
1096
1141
|
self.cookies = _SessionCookies(self)
|
|
1097
1142
|
|
|
1143
|
+
@property
|
|
1144
|
+
def keep_identity(self) -> bool:
|
|
1145
|
+
return self._pin.keep
|
|
1146
|
+
|
|
1147
|
+
def rotate(self) -> None:
|
|
1148
|
+
"""Retire the current identity; the next request opens a fresh one (new exit, profile and jar)."""
|
|
1149
|
+
with self._pin.tlock:
|
|
1150
|
+
ident, self._pin.identity = self._pin.identity, None
|
|
1151
|
+
if ident is not None:
|
|
1152
|
+
self._client._retire(ident, force=ident.in_flight <= 0)
|
|
1153
|
+
|
|
1098
1154
|
@property
|
|
1099
1155
|
def profile(self) -> Optional[str]:
|
|
1100
1156
|
return self._pin.identity.profile.label if self._pin.identity else None
|