scrapely-python 0.1.40__py3-none-any.whl → 0.1.42__py3-none-any.whl

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/__init__.py CHANGED
@@ -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.40'
230
+ __version__ = '0.1.41'
231
231
  __all__ = [
232
232
  # Core
233
233
  'Actor',
scrapely/actor.py CHANGED
@@ -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.
scrapely/charging.py CHANGED
@@ -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,9 +395,9 @@ 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 the spending limit was '
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:
@@ -350,24 +444,107 @@ class ChargingManager:
350
444
  return False
351
445
  return self._remaining_budget_usd() < min(prices)
352
446
 
353
- def _mark_charge_limit_hit(self) -> None:
447
+ def _mark_charge_limit_hit(self, reason: Optional[str] = None, max_items: Optional[int] = None) -> None:
354
448
  if self._pricing_info is not None:
355
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'
356
535
 
357
536
  def _note_refused_default_dataset_items(self, count: int) -> None:
358
537
  """Count rows dropped because of the limit; warn on the first one only."""
359
538
  if count <= 0:
360
539
  return
361
540
  if not self._refused_default_dataset_items:
362
- limit = (
363
- f'${self._max_total_charge_usd}' if self._max_total_charge_usd is not None else 'the limit'
364
- )
541
+ name = self._limit_name()
365
542
  logger.warning(
366
- 'The spending limit of this run (maxTotalChargeUsd = %s) has been reached: '
543
+ '%s has been reached: '
367
544
  'results pushed to the default dataset are no longer saved. The Actor should '
368
545
  'stop now (check ChargeResult.event_charge_limit_reached or '
369
546
  'Actor.get_charging_manager().is_charge_limit_reached()).',
370
- limit,
547
+ name[0].upper() + name[1:],
371
548
  )
372
549
  self._refused_default_dataset_items += count
373
550
 
@@ -927,6 +1104,7 @@ class ChargingManager:
927
1104
  deadline=deadline,
928
1105
  )
929
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)
930
1108
  return _read_accepted_count(body, count)
931
1109
  except Exception as e:
932
1110
  last_error = e
@@ -997,8 +1175,7 @@ class ChargingManager:
997
1175
  deadline=deadline,
998
1176
  )
999
1177
  except ChargeRejectedError as err:
1000
- if _is_charge_limit_rejection(err):
1001
- self._mark_charge_limit_hit()
1178
+ self._mark_rejected_charge(err)
1002
1179
  self._unwind_reservation(event_name, count)
1003
1180
  self._drop_pending(idempotency_key)
1004
1181
  logger.warning(
@@ -1010,7 +1187,7 @@ class ChargingManager:
1010
1187
  continue
1011
1188
  if accepted is not None:
1012
1189
  if accepted < count:
1013
- self._mark_charge_limit_hit()
1190
+ self._mark_partial_charge()
1014
1191
  self._unwind_reservation(event_name, count - accepted)
1015
1192
  self._drop_pending(idempotency_key)
1016
1193
  confirmed += 1
@@ -1100,7 +1277,8 @@ class ChargingManager:
1100
1277
  )
1101
1278
 
1102
1279
  if chargeable_count < count:
1103
- self._mark_charge_limit_hit()
1280
+ # The local budget gate: only maxTotalChargeUsd trims here.
1281
+ self._mark_charge_limit_hit(CHARGE_LIMIT_MAX_CHARGE)
1104
1282
 
1105
1283
  if chargeable_count <= 0:
1106
1284
  return ChargeResult(
@@ -1155,8 +1333,7 @@ class ChargingManager:
1155
1333
  idempotency_key=idempotency_key,
1156
1334
  )
1157
1335
  except ChargeRejectedError as rejected:
1158
- if _is_charge_limit_rejection(rejected):
1159
- self._mark_charge_limit_hit()
1336
+ self._mark_rejected_charge(rejected)
1160
1337
  # Server definitively rejected: unwind local reservation.
1161
1338
  self._drop_pending(idempotency_key)
1162
1339
  self._unwind_reservation(event_name, chargeable_count)
@@ -1171,7 +1348,7 @@ class ChargingManager:
1171
1348
  self._last_accepted_operation_id = idempotency_key
1172
1349
  if accepted < chargeable_count:
1173
1350
  # The platform capped the charge at the run's limit.
1174
- self._mark_charge_limit_hit()
1351
+ self._mark_partial_charge()
1175
1352
  self._unwind_reservation(event_name, chargeable_count - accepted)
1176
1353
  chargeable_count = accepted
1177
1354
  result = ChargeResult(
@@ -1223,11 +1400,12 @@ class ChargingManager:
1223
1400
  if chargeable_count < count:
1224
1401
  subject = 'instance' if count == 1 else 'instances'
1225
1402
  logger.info(
1226
- "Charging %s %s of '%s' event would exceed maxTotalChargeUsd - "
1403
+ "Charging %s %s of '%s' event would exceed %s - "
1227
1404
  'only %s events were charged',
1228
1405
  count,
1229
1406
  subject,
1230
1407
  event_name,
1408
+ self._limit_name(),
1231
1409
  chargeable_count,
1232
1410
  )
1233
1411
 
@@ -1421,6 +1599,43 @@ def _read_remaining_budget_nd(body: Any) -> Optional[int]:
1421
1599
  return None
1422
1600
 
1423
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
+
1424
1639
  def _read_accepted_count(body: Any, fallback: int) -> int:
1425
1640
  if not isinstance(body, dict):
1426
1641
  return fallback
@@ -1446,6 +1661,9 @@ def _classify_charge_http_error(
1446
1661
 
1447
1662
  # 401/403: ownership or scoped-run authorization failures are permanent.
1448
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
+ )
1449
1667
  return ChargeRejectedError(
1450
1668
  f'Charge rejected by platform (HTTP {status}) for event {event_name!r}',
1451
1669
  operation_id=operation_id,
@@ -1453,6 +1671,8 @@ def _classify_charge_http_error(
1453
1671
  count=count,
1454
1672
  status_code=int(status) if status is not None else None,
1455
1673
  reason='charge-limit-exceeded' if status == 402 else f'http-{status}',
1674
+ limit_reason=limit_reason,
1675
+ max_items=max_items,
1456
1676
  )
1457
1677
 
1458
1678
  # A 5xx / 408 / 429 answer is never a definitive rejection, whatever its text
@@ -1472,6 +1692,11 @@ def _classify_charge_http_error(
1472
1692
  'conflict',
1473
1693
  )
1474
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
+ )
1475
1700
  return ChargeRejectedError(
1476
1701
  message,
1477
1702
  operation_id=operation_id,
@@ -1479,6 +1704,8 @@ def _classify_charge_http_error(
1479
1704
  count=count,
1480
1705
  status_code=int(status) if isinstance(status, int) else None,
1481
1706
  reason='message-match',
1707
+ limit_reason=limit_reason,
1708
+ max_items=max_items,
1482
1709
  )
1483
1710
  return None
1484
1711
 
@@ -1580,7 +1807,7 @@ async def push_data_and_charge(
1580
1807
  limited_items = items[:limited_count] if limited_count < len(items) else items
1581
1808
  if is_ppe and is_default_dataset and limited_count < len(items):
1582
1809
  # Rows beyond what the budget can pay for are dropped.
1583
- charging_manager._mark_charge_limit_hit()
1810
+ charging_manager._mark_charge_limit_hit(CHARGE_LIMIT_MAX_CHARGE)
1584
1811
  charging_manager._note_refused_default_dataset_items(len(items) - limited_count)
1585
1812
 
1586
1813
  if not limited_items:
scrapely/http/_client.py CHANGED
@@ -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
- __slots__ = ('identity', 'alock', 'tlock')
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
- assert error is not None
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
- assert error is not None
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
scrapely/solver.py CHANGED
@@ -62,6 +62,7 @@ from typing import Any, Awaitable, Callable, Optional
62
62
  import httpx
63
63
 
64
64
  __all__ = [
65
+ "AkamaiSensor",
65
66
  "Clearance",
66
67
  "DEGRADED_STATUS_MESSAGE",
67
68
  "IpBanned",
@@ -72,11 +73,16 @@ __all__ = [
72
73
  "SolverAuthError",
73
74
  "SolverRateLimited",
74
75
  "SolverRequestInvalid",
76
+ "SensorResult",
75
77
  "SolverUnavailable",
76
78
  "VendorUnavailable",
79
+ "abck_state",
77
80
  "check_solver_response",
78
81
  "degraded_status_message",
79
82
  "detect_vendor",
83
+ "find_pixel_collector",
84
+ "find_sbsd_bundle",
85
+ "find_sensor_script",
80
86
  "is_blocked",
81
87
  "solver_env",
82
88
  "solver_status",
@@ -874,3 +880,361 @@ class Solver:
874
880
  if not ok:
875
881
  raise SolveAttemptError("still blocked after the clearance was applied")
876
882
  return clearance
883
+
884
+
885
+ # ─── Akamai Bot Manager on your own session (sensor WebSocket lane + SBSD) ──
886
+
887
+
888
+ _SCRIPT_SRC_RE = re.compile(r"<script[^>]+src=[\"']([^\"']+)[\"']", re.I)
889
+ _SBSD_NONCE_RE = re.compile(r"(?:^|&)v=[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(?:&|$)", re.I)
890
+ _PIXEL_PATH_RE = re.compile(r"^/akam/\d+/")
891
+ _LONG_PATH_RE = re.compile(r"<script[^>]+src=[\"']((?:/[a-zA-Z0-9\-_]+){5,10}(?:\?v=[^\"']*)?)[\"']", re.I)
892
+ _AKAM_PIXEL_TAG_RE = re.compile(r"akam/13.*?top:\s?-999px.*?src=\"(.*?)\"", re.S)
893
+ _AKAM_KEYWORD_RE = re.compile(r"<script[^>]+src=[\"']([^\"']*/(?:akam|_abck|bm-)[^\"']+)[\"']", re.I)
894
+ _STRIP_SCRIPTS_RE = re.compile(r"<script\b[^<]*(?:(?!</script>)<[^<]*)*</script>", re.I)
895
+ # Headers the submission must carry from YOUR client (its fingerprint), never the solver's copy.
896
+ _OWN_HEADERS = {"user-agent", "cookie", "host", "content-length", "accept-language", "accept-encoding", "connection",
897
+ "sec-ch-ua", "sec-ch-ua-mobile", "sec-ch-ua-platform"}
898
+
899
+
900
+ def _urljoin(base: str, ref: str) -> str:
901
+ from urllib.parse import urljoin
902
+
903
+ return urljoin(base, ref)
904
+
905
+
906
+ def _is_sbsd_bundle(url: str) -> bool:
907
+ u = httpx.URL(url)
908
+ return u.path.startswith("/.well-known/sbsd") or bool(_SBSD_NONCE_RE.search(u.query.decode() if isinstance(u.query, bytes) else str(u.query or "")))
909
+
910
+
911
+ def _is_sensor_candidate(url: str) -> bool:
912
+ return not _is_sbsd_bundle(url) and not _PIXEL_PATH_RE.match(httpx.URL(url).path)
913
+
914
+
915
+ def _sbsd_src(html: str, base_url: str) -> tuple[str, str] | None:
916
+ for m in _SCRIPT_SRC_RE.finditer(html or ""):
917
+ u = _urljoin(base_url, m.group(1))
918
+ if _is_sbsd_bundle(u):
919
+ return m.group(1), u
920
+ return None
921
+
922
+
923
+ def find_sbsd_bundle(html: str, base_url: str) -> str | None:
924
+ """URL of the SBSD bundle script a page loads (``src`` with a UUID ``v=``, or ``/.well-known/sbsd``), or None."""
925
+ found = _sbsd_src(html, base_url)
926
+ return found[1] if found else None
927
+
928
+
929
+ def find_sensor_script(html: str, base_url: str) -> str | None:
930
+ """URL of the Akamai ``_abck`` sensor script in a page, or None (the provider examples' three patterns)."""
931
+ html = html or ""
932
+ m = _AKAM_PIXEL_TAG_RE.search(html)
933
+ if m:
934
+ u = _urljoin(base_url, m.group(1))
935
+ if _is_sensor_candidate(u):
936
+ return u
937
+ for m in _LONG_PATH_RE.finditer(html):
938
+ u = _urljoin(base_url, m.group(1))
939
+ if _is_sensor_candidate(u):
940
+ return u
941
+ m = _AKAM_KEYWORD_RE.search(html)
942
+ if m:
943
+ u = _urljoin(base_url, m.group(1))
944
+ if _is_sensor_candidate(u):
945
+ return u
946
+ return None
947
+
948
+
949
+ _PIXEL_COLLECTOR_RE = re.compile(r"<script[^>]+src=[\"']([^\"']*/akam/\d+/[0-9a-f]{6,})[\"']", re.I)
950
+
951
+
952
+ def find_pixel_collector(html: str, base_url: str) -> str | None:
953
+ """URL of Akamai's pixel collector script (``/akam/<n>/<hex>``, it POSTs to ``/akam/<n>/pixel_<hex>``), or None."""
954
+ m = _PIXEL_COLLECTOR_RE.search(html or "")
955
+ return _urljoin(base_url, m.group(1)) if m else None
956
+
957
+
958
+ def pixel_target(collector_url: str) -> str:
959
+ """The endpoint a pixel collector POSTs to: its own directory, ``pixel_`` + its basename, no query."""
960
+ u = httpx.URL(collector_url)
961
+ head, _, name = u.path.rpartition("/")
962
+ return f"{u.scheme}://{u.host}{head}/pixel_{name}"
963
+
964
+
965
+ def abck_state(value: str | None) -> str | None:
966
+ """The state field of an ``_abck`` cookie: ``'0'`` accepted, ``'-1'`` keep going, None when absent."""
967
+ parts = (value or "").split("~")
968
+ return parts[1] if len(parts) > 1 else None
969
+
970
+
971
+ def _cookie_dict(session: Any) -> dict[str, str]:
972
+ jar = getattr(session, "cookies", None)
973
+ if jar is None:
974
+ return {}
975
+ try:
976
+ if hasattr(jar, "jar"):
977
+ return {c.name: c.value for c in jar.jar}
978
+ return dict(jar.items())
979
+ except Exception: # noqa: BLE001
980
+ return {}
981
+
982
+
983
+ def _ws_connect(url: str, headers: dict[str, str]) -> Any:
984
+ """websockets.connect with request headers (``additional_headers`` from 13, ``extra_headers`` before)."""
985
+ import websockets
986
+
987
+ try:
988
+ return websockets.connect(url, additional_headers=headers, max_size=32 * 1024 * 1024, open_timeout=30)
989
+ except TypeError:
990
+ return websockets.connect(url, extra_headers=headers, max_size=32 * 1024 * 1024, open_timeout=30)
991
+
992
+
993
+ def _refused_upgrade(exc: BaseException) -> tuple[int, str, str | None]:
994
+ """(status, body, Retry-After) of a refused WebSocket upgrade, across websockets versions."""
995
+ response = getattr(exc, "response", None)
996
+ if response is not None:
997
+ status = int(getattr(response, "status_code", 0) or 0)
998
+ raw = getattr(response, "body", b"") or b""
999
+ headers = getattr(response, "headers", None)
1000
+ return status, raw.decode("utf-8", "replace") if isinstance(raw, (bytes, bytearray)) else str(raw), headers.get("retry-after") if headers is not None else None
1001
+ status = int(getattr(exc, "status_code", 0) or 0)
1002
+ headers = getattr(exc, "headers", None)
1003
+ return status, "", headers.get("retry-after") if headers is not None else None
1004
+
1005
+
1006
+ def _is_scrapely_http(session: Any) -> bool:
1007
+ return type(session).__module__.startswith("scrapely.http")
1008
+
1009
+
1010
+ async def _session_send(session: Any, method: str, url: str, *, headers: dict, body: str | bytes | None, kind: str, timeout: float) -> tuple[int, str]:
1011
+ kwargs: dict[str, Any] = {"headers": headers, "timeout": timeout}
1012
+ if body is not None:
1013
+ data = body.encode() if isinstance(body, str) else body
1014
+ kwargs["content" if (_is_httpx(session) or _is_scrapely_http(session)) else "data"] = data
1015
+ if _is_scrapely_http(session):
1016
+ # One identity for the whole solve: no rotation, and a 403 is an answer, not a block to route around.
1017
+ kwargs.update(kind=kind, max_rotations=0, is_blocked=lambda r: None)
1018
+ r = await session.request(method, url, **kwargs)
1019
+ return _status_body(r)
1020
+
1021
+
1022
+ @dataclass
1023
+ class SensorResult:
1024
+ """Outcome of ``AkamaiSensor.solve_abck``: whether the provider reported ``_abck`` accepted."""
1025
+
1026
+ accepted: bool
1027
+ rounds: int = 0
1028
+ submissions: int = 0
1029
+ abck: str | None = None
1030
+ error: str | None = None
1031
+
1032
+
1033
+ class AkamaiSensor:
1034
+ """Akamai Bot Manager solved on YOUR HTTP session, the way the provider's own examples do it.
1035
+
1036
+ Two independent channels, both answered through the session you pass, so
1037
+ every request carries your client's fingerprint, exit IP and cookie jar:
1038
+
1039
+ * ``solve_sbsd(session, url, html)``: the page loads an SBSD bundle (a
1040
+ script whose ``src`` has a UUID ``v=``, often a ~440-byte "Access Denied"
1041
+ bootstrap). The bundle is fetched on your session, the provider computes
1042
+ the ledger (``/akamai/sbsd/generate-session``), and its rows are POSTed to
1043
+ the bundle path on your session.
1044
+ * ``solve_abck(session, url, html)``: the classic ``_abck`` sensor over the
1045
+ provider's stateful WebSocket (``/akamai/session``, relayed by the
1046
+ platform for runs). The provider generates each sensor; it is POSTed on
1047
+ your session; rounds continue until ``_abck`` is ``~0~``.
1048
+
1049
+ ``solve(session, url, html)`` does SBSD when the page carries a bundle, then ``_abck``.
1050
+
1051
+ Use a session that keeps its identity on a block (``http.session(keep_identity=True)``
1052
+ of ``scrapely.http``, or one curl_cffi / httpx client): the solve, the
1053
+ retries and the page fetches must share one identity, jar and exit. The
1054
+ ``profile_id`` must be the browser your client claims (``chrome-154-macos``
1055
+ for scrapely.http Chrome 154 on macOS, ``chrome-146-macos`` for curl_cffi
1056
+ ``impersonate="chrome146"``); a mismatch shows as ``_abck`` stuck at ``~-1~``.
1057
+
1058
+ Example::
1059
+
1060
+ http = AsyncClient(proxy_configuration=proxy, fingerprint_options=FingerprintOptions(
1061
+ browsers=[{"name": "chrome", "min_version": 154, "max_version": 154}], operating_systems=["macos"]))
1062
+ session = http.session(keep_identity=True)
1063
+ akamai = AkamaiSensor(profile_id="chrome-154-macos")
1064
+ home = await session.get("https://www.example.com/")
1065
+ result = await akamai.solve(session, "https://www.example.com/", home.text)
1066
+ page = await session.get("https://www.example.com/item/1")
1067
+ """
1068
+
1069
+ def __init__(
1070
+ self,
1071
+ profile_id: str = "chrome-154-macos",
1072
+ *,
1073
+ telemetry: dict | None = None,
1074
+ timeout: float = 90.0,
1075
+ request_timeout: float = 30.0,
1076
+ max_rounds: int = 12,
1077
+ ) -> None:
1078
+ self.profile_id = profile_id
1079
+ self.telemetry = telemetry
1080
+ self.timeout = timeout
1081
+ self.request_timeout = request_timeout
1082
+ self.max_rounds = max_rounds
1083
+ self.sessions = 0
1084
+ self.accepted = 0
1085
+
1086
+ @staticmethod
1087
+ def _endpoint() -> tuple[str, str]:
1088
+ env = solver_env()
1089
+ if env is None:
1090
+ raise VendorUnavailable("solver_not_enabled", "akamai", None, "the platform solver is not available to this run")
1091
+ return env
1092
+
1093
+ # -- SBSD --------------------------------------------------------------------
1094
+
1095
+ async def solve_sbsd(self, session: Any, url: str, html: str) -> bool:
1096
+ """Answers the SBSD bundle of `html` (the page `url` as your session got it). False when there is none."""
1097
+ found = _sbsd_src(html, url)
1098
+ if not found:
1099
+ return False
1100
+ src_attr, bundle_url = found
1101
+ base, key = self._endpoint()
1102
+ origin = f"{httpx.URL(url).scheme}://{httpx.URL(url).host}"
1103
+ _, source = await _session_send(session, "GET", bundle_url, headers={"referer": url, "sec-fetch-dest": "script", "sec-fetch-mode": "no-cors", "sec-fetch-site": "same-origin", "accept": "*/*"}, body=None, kind="xhr", timeout=self.request_timeout)
1104
+ cookie_header = "; ".join(f"{k}={v}" for k, v in _cookie_dict(session).items())
1105
+ async with httpx.AsyncClient(timeout=self.timeout) as api:
1106
+ r = await api.post(
1107
+ f"{base}/akamai/sbsd/generate-session",
1108
+ headers={"x-api-key": key},
1109
+ json={"bundle": {"scriptSrc": src_attr, "source": source}, "document": {"url": url, "html": html, "cookieHeader": cookie_header}, "profile": {"id": self.profile_id}},
1110
+ )
1111
+ check_solver_response(r.status_code, r.text, r.headers.get("retry-after"))
1112
+ data = _json(r.text)
1113
+ rows = sorted([s for s in data.get("submissions") or [] if isinstance(s, dict) and s.get("resolvedUrl")], key=lambda s: s.get("index", 0))
1114
+ if not rows:
1115
+ raise SolveAttemptError(f"the SBSD ledger had no rows ({str(data.get('error') or data.get('outcome') or 'no reason')[:120]})")
1116
+ for row in rows:
1117
+ target = _urljoin(url, row["resolvedUrl"])
1118
+ if not _step_allowed(target, url):
1119
+ raise SolveAttemptError("the SBSD ledger pointed outside the target site; refused")
1120
+ await _session_send(session, "POST", target, headers={"content-type": "text/plain;charset=UTF-8", "origin": origin, "referer": url, "sec-fetch-dest": "empty", "sec-fetch-mode": "cors", "sec-fetch-site": "same-origin", "accept": "*/*"}, body=row.get("body") or "", kind="xhr", timeout=self.request_timeout)
1121
+ return True
1122
+
1123
+ # -- pixel channel (/akam/<n>/<hex> collector) -----------------------------------
1124
+
1125
+ async def solve_pixel(self, session: Any, url: str, html: str) -> bool:
1126
+ """Answers Akamai's pixel channel of `html`: the collector script is fetched on your session, the solver
1127
+ writes the POST the collector would send (``/akamai/pixel/generate``), and it is POSTed to the collector's
1128
+ ``pixel_`` endpoint on your session. False when the page has no collector."""
1129
+ collector = find_pixel_collector(html, url)
1130
+ if not collector:
1131
+ return False
1132
+ base, key = self._endpoint()
1133
+ origin = f"{httpx.URL(url).scheme}://{httpx.URL(url).host}"
1134
+ _, source = await _session_send(session, "GET", collector, headers={"referer": url, "sec-fetch-dest": "script", "sec-fetch-mode": "no-cors", "sec-fetch-site": "same-origin", "accept": "*/*"}, body=None, kind="xhr", timeout=self.request_timeout)
1135
+ cookie_header = "; ".join(f"{k}={v}" for k, v in _cookie_dict(session).items())
1136
+ async with httpx.AsyncClient(timeout=self.timeout) as api:
1137
+ r = await api.post(
1138
+ f"{base}/akamai/pixel/generate",
1139
+ headers={"x-api-key": key, "accept": "application/json"},
1140
+ json={"document": {"url": url, "html": html, **({"cookieHeader": cookie_header} if cookie_header else {})},
1141
+ "profile": {"id": self.profile_id}, "script": {"source": source, "url": collector}},
1142
+ )
1143
+ check_solver_response(r.status_code, r.text, r.headers.get("retry-after"))
1144
+ data = _json(r.text)
1145
+ request = data.get("request") if isinstance(data.get("request"), dict) else {}
1146
+ if not data.get("ok") or not isinstance(request.get("body"), str):
1147
+ err = data.get("error") if isinstance(data.get("error"), dict) else {}
1148
+ raise SolveAttemptError(f"the pixel route refused ({err.get('code') or r.status_code})")
1149
+ # The collector's own endpoint, not the solver's reconstruction of it (the provider examples do the same).
1150
+ target = pixel_target(collector)
1151
+ await _session_send(session, "POST", target, headers={"content-type": request.get("contentType") or "application/x-www-form-urlencoded", "origin": origin, "referer": url, "sec-fetch-dest": "empty", "sec-fetch-mode": "cors", "sec-fetch-site": "same-origin", "accept": "*/*"}, body=request["body"], kind="xhr", timeout=self.request_timeout)
1152
+ return True
1153
+
1154
+ # -- _abck sensor (WebSocket) ---------------------------------------------------
1155
+
1156
+ async def solve_abck(self, session: Any, url: str, html: str, *, script_url: str | None = None) -> SensorResult:
1157
+ """Runs the ``_abck`` sensor rounds for the page `url` (its `html` as your session got it)."""
1158
+ import websockets
1159
+
1160
+ script_url = script_url or find_sensor_script(html, url)
1161
+ if not script_url:
1162
+ return SensorResult(accepted=False, error="no Akamai sensor script in the page")
1163
+ base, key = self._endpoint()
1164
+ origin = f"{httpx.URL(url).scheme}://{httpx.URL(url).host}"
1165
+ _, script = await _session_send(session, "GET", script_url, headers={"referer": url, "sec-fetch-dest": "script", "sec-fetch-mode": "no-cors", "sec-fetch-site": "same-origin", "accept": "*/*"}, body=None, kind="xhr", timeout=self.request_timeout)
1166
+ init: dict[str, Any] = {
1167
+ "type": "init", "url": url, "script": script, "scriptUrl": script_url, "html": _STRIP_SCRIPTS_RE.sub("", html or ""),
1168
+ "cookies": _cookie_dict(session), "profileId": self.profile_id,
1169
+ }
1170
+ if self.telemetry:
1171
+ init["profile"] = self.telemetry
1172
+ ws_url = re.sub(r"^http", "ws", base, flags=re.I) + "/akamai/session"
1173
+ result = SensorResult(accepted=False)
1174
+ self.sessions += 1
1175
+ deadline = time.monotonic() + self.timeout
1176
+ try:
1177
+ async with _ws_connect(ws_url, {"x-api-key": key}) as ws:
1178
+ await ws.send(json.dumps(init))
1179
+ while True:
1180
+ left = deadline - time.monotonic()
1181
+ if left <= 0:
1182
+ result.error = f"not accepted within {self.timeout:g}s"
1183
+ break
1184
+ try:
1185
+ raw = await asyncio.wait_for(ws.recv(), timeout=left)
1186
+ except asyncio.TimeoutError:
1187
+ result.error = f"not accepted within {self.timeout:g}s"
1188
+ break
1189
+ msg = _json(raw if isinstance(raw, str) else raw.decode("utf-8", "replace"))
1190
+ kind = msg.get("type")
1191
+ if kind == "submission":
1192
+ result.submissions += 1
1193
+ if result.submissions > self.max_rounds:
1194
+ result.error = f"not accepted after {self.max_rounds} rounds"
1195
+ break
1196
+ target = str(msg.get("url") or "")
1197
+ headers = {k: v for k, v in (msg.get("headers") or {}).items() if k.lower() not in _OWN_HEADERS}
1198
+ headers.setdefault("origin", origin)
1199
+ headers.setdefault("referer", url)
1200
+ if not _step_allowed(target, url):
1201
+ await ws.send(json.dumps({"type": "submission_response", "id": msg.get("id"), "status": 0, "body": "Client error: refused a submission outside the target site"}))
1202
+ continue
1203
+ try:
1204
+ status, body = await _session_send(session, str(msg.get("method") or "POST"), target, headers=headers, body=msg.get("body") or "", kind="xhr", timeout=self.request_timeout)
1205
+ except Exception as exc: # noqa: BLE001
1206
+ status, body = 0, f"Client error: {type(exc).__name__}"
1207
+ await ws.send(json.dumps({"type": "submission_response", "id": msg.get("id"), "status": status, "body": body, "cookies": _cookie_dict(session)}))
1208
+ elif kind == "cookie_update":
1209
+ result.rounds = int(msg.get("round") or result.rounds + 1)
1210
+ result.abck = abck_state((msg.get("cookies") or {}).get("_abck")) or result.abck
1211
+ if msg.get("accepted"):
1212
+ result.accepted = True
1213
+ break
1214
+ elif kind == "status" and str(msg.get("state") or "") == "accepted":
1215
+ result.accepted = True
1216
+ break
1217
+ elif kind == "error":
1218
+ result.error = str(msg.get("message") or msg.get("code") or "solver error")[:300]
1219
+ if msg.get("code") == "invalid_request":
1220
+ raise SolverRequestInvalid(result.error)
1221
+ except websockets.exceptions.InvalidHandshake as exc: # a refused upgrade carries the gateway's JSON error
1222
+ status, body, retry_after = _refused_upgrade(exc)
1223
+ if status:
1224
+ check_solver_response(status, body, retry_after)
1225
+ raise SolveAttemptError(f"solver session refused ({status or type(exc).__name__})") from exc
1226
+ except (OSError, asyncio.TimeoutError, websockets.exceptions.WebSocketException) as exc:
1227
+ if not result.accepted:
1228
+ result.error = result.error or f"solver session failed: {type(exc).__name__}"
1229
+ result.abck = abck_state(_cookie_dict(session).get("_abck")) or result.abck
1230
+ if result.accepted:
1231
+ self.accepted += 1
1232
+ return result
1233
+
1234
+ async def solve(self, session: Any, url: str, html: str) -> SensorResult:
1235
+ """SBSD when the page carries a bundle, then the ``_abck`` sensor (on the page as it is after SBSD)."""
1236
+ if find_sbsd_bundle(html, url):
1237
+ await self.solve_sbsd(session, url, html)
1238
+ if not find_sensor_script(html, url) or len(html) < 5000:
1239
+ _, html = await _session_send(session, "GET", url, headers={"referer": url}, body=None, kind="navigate", timeout=self.request_timeout)
1240
+ return await self.solve_abck(session, url, html)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scrapely-python
3
- Version: 0.1.40
3
+ Version: 0.1.42
4
4
  Summary: Scrapely Python SDK - A toolkit for building Actors on the Scrapely platform
5
5
  Author-email: Scrapely <hello@scrape.ly>
6
6
  License-Expression: MIT AND Apache-2.0
@@ -1,9 +1,9 @@
1
- scrapely/__init__.py,sha256=cTs0rZD-AnrB6WLOzrhgGkh9XqmYj8Ihvqk80JFPjsM,9668
1
+ scrapely/__init__.py,sha256=5ifQCxUm8btxkp_9q1u6UPSgimx-D63FCey6Nydp6SM,9668
2
2
  scrapely/_browser_locations.py,sha256=8QVVnkms2bDn3_LDYgYDR59MvFx8757l_JYnZ7xb3oI,145269
3
- scrapely/actor.py,sha256=z-kL24Y6m98v6lKLOxGYCnFmhVhaHJ6c0uOdYZW_4ak,93514
3
+ scrapely/actor.py,sha256=nT-4xWfqx2Cd8swQ6DFkT8n8xYCoPy9a4dbA1bB-1GI,94391
4
4
  scrapely/browser_configuration.py,sha256=jAZapNSHLFW05B96df4wNBtcyiiU9DA3ml0yTL1FwfA,5770
5
5
  scrapely/cdp_retry.py,sha256=hdTJEAIKhApM7IOk1DrQEl8NoflSJSmqd4RbNJ2IsgI,8540
6
- scrapely/charging.py,sha256=xWYBFSr20UN5ySW0Vp1g0Dl6SppXN1y9qvMPQ1B0F1M,95615
6
+ scrapely/charging.py,sha256=qnlqSjKeaJnWepUl9alLytHooihRIVU2CruIYGlFcnk,106445
7
7
  scrapely/charging_client.py,sha256=-4q-Ww7gu9EZXpM9QIn1Vd4vwAXmgL_WEKqaQIx3ViA,3961
8
8
  scrapely/configuration.py,sha256=ONnzw4mhtrY2jVvoVUQ48dMgmMWnx3Gv7fXrT4pY9z8,16919
9
9
  scrapely/errors.py,sha256=V8JBCGSyqrbwMD9GDv6ahWhZ92n2cb8AWxkxth6mcI8,4062
@@ -12,7 +12,7 @@ scrapely/patch_browser_pool.py,sha256=eZck-JSOvCuXdoGQaX6xUsKVGzolDlB4uKiBSBrFyr
12
12
  scrapely/pricing_types.py,sha256=yy2nXmCxL0BvUXRtWnznwhoInYeVdWObMRT5Fo0fc4I,5083
13
13
  scrapely/proxy_configuration.py,sha256=YAg2xm8eK4B14vCRWsSJxgtMIxdDJXbM66pZDx-HShk,39018
14
14
  scrapely/py.typed,sha256=XtWlywqUjc6x_M2gAXlDtYXbAsYkCjKZEjFyJvaHvm4,105
15
- scrapely/solver.py,sha256=RVoLNJTxniTI5SzDRpUdxe0ajzy4qqjuEmToTL1GRzA,35863
15
+ scrapely/solver.py,sha256=9wVldJmye1P1f4c4bfZAMoRMRYB_CzmGY0Y2ZkI1nLI,55358
16
16
  scrapely/types.py,sha256=2yGedPfb5786UNqnlnRG5Cw4Pbyc4EPBiYeoU2kix3I,8812
17
17
  scrapely/webhook.py,sha256=GUpQXQ1UYCKi3WZPJ9reztpilBXvwqYInXJxUax36M8,2823
18
18
  scrapely/crypto/__init__.py,sha256=MbMPs7Eq_fGSVjOZxNts_JckDCUtxCVqdkHU0VCfN4M,481
@@ -22,7 +22,7 @@ scrapely/events/_types.py,sha256=XslMIEAQOe7othqevnxO86c7i7AysbvDLHH0lep64QU,599
22
22
  scrapely/events/event_manager.py,sha256=svqrMb3KtSLvWBBoDrL6ozXaBE-IlHrdrh-rFPDnveM,12988
23
23
  scrapely/http/__init__.py,sha256=KGbINWi2WY6tN4CTM6c4-RIOQGL6iV3giNCYoGYJfWU,1949
24
24
  scrapely/http/_capture.py,sha256=wtS3bl_hNjICgxg3Iu_aL4uniQ_6xzXk_XsxGzpoBLE,3024
25
- scrapely/http/_client.py,sha256=_JoW2u3NCpmBdfZbGyJAqrCTIkCBcxvnLPO1Cw0yeKc,48651
25
+ scrapely/http/_client.py,sha256=VccwfsgOlzUQeRMaFVP7oBOw32Fcp89MIUePpinEWCQ,51547
26
26
  scrapely/http/_compat.py,sha256=si8V3SAb-FDBcdUKuQHMCthRjNR6_oFlg8rpIKyEv2g,4082
27
27
  scrapely/http/_detect.py,sha256=NXMonGYv0jFwG7ONY6MPdhJadTRL2LbyPss-HVbE5J4,5433
28
28
  scrapely/http/_headers.py,sha256=jJMYYb6SpcMmIQ5ETTLVZi2xSA40wW_pt8UxzP2EMMQ,16633
@@ -68,10 +68,10 @@ scrapely/storage/_file_system/_key_value_store_client.py,sha256=1C22qY-RWlARzMyW
68
68
  scrapely/storage/_file_system/_storage_client.py,sha256=QHa3IULEb6AmgRFwpBqHD2OUTmtA_gl3lId-j6QdSFo,2027
69
69
  scrapely/storage_clients/__init__.py,sha256=q2pC-Rwe-BfHl018OyN_P0EDSx4-hNdxbk3pOCLLd4U,618
70
70
  scrapely/storages/__init__.py,sha256=kDNtd9VodjdHd4u7GzruJo0aB2HeiBN2_-HrkeqjmFg,139
71
- scrapely_python-0.1.40.dist-info/licenses/LICENSE,sha256=pQGY0XwjIYh-a2HfawxHq1en53bXkFkDj2Q70jKWHr4,1088
72
- scrapely_python-0.1.40.dist-info/licenses/LICENSE-APACHE,sha256=N8m0FzMq8i4UbTgXZvKEZJSD-27OF8pWZYS_atz4kTU,11355
73
- scrapely_python-0.1.40.dist-info/licenses/NOTICE,sha256=1ltrzKk2ez7x2BPNpXeyvOlTty5AfABYDBlwNilpORk,788
74
- scrapely_python-0.1.40.dist-info/METADATA,sha256=Dqdbn37BPsue4GTs3scUAmHGJUjmHGRDOX9ByjWuah8,4914
75
- scrapely_python-0.1.40.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
76
- scrapely_python-0.1.40.dist-info/top_level.txt,sha256=T4B9UMrs4q99QSAFyD1zdXhl40KnLhBKqrDkPhOTsmY,9
77
- scrapely_python-0.1.40.dist-info/RECORD,,
71
+ scrapely_python-0.1.42.dist-info/licenses/LICENSE,sha256=pQGY0XwjIYh-a2HfawxHq1en53bXkFkDj2Q70jKWHr4,1088
72
+ scrapely_python-0.1.42.dist-info/licenses/LICENSE-APACHE,sha256=N8m0FzMq8i4UbTgXZvKEZJSD-27OF8pWZYS_atz4kTU,11355
73
+ scrapely_python-0.1.42.dist-info/licenses/NOTICE,sha256=1ltrzKk2ez7x2BPNpXeyvOlTty5AfABYDBlwNilpORk,788
74
+ scrapely_python-0.1.42.dist-info/METADATA,sha256=sZW9HkpiYLFvgRsQ-DVb-ZcfqUSH5zodw9nNMMw8wgM,4914
75
+ scrapely_python-0.1.42.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
76
+ scrapely_python-0.1.42.dist-info/top_level.txt,sha256=T4B9UMrs4q99QSAFyD1zdXhl40KnLhBKqrDkPhOTsmY,9
77
+ scrapely_python-0.1.42.dist-info/RECORD,,