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 +1 -1
- scrapely/actor.py +17 -0
- scrapely/charging.py +244 -17
- scrapely/http/_client.py +71 -15
- scrapely/solver.py +364 -0
- {scrapely_python-0.1.40.dist-info → scrapely_python-0.1.42.dist-info}/METADATA +1 -1
- {scrapely_python-0.1.40.dist-info → scrapely_python-0.1.42.dist-info}/RECORD +12 -12
- {scrapely_python-0.1.40.dist-info → scrapely_python-0.1.42.dist-info}/WHEEL +0 -0
- {scrapely_python-0.1.40.dist-info → scrapely_python-0.1.42.dist-info}/licenses/LICENSE +0 -0
- {scrapely_python-0.1.40.dist-info → scrapely_python-0.1.42.dist-info}/licenses/LICENSE-APACHE +0 -0
- {scrapely_python-0.1.40.dist-info → scrapely_python-0.1.42.dist-info}/licenses/NOTICE +0 -0
- {scrapely_python-0.1.40.dist-info → scrapely_python-0.1.42.dist-info}/top_level.txt +0 -0
scrapely/__init__.py
CHANGED
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
|
|
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
|
-
|
|
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
|
-
'
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
108
|
+
``keep``: the identity is never retired or swapped by the client (no
|
|
109
|
+
rotation on a block or error, no usage/age expiry); only ``rotate()`` or
|
|
110
|
+
closing the session moves it to a new one.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
__slots__ = ('identity', 'alock', 'tlock', 'keep')
|
|
108
114
|
|
|
109
|
-
def __init__(self) -> None:
|
|
115
|
+
def __init__(self, keep: bool = False) -> None:
|
|
110
116
|
self.identity: Optional[Identity] = None
|
|
111
117
|
self.alock: Optional[asyncio.Lock] = None
|
|
112
118
|
self.tlock = threading.Lock()
|
|
119
|
+
self.keep = keep
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _no_rotation(decision: Any) -> Any:
|
|
123
|
+
"""The policy's decision with the rotation taken out (kept identities): stop here, retire nothing."""
|
|
124
|
+
try:
|
|
125
|
+
return dataclasses.replace(decision, retry=False, retire=False, delay=0.0)
|
|
126
|
+
except TypeError:
|
|
127
|
+
decision.retry = False
|
|
128
|
+
decision.retire = False
|
|
129
|
+
return decision
|
|
113
130
|
|
|
114
131
|
|
|
115
132
|
class CookieStore:
|
|
@@ -522,7 +539,7 @@ class AsyncClient(_Base):
|
|
|
522
539
|
pin.alock = asyncio.Lock()
|
|
523
540
|
async with pin.alock:
|
|
524
541
|
ident = pin.identity
|
|
525
|
-
if ident is not None and ident.expired(self._clock(), self.max_usage, self.max_age):
|
|
542
|
+
if ident is not None and not pin.keep and ident.expired(self._clock(), self.max_usage, self.max_age):
|
|
526
543
|
pin.identity = None
|
|
527
544
|
await self._retire(ident)
|
|
528
545
|
ident = None
|
|
@@ -558,6 +575,8 @@ class AsyncClient(_Base):
|
|
|
558
575
|
async def _release(self, pin: Optional[_Pin], ident: Identity, retire: bool) -> None:
|
|
559
576
|
ident.in_flight -= 1
|
|
560
577
|
if pin is not None:
|
|
578
|
+
if pin.keep and pin.identity is ident:
|
|
579
|
+
return
|
|
561
580
|
if retire or not self.sticky:
|
|
562
581
|
if pin.identity is ident:
|
|
563
582
|
pin.identity = None
|
|
@@ -658,12 +677,16 @@ class AsyncClient(_Base):
|
|
|
658
677
|
# Cancelled or failed in our own code: nothing says the identity is burnt.
|
|
659
678
|
await self._release(_pin, ident, retire=False)
|
|
660
679
|
raise
|
|
680
|
+
if decision.retry and not decision.same_identity and _pin is not None and _pin.keep:
|
|
681
|
+
# A kept identity is never swapped: the block or error goes back to the caller as is.
|
|
682
|
+
decision = _no_rotation(decision)
|
|
661
683
|
if not decision.retry:
|
|
662
684
|
await self._release(_pin, ident, retire=decision.retire)
|
|
663
685
|
ok = resp is not None and reason is None and resp.status_code < 500
|
|
664
686
|
self._stats.record_request(ok)
|
|
665
687
|
if resp is None:
|
|
666
|
-
|
|
688
|
+
if error is None:
|
|
689
|
+
raise RuntimeError('scrapely.http: request ended with no response and no error')
|
|
667
690
|
raise error
|
|
668
691
|
resp.blocked = reason
|
|
669
692
|
return resp
|
|
@@ -703,14 +726,21 @@ class AsyncClient(_Base):
|
|
|
703
726
|
async def options(self, url: str, **kwargs: Any) -> Response:
|
|
704
727
|
return await self.request('OPTIONS', url, **kwargs)
|
|
705
728
|
|
|
706
|
-
def session(self) -> 'AsyncHttpSession':
|
|
729
|
+
def session(self, keep_identity: bool = False) -> 'AsyncHttpSession':
|
|
707
730
|
"""A pinned identity for multi-step flows (homepage → API with its cookies).
|
|
708
731
|
|
|
709
732
|
All requests of the session use one profile, proxy session and cookie
|
|
710
733
|
jar. On a block the whole identity is retired and the session continues
|
|
711
734
|
on a fresh one (new IP, new profile, empty jar).
|
|
735
|
+
|
|
736
|
+
``keep_identity=True`` keeps the identity and its jar on a block or an
|
|
737
|
+
error too: the blocked response comes back (``response.blocked`` names
|
|
738
|
+
the block) and the next request goes out on the same profile, exit and
|
|
739
|
+
cookies. That is what a challenge solve needs (the SBSD bundle, the
|
|
740
|
+
sensor submissions and the page fetches must share one identity).
|
|
741
|
+
``rotate()`` moves the session to a fresh identity when you decide to.
|
|
712
742
|
"""
|
|
713
|
-
return AsyncHttpSession(self)
|
|
743
|
+
return AsyncHttpSession(self, keep_identity=keep_identity)
|
|
714
744
|
|
|
715
745
|
async def aclose(self) -> None:
|
|
716
746
|
if self._closed:
|
|
@@ -733,11 +763,21 @@ class AsyncClient(_Base):
|
|
|
733
763
|
class AsyncHttpSession:
|
|
734
764
|
"""A pinned identity on an ``AsyncClient`` (see ``AsyncClient.session()``)."""
|
|
735
765
|
|
|
736
|
-
def __init__(self, client: AsyncClient) -> None:
|
|
766
|
+
def __init__(self, client: AsyncClient, keep_identity: bool = False) -> None:
|
|
737
767
|
self._client = client
|
|
738
|
-
self._pin = _Pin()
|
|
768
|
+
self._pin = _Pin(keep=keep_identity)
|
|
739
769
|
self.cookies = _SessionCookies(self)
|
|
740
770
|
|
|
771
|
+
@property
|
|
772
|
+
def keep_identity(self) -> bool:
|
|
773
|
+
return self._pin.keep
|
|
774
|
+
|
|
775
|
+
async def rotate(self) -> None:
|
|
776
|
+
"""Retire the current identity; the next request opens a fresh one (new exit, profile and jar)."""
|
|
777
|
+
if self._pin.identity is not None:
|
|
778
|
+
ident, self._pin.identity = self._pin.identity, None
|
|
779
|
+
await self._client._retire(ident, force=ident.in_flight <= 0)
|
|
780
|
+
|
|
741
781
|
@property
|
|
742
782
|
def profile(self) -> Optional[str]:
|
|
743
783
|
return self._pin.identity.profile.label if self._pin.identity else None
|
|
@@ -873,7 +913,7 @@ class Client(_Base):
|
|
|
873
913
|
if pin is not None:
|
|
874
914
|
with pin.tlock:
|
|
875
915
|
ident = pin.identity
|
|
876
|
-
if ident is not None and ident.expired(self._clock(), self.max_usage, self.max_age):
|
|
916
|
+
if ident is not None and not pin.keep and ident.expired(self._clock(), self.max_usage, self.max_age):
|
|
877
917
|
pin.identity = None
|
|
878
918
|
self._retire(ident)
|
|
879
919
|
ident = None
|
|
@@ -918,6 +958,8 @@ class Client(_Base):
|
|
|
918
958
|
with self._lock:
|
|
919
959
|
ident.in_flight -= 1
|
|
920
960
|
if pin is not None:
|
|
961
|
+
if pin.keep and pin.identity is ident:
|
|
962
|
+
return
|
|
921
963
|
if retire or not self.sticky:
|
|
922
964
|
with pin.tlock:
|
|
923
965
|
if pin.identity is ident:
|
|
@@ -1023,12 +1065,15 @@ class Client(_Base):
|
|
|
1023
1065
|
# Failed in our own code: nothing says the identity is burnt.
|
|
1024
1066
|
self._release(_pin, ident, retire=False)
|
|
1025
1067
|
raise
|
|
1068
|
+
if decision.retry and not decision.same_identity and _pin is not None and _pin.keep:
|
|
1069
|
+
decision = _no_rotation(decision)
|
|
1026
1070
|
if not decision.retry:
|
|
1027
1071
|
self._release(_pin, ident, retire=decision.retire)
|
|
1028
1072
|
ok = resp is not None and reason is None and resp.status_code < 500
|
|
1029
1073
|
self._stats.record_request(ok)
|
|
1030
1074
|
if resp is None:
|
|
1031
|
-
|
|
1075
|
+
if error is None:
|
|
1076
|
+
raise RuntimeError('scrapely.http: request ended with no response and no error')
|
|
1032
1077
|
raise error
|
|
1033
1078
|
resp.blocked = reason
|
|
1034
1079
|
return resp
|
|
@@ -1067,9 +1112,9 @@ class Client(_Base):
|
|
|
1067
1112
|
def options(self, url: str, **kwargs: Any) -> Response:
|
|
1068
1113
|
return self.request('OPTIONS', url, **kwargs)
|
|
1069
1114
|
|
|
1070
|
-
def session(self) -> 'HttpSession':
|
|
1115
|
+
def session(self, keep_identity: bool = False) -> 'HttpSession':
|
|
1071
1116
|
"""A pinned identity for multi-step flows (see ``AsyncClient.session``)."""
|
|
1072
|
-
return HttpSession(self)
|
|
1117
|
+
return HttpSession(self, keep_identity=keep_identity)
|
|
1073
1118
|
|
|
1074
1119
|
def close(self) -> None:
|
|
1075
1120
|
if self._closed:
|
|
@@ -1090,11 +1135,22 @@ class Client(_Base):
|
|
|
1090
1135
|
class HttpSession:
|
|
1091
1136
|
"""A pinned identity on a sync ``Client`` (see ``Client.session()``)."""
|
|
1092
1137
|
|
|
1093
|
-
def __init__(self, client: Client) -> None:
|
|
1138
|
+
def __init__(self, client: Client, keep_identity: bool = False) -> None:
|
|
1094
1139
|
self._client = client
|
|
1095
|
-
self._pin = _Pin()
|
|
1140
|
+
self._pin = _Pin(keep=keep_identity)
|
|
1096
1141
|
self.cookies = _SessionCookies(self)
|
|
1097
1142
|
|
|
1143
|
+
@property
|
|
1144
|
+
def keep_identity(self) -> bool:
|
|
1145
|
+
return self._pin.keep
|
|
1146
|
+
|
|
1147
|
+
def rotate(self) -> None:
|
|
1148
|
+
"""Retire the current identity; the next request opens a fresh one (new exit, profile and jar)."""
|
|
1149
|
+
with self._pin.tlock:
|
|
1150
|
+
ident, self._pin.identity = self._pin.identity, None
|
|
1151
|
+
if ident is not None:
|
|
1152
|
+
self._client._retire(ident, force=ident.in_flight <= 0)
|
|
1153
|
+
|
|
1098
1154
|
@property
|
|
1099
1155
|
def profile(self) -> Optional[str]:
|
|
1100
1156
|
return self._pin.identity.profile.label if self._pin.identity else None
|
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,9 +1,9 @@
|
|
|
1
|
-
scrapely/__init__.py,sha256=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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.
|
|
72
|
-
scrapely_python-0.1.
|
|
73
|
-
scrapely_python-0.1.
|
|
74
|
-
scrapely_python-0.1.
|
|
75
|
-
scrapely_python-0.1.
|
|
76
|
-
scrapely_python-0.1.
|
|
77
|
-
scrapely_python-0.1.
|
|
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,,
|
|
File without changes
|
|
File without changes
|
{scrapely_python-0.1.40.dist-info → scrapely_python-0.1.42.dist-info}/licenses/LICENSE-APACHE
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|