scrapely-python 0.1.38__tar.gz → 0.1.41__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.38 → scrapely_python-0.1.41}/CHANGELOG.md +27 -0
- {scrapely_python-0.1.38/src/scrapely_python.egg-info → scrapely_python-0.1.41}/PKG-INFO +1 -1
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/pyproject.toml +1 -1
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely/__init__.py +1 -1
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely/actor.py +17 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely/charging.py +246 -18
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely/events/event_manager.py +8 -6
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely/http/_client.py +97 -14
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely/http/_headers.py +72 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely/http/_options.py +4 -4
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely/http/_policy.py +1 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely/http/_profiles.py +5 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely/http/profiles.json +397 -46
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely/proxy_configuration.py +31 -2
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely/storage/api_client.py +29 -5
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely/storage/client.py +6 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.41/src/scrapely_python.egg-info}/PKG-INFO +1 -1
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/LICENSE +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/LICENSE-APACHE +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/MANIFEST.in +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/NOTICE +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/README.md +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/setup.cfg +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely/_browser_locations.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely/browser_configuration.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely/cdp_retry.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely/charging_client.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely/configuration.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely/crypto/__init__.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely/crypto/_secrets.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely/errors.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely/events/__init__.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely/events/_types.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely/http/__init__.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely/http/_capture.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely/http/_compat.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely/http/_detect.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely/http/_response.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely/http/_stats.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely/http/_transport.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely/log.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely/patch_browser_pool.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely/pricing_types.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely/py.typed +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely/request_loaders/__init__.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely/request_loaders/_scrapely_request_list.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely/scrapy/__init__.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely/scrapy/_actor_runner.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely/scrapy/_async_thread.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely/scrapy/_logging_config.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely/scrapy/_serialization.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely/scrapy/_warnings.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely/scrapy/extensions/__init__.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely/scrapy/extensions/_graceful_stop.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely/scrapy/extensions/_httpcache.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely/scrapy/middlewares/__init__.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely/scrapy/middlewares/apify_proxy.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely/scrapy/pipelines/__init__.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely/scrapy/pipelines/actor_dataset_push.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely/scrapy/requests.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely/scrapy/scheduler.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely/scrapy/utils.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely/solver.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely/storage/__init__.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely/storage/_file_system/__init__.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely/storage/_file_system/_key_value_store_client.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely/storage/_file_system/_storage_client.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely/storage/_metadata.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely/storage/patch_storage.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely/storage/scrapely_dataset_client.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely/storage/scrapely_kvs_client.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely/storage/scrapely_rq_client.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely/storage/scrapely_storage_client.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely/storage/smart_scrapely_storage_client.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely/storage/types.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely/storage/utils.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely/storage_clients/__init__.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely/storages/__init__.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely/types.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely/webhook.py +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely_python.egg-info/SOURCES.txt +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely_python.egg-info/dependency_links.txt +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely_python.egg-info/requires.txt +0 -0
- {scrapely_python-0.1.38 → scrapely_python-0.1.41}/src/scrapely_python.egg-info/top_level.txt +0 -0
|
@@ -4,6 +4,33 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.1.41] - 2026-10-29
|
|
8
|
+
|
|
9
|
+
- **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:
|
|
10
|
+
- The SDK's own log lines name the real limit ("The item limit of this run (maxItems = 800) has been reached: ...").
|
|
11
|
+
- `ChargingManager.get_charge_limit_reason()` returns `'max-items'`, `'max-charge'` or `'insufficient-funds'` (None while charges are still accepted), and `get_charge_limit_message()` the stop message: "Stopped: reached the item limit you set (800 items)", "Stopped: reached the maximum charge you set ($3.00)" or "Stopped: your account has no credit left for this run".
|
|
12
|
+
- Once a limit was reached, a status message (`Actor.set_status_message`, `Actor.exit`, `Actor.fail`) whose clause blames the maximum charge ("Saved 800 results; stopped because the maximum charge for this run was reached") gets that clause replaced by the real stop message ("Saved 800 results. Stopped: reached the item limit you set (800 items)"). Other messages are untouched.
|
|
13
|
+
- Platforms that do not send the reason yet: inferred from the 402 message and `ACTOR_MAX_PAID_DATASET_ITEMS`.
|
|
14
|
+
- What is charged and saved does not change.
|
|
15
|
+
|
|
16
|
+
## [0.1.40] - 2026-10-28
|
|
17
|
+
|
|
18
|
+
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):
|
|
19
|
+
|
|
20
|
+
- **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.
|
|
21
|
+
- **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.
|
|
22
|
+
- **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`).
|
|
23
|
+
- **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.
|
|
24
|
+
- **Unchanged:** no proxy, your own proxy URLs and the `DATACENTER` group (US exits only) keep `en-US,en;q=0.9`.
|
|
25
|
+
- `ProxyConfiguration.new_url()` takes an optional `country_code` for one URL (residential platform proxy without a country only). Sessions expose `accept_language` and `country`.
|
|
26
|
+
- **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.
|
|
27
|
+
- **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).
|
|
28
|
+
- **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.
|
|
29
|
+
|
|
30
|
+
## [0.1.39] - 2026-10-27 (not published)
|
|
31
|
+
|
|
32
|
+
- 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.
|
|
33
|
+
|
|
7
34
|
## [0.1.38] - 2026-10-27
|
|
8
35
|
|
|
9
36
|
0.1.37 was never published to PyPI. This release includes everything listed under 0.1.37 below, plus these changes to `scrapely.http`:
|
|
@@ -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.41"
|
|
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"
|
|
@@ -725,8 +725,23 @@ class Actor(metaclass=_ActorMeta):
|
|
|
725
725
|
|
|
726
726
|
await cls._exit_with(opts)
|
|
727
727
|
|
|
728
|
+
@classmethod
|
|
729
|
+
def _truthful_status_message(cls, message: Optional[str]) -> Optional[str]:
|
|
730
|
+
"""Name the limit that really stopped the run in a status message.
|
|
731
|
+
|
|
732
|
+
Actors word a refused charge as "the maximum charge for this run was
|
|
733
|
+
reached" whatever refused it. Once the run hit a limit, the charging
|
|
734
|
+
manager knows which one (the item limit, the maximum charge, the
|
|
735
|
+
balance), and that clause is replaced by its stop message.
|
|
736
|
+
"""
|
|
737
|
+
rewrite = getattr(cls._charging_manager, 'rewrite_status_message', None)
|
|
738
|
+
if not message or rewrite is None:
|
|
739
|
+
return message
|
|
740
|
+
return rewrite(message)
|
|
741
|
+
|
|
728
742
|
@classmethod
|
|
729
743
|
async def _exit_with(cls, opts: ExitOptions, persist_message: bool = True) -> None:
|
|
744
|
+
opts.status_message = cls._truthful_status_message(opts.status_message)
|
|
730
745
|
if opts.status_message and persist_message:
|
|
731
746
|
logger.info(f'[Scrapely] {opts.status_message}')
|
|
732
747
|
|
|
@@ -1289,6 +1304,7 @@ class Actor(metaclass=_ActorMeta):
|
|
|
1289
1304
|
The updated ActorRun object, or an empty ActorRun when running locally.
|
|
1290
1305
|
"""
|
|
1291
1306
|
cls._ensure_initialized('set_status_message')
|
|
1307
|
+
message = cls._truthful_status_message(message) or message
|
|
1292
1308
|
opts = options or SetStatusMessageOptions()
|
|
1293
1309
|
level = (getattr(opts, 'level', None) or 'INFO').upper()
|
|
1294
1310
|
if is_terminal is None:
|
|
@@ -1371,6 +1387,7 @@ class Actor(metaclass=_ActorMeta):
|
|
|
1371
1387
|
if not api_url or not token or not run_id:
|
|
1372
1388
|
return None
|
|
1373
1389
|
|
|
1390
|
+
message = cls._truthful_status_message(message) or message
|
|
1374
1391
|
client = cls.new_client(token=token, base_url=api_url)
|
|
1375
1392
|
# Setting the message is idempotent: a 429 (run API rate limit), 5xx or
|
|
1376
1393
|
# dropped connection is retried with backoff instead of failing the run.
|
|
@@ -13,6 +13,7 @@ import contextvars
|
|
|
13
13
|
import json
|
|
14
14
|
import os
|
|
15
15
|
import logging
|
|
16
|
+
import re
|
|
16
17
|
import time
|
|
17
18
|
import uuid
|
|
18
19
|
from datetime import datetime, timezone
|
|
@@ -75,6 +76,87 @@ def _is_transient_status(status: Optional[int]) -> bool:
|
|
|
75
76
|
LOCAL_CHARGING_LOG_DATASET_NAME = 'charging_log'
|
|
76
77
|
|
|
77
78
|
|
|
79
|
+
# Which run limit refused or cut down a charge (the platform's ``error.reason``
|
|
80
|
+
# on HTTP 402 and ``limitedBy`` on a partial acceptance). The run's stop
|
|
81
|
+
# message names it, so an item limit is never called a spending limit.
|
|
82
|
+
CHARGE_LIMIT_MAX_ITEMS = 'max-items'
|
|
83
|
+
CHARGE_LIMIT_MAX_CHARGE = 'max-charge'
|
|
84
|
+
CHARGE_LIMIT_INSUFFICIENT_FUNDS = 'insufficient-funds'
|
|
85
|
+
_CHARGE_LIMIT_REASONS = frozenset({CHARGE_LIMIT_MAX_ITEMS, CHARGE_LIMIT_MAX_CHARGE, CHARGE_LIMIT_INSUFFICIENT_FUNDS})
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _format_usd(value: Any) -> str:
|
|
89
|
+
"""$3.00, $0.005: two decimals unless the amount needs more."""
|
|
90
|
+
try:
|
|
91
|
+
amount = Decimal(str(value))
|
|
92
|
+
except Exception:
|
|
93
|
+
return f'${value}'
|
|
94
|
+
if amount == amount.quantize(Decimal('0.01')):
|
|
95
|
+
return f'${amount:.2f}'
|
|
96
|
+
return f"${format(amount.normalize(), 'f')}"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def charge_limit_stop_message(
|
|
100
|
+
reason: Optional[str], *, max_items: Optional[int] = None, max_total_charge_usd: Any = None
|
|
101
|
+
) -> Optional[str]:
|
|
102
|
+
"""The run's stop message for a charge limit, or None when the reason is unknown."""
|
|
103
|
+
if reason == CHARGE_LIMIT_MAX_ITEMS:
|
|
104
|
+
if max_items is not None and max_items > 0:
|
|
105
|
+
unit = 'item' if max_items == 1 else 'items'
|
|
106
|
+
return f'Stopped: reached the item limit you set ({max_items} {unit})'
|
|
107
|
+
return 'Stopped: reached the item limit you set'
|
|
108
|
+
if reason == CHARGE_LIMIT_MAX_CHARGE:
|
|
109
|
+
if max_total_charge_usd is not None:
|
|
110
|
+
return f'Stopped: reached the maximum charge you set ({_format_usd(max_total_charge_usd)})'
|
|
111
|
+
return 'Stopped: reached the maximum charge you set'
|
|
112
|
+
if reason == CHARGE_LIMIT_INSUFFICIENT_FUNDS:
|
|
113
|
+
return 'Stopped: your account has no credit left for this run'
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# A status-message clause that blames the spending limit ("stopped because the
|
|
118
|
+
# maximum charge for this run was reached", "The run reached its maximum total
|
|
119
|
+
# charge. Stopping.", ...). Actors written before the platform said which limit
|
|
120
|
+
# was hit use such texts for every refused charge.
|
|
121
|
+
_CHARGE_CLAIM_RE = re.compile(r'\bmax(?:imum)?\s+(?:total\s+)?charge|\bspending\s+limit', re.IGNORECASE)
|
|
122
|
+
_CLAUSE_SEPARATOR_RE = re.compile(r'(;\s*|[.!?](?:\s+|$))')
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def rewrite_charge_limit_claims(message: str, stop_message: Optional[str]) -> str:
|
|
126
|
+
"""Replace the clause of ``message`` that blames the spending limit with ``stop_message``.
|
|
127
|
+
|
|
128
|
+
"Saved 800 results; stopped because the maximum charge for this run was
|
|
129
|
+
reached" becomes "Saved 800 results. Stopped: reached the item limit you set
|
|
130
|
+
(800 items)". Only the first such clause is replaced (later ones are
|
|
131
|
+
dropped); a message without one is returned as is.
|
|
132
|
+
"""
|
|
133
|
+
if not message or not stop_message or stop_message in message or not _CHARGE_CLAIM_RE.search(message):
|
|
134
|
+
return message
|
|
135
|
+
parts = _CLAUSE_SEPARATOR_RE.split(message)
|
|
136
|
+
out: List[str] = []
|
|
137
|
+
replaced = False
|
|
138
|
+
for i in range(0, len(parts), 2):
|
|
139
|
+
clause = parts[i]
|
|
140
|
+
sep = parts[i + 1] if i + 1 < len(parts) else ''
|
|
141
|
+
if not _CHARGE_CLAIM_RE.search(clause):
|
|
142
|
+
out.append(clause + sep)
|
|
143
|
+
continue
|
|
144
|
+
if replaced:
|
|
145
|
+
continue
|
|
146
|
+
replaced = True
|
|
147
|
+
if out and out[-1].rstrip().endswith(';'):
|
|
148
|
+
out[-1] = out[-1].rstrip()[:-1].rstrip() + '. '
|
|
149
|
+
lead = clause[: len(clause) - len(clause.lstrip())]
|
|
150
|
+
tail = ''
|
|
151
|
+
if sep:
|
|
152
|
+
tail = '.' + sep[len(sep.rstrip()):] if sep.strip() in (';', '.', '!', '?') else sep
|
|
153
|
+
out.append(lead + stop_message + tail)
|
|
154
|
+
result = ''.join(out).rstrip()
|
|
155
|
+
if message.rstrip().endswith('.') and not result.endswith('.'):
|
|
156
|
+
result += '.'
|
|
157
|
+
return result
|
|
158
|
+
|
|
159
|
+
|
|
78
160
|
# Charge-limit safety net for the default dataset.
|
|
79
161
|
#
|
|
80
162
|
# Once a pay-per-event run has used up its spending limit (maxTotalChargeUsd),
|
|
@@ -205,6 +287,8 @@ class ChargeRejectedError(Exception):
|
|
|
205
287
|
count: int,
|
|
206
288
|
status_code: Optional[int] = None,
|
|
207
289
|
reason: str = 'rejected',
|
|
290
|
+
limit_reason: Optional[str] = None,
|
|
291
|
+
max_items: Optional[int] = None,
|
|
208
292
|
) -> None:
|
|
209
293
|
super().__init__(message)
|
|
210
294
|
self.operation_id = operation_id
|
|
@@ -212,6 +296,9 @@ class ChargeRejectedError(Exception):
|
|
|
212
296
|
self.count = count
|
|
213
297
|
self.status_code = status_code
|
|
214
298
|
self.reason = reason
|
|
299
|
+
# Which run limit refused the charge (CHARGE_LIMIT_*), when the platform said.
|
|
300
|
+
self.limit_reason = limit_reason
|
|
301
|
+
self.max_items = max_items
|
|
215
302
|
|
|
216
303
|
|
|
217
304
|
class ChargingManager:
|
|
@@ -282,6 +369,13 @@ class ChargingManager:
|
|
|
282
369
|
# Sticky: the platform refused or capped a charge (HTTP 402 / fewer
|
|
283
370
|
# events accepted) or the local budget gate trimmed one.
|
|
284
371
|
self._charge_limit_hit = False
|
|
372
|
+
# Which limit that was (CHARGE_LIMIT_*; the first one known wins) and the
|
|
373
|
+
# run's item limit as the platform reported it.
|
|
374
|
+
self._charge_limit_reason: Optional[str] = None
|
|
375
|
+
self._charge_limit_max_items: Optional[int] = None
|
|
376
|
+
# limitedBy / maxItems of the last partially accepted charge response.
|
|
377
|
+
self._last_limited_by: Optional[str] = None
|
|
378
|
+
self._last_limit_max_items: Optional[int] = None
|
|
285
379
|
# Default-dataset rows refused after the limit (one warning, then a count).
|
|
286
380
|
self._refused_default_dataset_items = 0
|
|
287
381
|
# Prepaid-row balance: priced events charged through charge() minus rows
|
|
@@ -301,14 +395,15 @@ class ChargingManager:
|
|
|
301
395
|
self._closed = True
|
|
302
396
|
if self._refused_default_dataset_items:
|
|
303
397
|
logger.warning(
|
|
304
|
-
'%s result(s) pushed to the default dataset after
|
|
305
|
-
'reached were not saved',
|
|
398
|
+
'%s result(s) pushed to the default dataset after %s was reached were not saved',
|
|
306
399
|
self._refused_default_dataset_items,
|
|
400
|
+
self._limit_name(),
|
|
307
401
|
)
|
|
308
402
|
|
|
309
403
|
def _remaining_budget_usd(self) -> Decimal:
|
|
310
404
|
"""maxTotalChargeUsd minus this SDK's charges and the platform's other run spend."""
|
|
311
|
-
|
|
405
|
+
if self._max_total_charge_usd is None:
|
|
406
|
+
raise RuntimeError('maxTotalChargeUsd is not set')
|
|
312
407
|
return self._max_total_charge_usd - self._total_charged_usd - self._platform_other_usd
|
|
313
408
|
|
|
314
409
|
def _sync_platform_budget(self) -> None:
|
|
@@ -349,24 +444,107 @@ class ChargingManager:
|
|
|
349
444
|
return False
|
|
350
445
|
return self._remaining_budget_usd() < min(prices)
|
|
351
446
|
|
|
352
|
-
def _mark_charge_limit_hit(self) -> None:
|
|
447
|
+
def _mark_charge_limit_hit(self, reason: Optional[str] = None, max_items: Optional[int] = None) -> None:
|
|
353
448
|
if self._pricing_info is not None:
|
|
354
449
|
self._charge_limit_hit = True
|
|
450
|
+
if reason in _CHARGE_LIMIT_REASONS and self._charge_limit_reason is None:
|
|
451
|
+
self._charge_limit_reason = reason
|
|
452
|
+
if reason == CHARGE_LIMIT_MAX_ITEMS and max_items is not None and max_items > 0:
|
|
453
|
+
self._charge_limit_max_items = max_items
|
|
454
|
+
|
|
455
|
+
def _mark_rejected_charge(self, err: 'ChargeRejectedError') -> None:
|
|
456
|
+
"""Record a definitive platform refusal that means the run's limit is used up."""
|
|
457
|
+
if _is_charge_limit_rejection(err):
|
|
458
|
+
self._mark_charge_limit_hit(err.limit_reason, err.max_items)
|
|
459
|
+
|
|
460
|
+
def _mark_partial_charge(self) -> None:
|
|
461
|
+
"""The platform accepted fewer events than asked: record which limit cut it."""
|
|
462
|
+
self._mark_charge_limit_hit(self._last_limited_by, self._last_limit_max_items)
|
|
463
|
+
|
|
464
|
+
def _max_items_setting(self) -> Optional[int]:
|
|
465
|
+
"""The run's item limit: as the platform reported it, else ACTOR_MAX_PAID_DATASET_ITEMS."""
|
|
466
|
+
if self._charge_limit_max_items is not None:
|
|
467
|
+
return self._charge_limit_max_items
|
|
468
|
+
try:
|
|
469
|
+
raw = self._config.get('actor_max_paid_dataset_items')
|
|
470
|
+
value = int(raw) if raw not in (None, '') else None
|
|
471
|
+
except (TypeError, ValueError):
|
|
472
|
+
value = None
|
|
473
|
+
return value if value is not None and value > 0 else None
|
|
474
|
+
|
|
475
|
+
def get_charge_limit_reason(self) -> Optional[str]:
|
|
476
|
+
"""Why this run's charges stopped being accepted, or None while they still are.
|
|
477
|
+
|
|
478
|
+
One of ``'max-items'`` (the item limit the user set, maxItems),
|
|
479
|
+
``'max-charge'`` (the maximum total charge, maxTotalChargeUsd) or
|
|
480
|
+
``'insufficient-funds'`` (the account balance). Platforms that do not
|
|
481
|
+
say which limit it was: inferred from the run's settings.
|
|
482
|
+
"""
|
|
483
|
+
if not self.is_charge_limit_reached():
|
|
484
|
+
return None
|
|
485
|
+
if self._charge_limit_reason is not None:
|
|
486
|
+
return self._charge_limit_reason
|
|
487
|
+
max_items = self._max_items_setting()
|
|
488
|
+
if max_items is not None and any(
|
|
489
|
+
count >= max_items
|
|
490
|
+
for name, count in self._charged_events.items()
|
|
491
|
+
if not is_default_dataset_item_event(name) and self._per_event_prices.get(name, 0) > 0
|
|
492
|
+
):
|
|
493
|
+
return CHARGE_LIMIT_MAX_ITEMS
|
|
494
|
+
if self._max_total_charge_usd is not None:
|
|
495
|
+
prices = [price for price in self._per_event_prices.values() if price > 0]
|
|
496
|
+
if prices and self._remaining_budget_usd() < min(prices):
|
|
497
|
+
return CHARGE_LIMIT_MAX_CHARGE
|
|
498
|
+
return None
|
|
499
|
+
|
|
500
|
+
def get_charge_limit_message(self) -> Optional[str]:
|
|
501
|
+
"""The run's stop message for the limit that was reached, or None.
|
|
502
|
+
|
|
503
|
+
"Stopped: reached the item limit you set (800 items)", "Stopped: reached
|
|
504
|
+
the maximum charge you set ($3.00)", ... Use it as (part of) the final
|
|
505
|
+
status message when the Actor stops because a charge was refused.
|
|
506
|
+
"""
|
|
507
|
+
return charge_limit_stop_message(
|
|
508
|
+
self.get_charge_limit_reason(),
|
|
509
|
+
max_items=self._max_items_setting(),
|
|
510
|
+
max_total_charge_usd=self._max_total_charge_usd,
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
def rewrite_status_message(self, message: str) -> str:
|
|
514
|
+
"""``message`` with a clause blaming the spending limit replaced by the real reason.
|
|
515
|
+
|
|
516
|
+
Only once a limit was reached; any other message is returned as is.
|
|
517
|
+
"""
|
|
518
|
+
try:
|
|
519
|
+
return rewrite_charge_limit_claims(message, self.get_charge_limit_message())
|
|
520
|
+
except Exception: # noqa: BLE001 - a status message must never break the run
|
|
521
|
+
return message
|
|
522
|
+
|
|
523
|
+
def _limit_name(self) -> str:
|
|
524
|
+
"""'the item limit of this run (maxItems = 800)', ... for log lines."""
|
|
525
|
+
reason = self.get_charge_limit_reason()
|
|
526
|
+
if reason == CHARGE_LIMIT_MAX_ITEMS:
|
|
527
|
+
max_items = self._max_items_setting()
|
|
528
|
+
return f'the item limit of this run (maxItems = {max_items})' if max_items else 'the item limit of this run (maxItems)'
|
|
529
|
+
if reason == CHARGE_LIMIT_INSUFFICIENT_FUNDS:
|
|
530
|
+
return 'the end of the account balance'
|
|
531
|
+
if reason == CHARGE_LIMIT_MAX_CHARGE or self._max_total_charge_usd is not None:
|
|
532
|
+
limit = f'${self._max_total_charge_usd}' if self._max_total_charge_usd is not None else 'the limit'
|
|
533
|
+
return f'the spending limit of this run (maxTotalChargeUsd = {limit})'
|
|
534
|
+
return 'the limit of this run'
|
|
355
535
|
|
|
356
536
|
def _note_refused_default_dataset_items(self, count: int) -> None:
|
|
357
537
|
"""Count rows dropped because of the limit; warn on the first one only."""
|
|
358
538
|
if count <= 0:
|
|
359
539
|
return
|
|
360
540
|
if not self._refused_default_dataset_items:
|
|
361
|
-
|
|
362
|
-
f'${self._max_total_charge_usd}' if self._max_total_charge_usd is not None else 'the limit'
|
|
363
|
-
)
|
|
541
|
+
name = self._limit_name()
|
|
364
542
|
logger.warning(
|
|
365
|
-
'
|
|
543
|
+
'%s has been reached: '
|
|
366
544
|
'results pushed to the default dataset are no longer saved. The Actor should '
|
|
367
545
|
'stop now (check ChargeResult.event_charge_limit_reached or '
|
|
368
546
|
'Actor.get_charging_manager().is_charge_limit_reached()).',
|
|
369
|
-
|
|
547
|
+
name[0].upper() + name[1:],
|
|
370
548
|
)
|
|
371
549
|
self._refused_default_dataset_items += count
|
|
372
550
|
|
|
@@ -926,6 +1104,7 @@ class ChargingManager:
|
|
|
926
1104
|
deadline=deadline,
|
|
927
1105
|
)
|
|
928
1106
|
self._last_remaining_budget_nd = _read_remaining_budget_nd(body)
|
|
1107
|
+
self._last_limited_by, self._last_limit_max_items = _read_limited_by(body)
|
|
929
1108
|
return _read_accepted_count(body, count)
|
|
930
1109
|
except Exception as e:
|
|
931
1110
|
last_error = e
|
|
@@ -996,8 +1175,7 @@ class ChargingManager:
|
|
|
996
1175
|
deadline=deadline,
|
|
997
1176
|
)
|
|
998
1177
|
except ChargeRejectedError as err:
|
|
999
|
-
|
|
1000
|
-
self._mark_charge_limit_hit()
|
|
1178
|
+
self._mark_rejected_charge(err)
|
|
1001
1179
|
self._unwind_reservation(event_name, count)
|
|
1002
1180
|
self._drop_pending(idempotency_key)
|
|
1003
1181
|
logger.warning(
|
|
@@ -1009,7 +1187,7 @@ class ChargingManager:
|
|
|
1009
1187
|
continue
|
|
1010
1188
|
if accepted is not None:
|
|
1011
1189
|
if accepted < count:
|
|
1012
|
-
self.
|
|
1190
|
+
self._mark_partial_charge()
|
|
1013
1191
|
self._unwind_reservation(event_name, count - accepted)
|
|
1014
1192
|
self._drop_pending(idempotency_key)
|
|
1015
1193
|
confirmed += 1
|
|
@@ -1099,7 +1277,8 @@ class ChargingManager:
|
|
|
1099
1277
|
)
|
|
1100
1278
|
|
|
1101
1279
|
if chargeable_count < count:
|
|
1102
|
-
|
|
1280
|
+
# The local budget gate: only maxTotalChargeUsd trims here.
|
|
1281
|
+
self._mark_charge_limit_hit(CHARGE_LIMIT_MAX_CHARGE)
|
|
1103
1282
|
|
|
1104
1283
|
if chargeable_count <= 0:
|
|
1105
1284
|
return ChargeResult(
|
|
@@ -1154,8 +1333,7 @@ class ChargingManager:
|
|
|
1154
1333
|
idempotency_key=idempotency_key,
|
|
1155
1334
|
)
|
|
1156
1335
|
except ChargeRejectedError as rejected:
|
|
1157
|
-
|
|
1158
|
-
self._mark_charge_limit_hit()
|
|
1336
|
+
self._mark_rejected_charge(rejected)
|
|
1159
1337
|
# Server definitively rejected: unwind local reservation.
|
|
1160
1338
|
self._drop_pending(idempotency_key)
|
|
1161
1339
|
self._unwind_reservation(event_name, chargeable_count)
|
|
@@ -1170,7 +1348,7 @@ class ChargingManager:
|
|
|
1170
1348
|
self._last_accepted_operation_id = idempotency_key
|
|
1171
1349
|
if accepted < chargeable_count:
|
|
1172
1350
|
# The platform capped the charge at the run's limit.
|
|
1173
|
-
self.
|
|
1351
|
+
self._mark_partial_charge()
|
|
1174
1352
|
self._unwind_reservation(event_name, chargeable_count - accepted)
|
|
1175
1353
|
chargeable_count = accepted
|
|
1176
1354
|
result = ChargeResult(
|
|
@@ -1222,11 +1400,12 @@ class ChargingManager:
|
|
|
1222
1400
|
if chargeable_count < count:
|
|
1223
1401
|
subject = 'instance' if count == 1 else 'instances'
|
|
1224
1402
|
logger.info(
|
|
1225
|
-
"Charging %s %s of '%s' event would exceed
|
|
1403
|
+
"Charging %s %s of '%s' event would exceed %s - "
|
|
1226
1404
|
'only %s events were charged',
|
|
1227
1405
|
count,
|
|
1228
1406
|
subject,
|
|
1229
1407
|
event_name,
|
|
1408
|
+
self._limit_name(),
|
|
1230
1409
|
chargeable_count,
|
|
1231
1410
|
)
|
|
1232
1411
|
|
|
@@ -1420,6 +1599,43 @@ def _read_remaining_budget_nd(body: Any) -> Optional[int]:
|
|
|
1420
1599
|
return None
|
|
1421
1600
|
|
|
1422
1601
|
|
|
1602
|
+
def _positive_int(raw: Any) -> Optional[int]:
|
|
1603
|
+
if isinstance(raw, (int, float)) and not isinstance(raw, bool) and raw > 0:
|
|
1604
|
+
return int(raw)
|
|
1605
|
+
return None
|
|
1606
|
+
|
|
1607
|
+
|
|
1608
|
+
def _read_limited_by(body: Any) -> tuple[Optional[str], Optional[int]]:
|
|
1609
|
+
"""limitedBy and maxItems of a partially accepted charge (platforms that send them)."""
|
|
1610
|
+
if not isinstance(body, dict):
|
|
1611
|
+
return None, None
|
|
1612
|
+
data = body.get('data') if isinstance(body.get('data'), dict) else body
|
|
1613
|
+
if not isinstance(data, dict):
|
|
1614
|
+
return None, None
|
|
1615
|
+
reason = data.get('limitedBy')
|
|
1616
|
+
return (reason if reason in _CHARGE_LIMIT_REASONS else None), _positive_int(data.get('maxItems'))
|
|
1617
|
+
|
|
1618
|
+
|
|
1619
|
+
def _rejection_limit(body: Any, message: str) -> tuple[Optional[str], Optional[int]]:
|
|
1620
|
+
"""Which limit a 402 names: error.reason, else its message (older platforms)."""
|
|
1621
|
+
reason: Optional[str] = None
|
|
1622
|
+
max_items: Optional[int] = None
|
|
1623
|
+
if isinstance(body, dict):
|
|
1624
|
+
error = body.get('error')
|
|
1625
|
+
if isinstance(error, dict) and error.get('reason') in _CHARGE_LIMIT_REASONS:
|
|
1626
|
+
reason = error['reason']
|
|
1627
|
+
max_items = _positive_int(body.get('maxItems'))
|
|
1628
|
+
if reason is None:
|
|
1629
|
+
text = message.lower()
|
|
1630
|
+
if 'maxitems' in text or 'maximum number of results' in text:
|
|
1631
|
+
reason = CHARGE_LIMIT_MAX_ITEMS
|
|
1632
|
+
elif 'insufficient balance' in text:
|
|
1633
|
+
reason = CHARGE_LIMIT_INSUFFICIENT_FUNDS
|
|
1634
|
+
elif 'maximum total charge' in text:
|
|
1635
|
+
reason = CHARGE_LIMIT_MAX_CHARGE
|
|
1636
|
+
return reason, max_items
|
|
1637
|
+
|
|
1638
|
+
|
|
1423
1639
|
def _read_accepted_count(body: Any, fallback: int) -> int:
|
|
1424
1640
|
if not isinstance(body, dict):
|
|
1425
1641
|
return fallback
|
|
@@ -1445,6 +1661,9 @@ def _classify_charge_http_error(
|
|
|
1445
1661
|
|
|
1446
1662
|
# 401/403: ownership or scoped-run authorization failures are permanent.
|
|
1447
1663
|
if status in (400, 401, 402, 403, 404, 405, 409):
|
|
1664
|
+
limit_reason, max_items = (
|
|
1665
|
+
_rejection_limit(getattr(err, 'body', None), str(err)) if status == 402 else (None, None)
|
|
1666
|
+
)
|
|
1448
1667
|
return ChargeRejectedError(
|
|
1449
1668
|
f'Charge rejected by platform (HTTP {status}) for event {event_name!r}',
|
|
1450
1669
|
operation_id=operation_id,
|
|
@@ -1452,6 +1671,8 @@ def _classify_charge_http_error(
|
|
|
1452
1671
|
count=count,
|
|
1453
1672
|
status_code=int(status) if status is not None else None,
|
|
1454
1673
|
reason='charge-limit-exceeded' if status == 402 else f'http-{status}',
|
|
1674
|
+
limit_reason=limit_reason,
|
|
1675
|
+
max_items=max_items,
|
|
1455
1676
|
)
|
|
1456
1677
|
|
|
1457
1678
|
# A 5xx / 408 / 429 answer is never a definitive rejection, whatever its text
|
|
@@ -1471,6 +1692,11 @@ def _classify_charge_http_error(
|
|
|
1471
1692
|
'conflict',
|
|
1472
1693
|
)
|
|
1473
1694
|
):
|
|
1695
|
+
limit_reason, max_items = (
|
|
1696
|
+
_rejection_limit(getattr(err, 'body', None), message)
|
|
1697
|
+
if 'charge-limit-exceeded' in message.lower()
|
|
1698
|
+
else (None, None)
|
|
1699
|
+
)
|
|
1474
1700
|
return ChargeRejectedError(
|
|
1475
1701
|
message,
|
|
1476
1702
|
operation_id=operation_id,
|
|
@@ -1478,6 +1704,8 @@ def _classify_charge_http_error(
|
|
|
1478
1704
|
count=count,
|
|
1479
1705
|
status_code=int(status) if isinstance(status, int) else None,
|
|
1480
1706
|
reason='message-match',
|
|
1707
|
+
limit_reason=limit_reason,
|
|
1708
|
+
max_items=max_items,
|
|
1481
1709
|
)
|
|
1482
1710
|
return None
|
|
1483
1711
|
|
|
@@ -1579,7 +1807,7 @@ async def push_data_and_charge(
|
|
|
1579
1807
|
limited_items = items[:limited_count] if limited_count < len(items) else items
|
|
1580
1808
|
if is_ppe and is_default_dataset and limited_count < len(items):
|
|
1581
1809
|
# Rows beyond what the budget can pay for are dropped.
|
|
1582
|
-
charging_manager._mark_charge_limit_hit()
|
|
1810
|
+
charging_manager._mark_charge_limit_hit(CHARGE_LIMIT_MAX_CHARGE)
|
|
1583
1811
|
charging_manager._note_refused_default_dataset_items(len(items) - limited_count)
|
|
1584
1812
|
|
|
1585
1813
|
if not limited_items:
|
|
@@ -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)
|