scrapper-tool 0.1.0__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.
- scrapper_tool/__init__.py +66 -0
- scrapper_tool/_logging.py +78 -0
- scrapper_tool/adapter.py +127 -0
- scrapper_tool/canary.py +297 -0
- scrapper_tool/errors.py +68 -0
- scrapper_tool/http.py +288 -0
- scrapper_tool/ladder.py +209 -0
- scrapper_tool/patterns/__init__.py +15 -0
- scrapper_tool/patterns/b.py +262 -0
- scrapper_tool/patterns/c.py +210 -0
- scrapper_tool/patterns/d.py +166 -0
- scrapper_tool/testing.py +215 -0
- scrapper_tool-0.1.0.dist-info/METADATA +235 -0
- scrapper_tool-0.1.0.dist-info/RECORD +17 -0
- scrapper_tool-0.1.0.dist-info/WHEEL +4 -0
- scrapper_tool-0.1.0.dist-info/entry_points.txt +2 -0
- scrapper_tool-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""scrapper-tool — reusable web-scraping toolkit.
|
|
2
|
+
|
|
3
|
+
Pattern A/B/C/D ladder, TLS-impersonation fallback chain, deterministic
|
|
4
|
+
fixture-replay testing, and (v0.2.0+) an optional MCP server for LLM agents.
|
|
5
|
+
|
|
6
|
+
MCP integration is available via ``pip install scrapper-tool[agent]`` —
|
|
7
|
+
see ``docs/agent-integration.md`` (M13).
|
|
8
|
+
|
|
9
|
+
Public API
|
|
10
|
+
----------
|
|
11
|
+
|
|
12
|
+
The top-level ``scrapper_tool`` namespace re-exports the most commonly used
|
|
13
|
+
symbols. Submodules expose the rest:
|
|
14
|
+
|
|
15
|
+
- ``scrapper_tool.http`` — :func:`vendor_client`, :func:`request_with_retry`
|
|
16
|
+
- ``scrapper_tool.errors`` — :class:`VendorHTTPError`, :class:`BlockedError`,
|
|
17
|
+
:class:`ParseError`, :class:`VendorUnavailable`, :class:`ScrapingError`
|
|
18
|
+
- ``scrapper_tool.ladder`` *(M2)* — ``IMPERSONATE_LADDER`` and the walk logic
|
|
19
|
+
- ``scrapper_tool.patterns.{a,b,c,d}`` *(M3-M5)* — extraction helpers per pattern
|
|
20
|
+
- ``scrapper_tool.testing`` *(M6)* — fixture-replay test helpers
|
|
21
|
+
- ``scrapper_tool.canary`` *(M8)* — CLI for fingerprint-health probes
|
|
22
|
+
- ``scrapper_tool.adapter`` *(M7)* — generic ``Adapter[QueryT, ResultT]`` Protocol
|
|
23
|
+
|
|
24
|
+
Stability
|
|
25
|
+
---------
|
|
26
|
+
|
|
27
|
+
This is alpha software (v0.x). The public API may change between minor
|
|
28
|
+
versions until v1.0.0; pin a tilde or caret range when consuming.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
from scrapper_tool.adapter import Adapter
|
|
34
|
+
from scrapper_tool.errors import (
|
|
35
|
+
BlockedError,
|
|
36
|
+
ParseError,
|
|
37
|
+
ScrapingError,
|
|
38
|
+
VendorHTTPError,
|
|
39
|
+
VendorUnavailable,
|
|
40
|
+
)
|
|
41
|
+
from scrapper_tool.http import (
|
|
42
|
+
VendorHTTPClient,
|
|
43
|
+
request_with_retry,
|
|
44
|
+
vendor_client,
|
|
45
|
+
)
|
|
46
|
+
from scrapper_tool.ladder import (
|
|
47
|
+
IMPERSONATE_LADDER,
|
|
48
|
+
request_with_ladder,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
__version__ = "0.1.0"
|
|
52
|
+
|
|
53
|
+
__all__ = [
|
|
54
|
+
"IMPERSONATE_LADDER",
|
|
55
|
+
"Adapter",
|
|
56
|
+
"BlockedError",
|
|
57
|
+
"ParseError",
|
|
58
|
+
"ScrapingError",
|
|
59
|
+
"VendorHTTPClient",
|
|
60
|
+
"VendorHTTPError",
|
|
61
|
+
"VendorUnavailable",
|
|
62
|
+
"__version__",
|
|
63
|
+
"request_with_ladder",
|
|
64
|
+
"request_with_retry",
|
|
65
|
+
"vendor_client",
|
|
66
|
+
]
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Optional structlog binding shim.
|
|
2
|
+
|
|
3
|
+
The lib should be usable in projects that don't depend on ``structlog``.
|
|
4
|
+
This shim returns a real ``structlog`` logger if available, or a thin
|
|
5
|
+
adapter around stdlib ``logging`` that accepts the same keyword-arg
|
|
6
|
+
shape (``logger.warning("event", key=value)``) so call sites don't
|
|
7
|
+
have to branch.
|
|
8
|
+
|
|
9
|
+
Usage::
|
|
10
|
+
|
|
11
|
+
from scrapper_tool._logging import get_logger
|
|
12
|
+
|
|
13
|
+
_logger = get_logger(__name__)
|
|
14
|
+
_logger.warning("vendor_http.transport_error", url=url, attempt=attempt)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import logging
|
|
20
|
+
from typing import Any, Protocol
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class _Logger(Protocol):
|
|
24
|
+
"""Minimal structured-logger surface used by the lib."""
|
|
25
|
+
|
|
26
|
+
def debug(self, event: str, **kwargs: Any) -> None: ...
|
|
27
|
+
def info(self, event: str, **kwargs: Any) -> None: ...
|
|
28
|
+
def warning(self, event: str, **kwargs: Any) -> None: ...
|
|
29
|
+
def error(self, event: str, **kwargs: Any) -> None: ...
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _StdlibStructAdapter:
|
|
33
|
+
"""Wraps a stdlib ``logging.Logger`` to accept ``key=value`` kwargs.
|
|
34
|
+
|
|
35
|
+
Renders kwargs as ``key=value key2=value2`` after the event name —
|
|
36
|
+
not as JSON; the goal is "readable in a plain console" rather than
|
|
37
|
+
"structured log shipping". Consumers that want JSON should install
|
|
38
|
+
``structlog`` themselves; this lib will pick it up automatically.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, name: str) -> None:
|
|
42
|
+
self._inner = logging.getLogger(name)
|
|
43
|
+
|
|
44
|
+
@staticmethod
|
|
45
|
+
def _render(event: str, kwargs: dict[str, Any]) -> str:
|
|
46
|
+
if not kwargs:
|
|
47
|
+
return event
|
|
48
|
+
rendered = " ".join(f"{k}={v!r}" for k, v in kwargs.items())
|
|
49
|
+
return f"{event} {rendered}"
|
|
50
|
+
|
|
51
|
+
def debug(self, event: str, **kwargs: Any) -> None:
|
|
52
|
+
self._inner.debug(self._render(event, kwargs))
|
|
53
|
+
|
|
54
|
+
def info(self, event: str, **kwargs: Any) -> None:
|
|
55
|
+
self._inner.info(self._render(event, kwargs))
|
|
56
|
+
|
|
57
|
+
def warning(self, event: str, **kwargs: Any) -> None:
|
|
58
|
+
self._inner.warning(self._render(event, kwargs))
|
|
59
|
+
|
|
60
|
+
def error(self, event: str, **kwargs: Any) -> None:
|
|
61
|
+
self._inner.error(self._render(event, kwargs))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def get_logger(name: str) -> _Logger:
|
|
65
|
+
"""Return a structured logger for ``name``.
|
|
66
|
+
|
|
67
|
+
Prefers ``structlog`` when installed; falls back to a stdlib
|
|
68
|
+
adapter otherwise. Both implementations satisfy the ``_Logger``
|
|
69
|
+
protocol so call sites are uniform.
|
|
70
|
+
"""
|
|
71
|
+
try:
|
|
72
|
+
import structlog # noqa: PLC0415
|
|
73
|
+
except ImportError:
|
|
74
|
+
return _StdlibStructAdapter(name)
|
|
75
|
+
return structlog.get_logger(name) # type: ignore[no-any-return,unused-ignore]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
__all__ = ["get_logger"]
|
scrapper_tool/adapter.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Generic ``Adapter[QueryT, ResultT]`` Protocol.
|
|
2
|
+
|
|
3
|
+
A specialisation contract for vendor-specific scrapers built on top of
|
|
4
|
+
``scrapper-tool``. Consumers implement a class that conforms to this
|
|
5
|
+
Protocol and gain typed integration with the rest of the lib (notably
|
|
6
|
+
the M13 MCP server).
|
|
7
|
+
|
|
8
|
+
Why a Protocol (not an ABC)
|
|
9
|
+
---------------------------
|
|
10
|
+
|
|
11
|
+
`scrapper-tool` doesn't impose inheritance — adapters can be plain
|
|
12
|
+
classes, dataclasses, attrs models, or whatever the consumer prefers.
|
|
13
|
+
The Protocol is structural: any object exposing ``vendor_id``,
|
|
14
|
+
``search()``, and ``fetch_detail()`` with the right shapes satisfies
|
|
15
|
+
``Adapter[Q, R]``. ``isinstance(x, Adapter)`` works because the
|
|
16
|
+
Protocol is ``runtime_checkable``.
|
|
17
|
+
|
|
18
|
+
Specialising
|
|
19
|
+
------------
|
|
20
|
+
|
|
21
|
+
::
|
|
22
|
+
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from decimal import Decimal
|
|
25
|
+
from pydantic import BaseModel
|
|
26
|
+
|
|
27
|
+
from scrapper_tool import vendor_client, request_with_retry
|
|
28
|
+
from scrapper_tool.patterns.b import extract_product_offer
|
|
29
|
+
from scrapper_tool.adapter import Adapter
|
|
30
|
+
|
|
31
|
+
class OEMQuery(BaseModel):
|
|
32
|
+
oem: str
|
|
33
|
+
|
|
34
|
+
class PartResult(BaseModel):
|
|
35
|
+
oem: str
|
|
36
|
+
vendor: str
|
|
37
|
+
price: Decimal
|
|
38
|
+
currency: str
|
|
39
|
+
url: str
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class MyVendorAdapter: # implements Adapter[OEMQuery, PartResult]
|
|
43
|
+
vendor_id: str = "myvendor"
|
|
44
|
+
|
|
45
|
+
async def search(self, query: OEMQuery) -> list[PartResult]:
|
|
46
|
+
url = f"https://myvendor.example/api/search?oem={query.oem}"
|
|
47
|
+
async with vendor_client() as client:
|
|
48
|
+
resp = await request_with_retry(client, "GET", url)
|
|
49
|
+
... # parse + return
|
|
50
|
+
|
|
51
|
+
async def fetch_detail(self, url: str) -> PartResult | None:
|
|
52
|
+
async with vendor_client() as client:
|
|
53
|
+
resp = await request_with_retry(client, "GET", url)
|
|
54
|
+
offer = extract_product_offer(resp.text, base_url=url)
|
|
55
|
+
... # convert to PartResult
|
|
56
|
+
|
|
57
|
+
adapter: Adapter[OEMQuery, PartResult] = MyVendorAdapter()
|
|
58
|
+
|
|
59
|
+
The Protocol is ``runtime_checkable``, so ``isinstance(adapter, Adapter)``
|
|
60
|
+
returns True at runtime without inheritance.
|
|
61
|
+
|
|
62
|
+
Consumers wanting domain-specific extensions (PartsPilot's
|
|
63
|
+
``VendorAdapter`` is one) can subclass the Protocol or define their
|
|
64
|
+
own narrower one. The lib stays out of domain modelling.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
from __future__ import annotations
|
|
68
|
+
|
|
69
|
+
from typing import Protocol, runtime_checkable
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@runtime_checkable
|
|
73
|
+
class Adapter[QueryT, ResultT](Protocol):
|
|
74
|
+
"""Generic vendor-adapter contract.
|
|
75
|
+
|
|
76
|
+
Type parameters
|
|
77
|
+
---------------
|
|
78
|
+
QueryT
|
|
79
|
+
The query / input type. PartsPilot uses an ``OEMQuery``
|
|
80
|
+
Pydantic model with vehicle context; weather scrapers use a
|
|
81
|
+
``Location`` model; etc.
|
|
82
|
+
ResultT
|
|
83
|
+
The result type. Should be a Pydantic model so consumers can
|
|
84
|
+
snapshot-test against goldens (see
|
|
85
|
+
:func:`scrapper_tool.testing.assert_pydantic_snapshot`).
|
|
86
|
+
|
|
87
|
+
Required attributes
|
|
88
|
+
-------------------
|
|
89
|
+
vendor_id : str
|
|
90
|
+
Stable, lowercase, hyphen-or-underscore-free identifier for the
|
|
91
|
+
vendor (e.g. ``"amayama"``, ``"megazip"``). Used by the lib's
|
|
92
|
+
logging + the MCP server (M13) to tag tool calls.
|
|
93
|
+
|
|
94
|
+
Required methods
|
|
95
|
+
----------------
|
|
96
|
+
``async search(query: QueryT) -> list[ResultT]``
|
|
97
|
+
Search the vendor for results matching ``query``. Return a
|
|
98
|
+
list (possibly empty) of result records.
|
|
99
|
+
``async fetch_detail(url: str) -> ResultT | None``
|
|
100
|
+
Fetch a single result detail from a canonical URL. Return
|
|
101
|
+
``None`` when the vendor returns 404 or the URL doesn't
|
|
102
|
+
resolve to a vendor result.
|
|
103
|
+
|
|
104
|
+
Both methods are expected to:
|
|
105
|
+
- Wrap the actual HTTP work in :func:`scrapper_tool.http.vendor_client`
|
|
106
|
+
and :func:`scrapper_tool.http.request_with_retry`.
|
|
107
|
+
- Wrap the call site in the consumer's own circuit breaker — the
|
|
108
|
+
lib provides primitives but does not own breaker policy.
|
|
109
|
+
- Raise :class:`scrapper_tool.errors.VendorHTTPError` /
|
|
110
|
+
:class:`scrapper_tool.errors.VendorUnavailable` on transport
|
|
111
|
+
exhaustion (covers the breaker's "vendor down" branch).
|
|
112
|
+
- Raise :class:`scrapper_tool.errors.BlockedError` when the
|
|
113
|
+
anti-bot ladder exhausts (consumer should escalate to Pattern D).
|
|
114
|
+
- Raise :class:`scrapper_tool.errors.ParseError` on parser drift
|
|
115
|
+
— DOES NOT trip the breaker (it's "our bug", not "their fault").
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
vendor_id: str
|
|
119
|
+
|
|
120
|
+
async def search(self, query: QueryT) -> list[ResultT]: ...
|
|
121
|
+
|
|
122
|
+
async def fetch_detail(self, url: str) -> ResultT | None: ...
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
__all__ = [
|
|
126
|
+
"Adapter",
|
|
127
|
+
]
|
scrapper_tool/canary.py
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""``scrapper-tool canary`` CLI — fingerprint-health probe.
|
|
2
|
+
|
|
3
|
+
Walks the impersonation ladder against a target URL and reports which
|
|
4
|
+
profiles return 200 vs 403/blocked. Designed to run from cron / GitHub
|
|
5
|
+
Actions to surface "chrome133a is starting to 403" before any consumer
|
|
6
|
+
adapter notices.
|
|
7
|
+
|
|
8
|
+
Usage::
|
|
9
|
+
|
|
10
|
+
scrapper-tool canary https://example.com/api/health
|
|
11
|
+
scrapper-tool canary https://example.com/api/health --json
|
|
12
|
+
scrapper-tool canary https://example.com/api/health \\
|
|
13
|
+
--profiles chrome133a,chrome124,safari18_0
|
|
14
|
+
|
|
15
|
+
Exit codes
|
|
16
|
+
----------
|
|
17
|
+
|
|
18
|
+
- ``0`` — at least one profile returned ≠ 403/503.
|
|
19
|
+
- ``1`` — all profiles blocked (informs the caller that Pattern D is
|
|
20
|
+
needed, the URL is hostile, or the canary URL itself moved).
|
|
21
|
+
- ``2`` — argument parsing / runtime error (network down, bad URL).
|
|
22
|
+
|
|
23
|
+
Output (default, human-readable)::
|
|
24
|
+
|
|
25
|
+
URL: https://example.com/api/health
|
|
26
|
+
Effective profile: chrome133a
|
|
27
|
+
Profile | Status | Time (ms)
|
|
28
|
+
-------- | ------ | ---------
|
|
29
|
+
chrome133a | 200 | 234
|
|
30
|
+
chrome124 | - | - (skipped — earlier profile won)
|
|
31
|
+
safari18_0 | - | -
|
|
32
|
+
firefox135 | - | -
|
|
33
|
+
|
|
34
|
+
Output (--json, machine-readable)::
|
|
35
|
+
|
|
36
|
+
{
|
|
37
|
+
"url": "https://example.com/api/health",
|
|
38
|
+
"winning_profile": "chrome133a",
|
|
39
|
+
"exit_code": 0,
|
|
40
|
+
"results": [
|
|
41
|
+
{"profile": "chrome133a", "status": 200, "elapsed_ms": 234, "skipped": false},
|
|
42
|
+
{"profile": "chrome124", "status": null, "elapsed_ms": null, "skipped": true},
|
|
43
|
+
...
|
|
44
|
+
]
|
|
45
|
+
}
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
from __future__ import annotations
|
|
49
|
+
|
|
50
|
+
import argparse
|
|
51
|
+
import asyncio
|
|
52
|
+
import json
|
|
53
|
+
import sys
|
|
54
|
+
import time
|
|
55
|
+
from typing import TYPE_CHECKING, cast
|
|
56
|
+
|
|
57
|
+
from scrapper_tool._logging import get_logger
|
|
58
|
+
from scrapper_tool.errors import BlockedError, VendorHTTPError
|
|
59
|
+
from scrapper_tool.http import request_with_retry
|
|
60
|
+
from scrapper_tool.ladder import (
|
|
61
|
+
IMPERSONATE_LADDER,
|
|
62
|
+
_curl_cffi_session,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
if TYPE_CHECKING:
|
|
66
|
+
from collections.abc import Sequence
|
|
67
|
+
|
|
68
|
+
import httpx
|
|
69
|
+
|
|
70
|
+
_logger = get_logger(__name__)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
_ROTATE_STATUS_CODES = frozenset({403, 503})
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
async def probe_profile(
|
|
77
|
+
profile: str,
|
|
78
|
+
url: str,
|
|
79
|
+
*,
|
|
80
|
+
timeout: float = 10.0, # noqa: ASYNC109 — passed to curl_cffi, not asyncio.timeout
|
|
81
|
+
proxy: str | None = None,
|
|
82
|
+
) -> tuple[int | None, float | None, str | None]:
|
|
83
|
+
"""Issue one GET against ``url`` impersonating ``profile``.
|
|
84
|
+
|
|
85
|
+
Returns ``(status_code, elapsed_ms, error_message)``. On transport
|
|
86
|
+
error, status is ``None`` and ``error_message`` carries the
|
|
87
|
+
failure reason; otherwise ``error_message`` is ``None``.
|
|
88
|
+
"""
|
|
89
|
+
started = time.perf_counter()
|
|
90
|
+
try:
|
|
91
|
+
async with _curl_cffi_session(
|
|
92
|
+
profile, timeout=timeout, proxy=proxy, extra_headers=None
|
|
93
|
+
) as session:
|
|
94
|
+
resp = await request_with_retry(
|
|
95
|
+
cast("httpx.AsyncClient", session),
|
|
96
|
+
"GET",
|
|
97
|
+
url,
|
|
98
|
+
)
|
|
99
|
+
elapsed_ms = (time.perf_counter() - started) * 1000.0
|
|
100
|
+
except VendorHTTPError as exc:
|
|
101
|
+
elapsed_ms = (time.perf_counter() - started) * 1000.0
|
|
102
|
+
return None, elapsed_ms, str(exc)
|
|
103
|
+
return int(resp.status_code), elapsed_ms, None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
async def run_canary(
|
|
107
|
+
url: str,
|
|
108
|
+
*,
|
|
109
|
+
ladder: tuple[str, ...] = IMPERSONATE_LADDER,
|
|
110
|
+
timeout: float = 10.0, # noqa: ASYNC109 — passed through to curl_cffi
|
|
111
|
+
proxy: str | None = None,
|
|
112
|
+
) -> dict[str, object]:
|
|
113
|
+
"""Walk ``ladder`` against ``url``. Stop at the first ≠ 403/503.
|
|
114
|
+
|
|
115
|
+
Returns a structured result dict (see module docstring's --json
|
|
116
|
+
example). Profiles tried *after* the winning one are recorded
|
|
117
|
+
with ``skipped=True``.
|
|
118
|
+
"""
|
|
119
|
+
if not ladder:
|
|
120
|
+
msg = "ladder must contain at least one profile"
|
|
121
|
+
raise ValueError(msg)
|
|
122
|
+
|
|
123
|
+
results: list[dict[str, object]] = []
|
|
124
|
+
winning_profile: str | None = None
|
|
125
|
+
|
|
126
|
+
for i, profile in enumerate(ladder):
|
|
127
|
+
if winning_profile is not None:
|
|
128
|
+
results.append(
|
|
129
|
+
{
|
|
130
|
+
"profile": profile,
|
|
131
|
+
"status": None,
|
|
132
|
+
"elapsed_ms": None,
|
|
133
|
+
"skipped": True,
|
|
134
|
+
"error": None,
|
|
135
|
+
}
|
|
136
|
+
)
|
|
137
|
+
continue
|
|
138
|
+
|
|
139
|
+
status, elapsed_ms, error = await probe_profile(profile, url, timeout=timeout, proxy=proxy)
|
|
140
|
+
results.append(
|
|
141
|
+
{
|
|
142
|
+
"profile": profile,
|
|
143
|
+
"status": status,
|
|
144
|
+
"elapsed_ms": (round(elapsed_ms, 1) if elapsed_ms is not None else None),
|
|
145
|
+
"skipped": False,
|
|
146
|
+
"error": error,
|
|
147
|
+
}
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
if status is not None and status not in _ROTATE_STATUS_CODES:
|
|
151
|
+
winning_profile = profile
|
|
152
|
+
# Mark remaining profiles as skipped.
|
|
153
|
+
for skipped_profile in ladder[i + 1 :]:
|
|
154
|
+
results.append(
|
|
155
|
+
{
|
|
156
|
+
"profile": skipped_profile,
|
|
157
|
+
"status": None,
|
|
158
|
+
"elapsed_ms": None,
|
|
159
|
+
"skipped": True,
|
|
160
|
+
"error": None,
|
|
161
|
+
}
|
|
162
|
+
)
|
|
163
|
+
break
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
"url": url,
|
|
167
|
+
"winning_profile": winning_profile,
|
|
168
|
+
"exit_code": 0 if winning_profile is not None else 1,
|
|
169
|
+
"results": results,
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _format_text(report: dict[str, object]) -> str:
|
|
174
|
+
"""Render the canary report as a human-readable text table."""
|
|
175
|
+
lines = [
|
|
176
|
+
f"URL: {report['url']}",
|
|
177
|
+
f"Effective profile: {report['winning_profile'] or '(none — all blocked)'}",
|
|
178
|
+
"",
|
|
179
|
+
"Profile | Status | Time (ms) | Skipped",
|
|
180
|
+
"----------- | ------ | --------- | -------",
|
|
181
|
+
]
|
|
182
|
+
results = report["results"]
|
|
183
|
+
assert isinstance(results, list)
|
|
184
|
+
for row in results:
|
|
185
|
+
assert isinstance(row, dict)
|
|
186
|
+
status = row["status"]
|
|
187
|
+
elapsed = row["elapsed_ms"]
|
|
188
|
+
skipped = row["skipped"]
|
|
189
|
+
status_cell = "-" if status is None else str(status)
|
|
190
|
+
elapsed_cell = "-" if elapsed is None else f"{elapsed:.1f}"
|
|
191
|
+
skipped_cell = "yes" if skipped else "no"
|
|
192
|
+
profile_str = str(row["profile"])
|
|
193
|
+
lines.append(f"{profile_str:<11} | {status_cell:<6} | {elapsed_cell:<9} | {skipped_cell}")
|
|
194
|
+
error = row.get("error")
|
|
195
|
+
if error:
|
|
196
|
+
lines.append(f" error: {error}")
|
|
197
|
+
return "\n".join(lines)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
201
|
+
parser = argparse.ArgumentParser(
|
|
202
|
+
prog="scrapper-tool",
|
|
203
|
+
description="Reusable web-scraping toolkit — Pattern A/B/C/D ladder.",
|
|
204
|
+
)
|
|
205
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
206
|
+
|
|
207
|
+
canary = sub.add_parser(
|
|
208
|
+
"canary",
|
|
209
|
+
help="Probe a URL through the impersonation ladder; report which profile won.",
|
|
210
|
+
description=(
|
|
211
|
+
"Walks the four-profile impersonation ladder "
|
|
212
|
+
"(chrome133a -> chrome124 -> safari18_0 -> firefox135) against "
|
|
213
|
+
"URL. Stops at the first non-403/503. Exit 0 on success, 1 if "
|
|
214
|
+
"all profiles 403, 2 on error."
|
|
215
|
+
),
|
|
216
|
+
)
|
|
217
|
+
canary.add_argument("url", help="Target URL to probe.")
|
|
218
|
+
canary.add_argument(
|
|
219
|
+
"--profiles",
|
|
220
|
+
type=str,
|
|
221
|
+
default=None,
|
|
222
|
+
help=(
|
|
223
|
+
"Comma-separated impersonation profiles to walk in order. "
|
|
224
|
+
"Default: the lib's IMPERSONATE_LADDER."
|
|
225
|
+
),
|
|
226
|
+
)
|
|
227
|
+
canary.add_argument(
|
|
228
|
+
"--timeout",
|
|
229
|
+
type=float,
|
|
230
|
+
default=10.0,
|
|
231
|
+
help="Per-request timeout in seconds. Default: 10.0",
|
|
232
|
+
)
|
|
233
|
+
canary.add_argument(
|
|
234
|
+
"--proxy",
|
|
235
|
+
type=str,
|
|
236
|
+
default=None,
|
|
237
|
+
help="Proxy URL to route requests through. Default: none.",
|
|
238
|
+
)
|
|
239
|
+
canary.add_argument(
|
|
240
|
+
"--json",
|
|
241
|
+
action="store_true",
|
|
242
|
+
help="Emit machine-readable JSON instead of a text table.",
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
return parser
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
249
|
+
"""Entry point for ``scrapper-tool`` console script."""
|
|
250
|
+
parser = _build_parser()
|
|
251
|
+
args = parser.parse_args(argv)
|
|
252
|
+
|
|
253
|
+
if args.command != "canary": # pragma: no cover — argparse rejects others
|
|
254
|
+
parser.error(f"unknown command: {args.command}")
|
|
255
|
+
|
|
256
|
+
ladder: tuple[str, ...]
|
|
257
|
+
if args.profiles:
|
|
258
|
+
ladder = tuple(p.strip() for p in args.profiles.split(",") if p.strip())
|
|
259
|
+
if not ladder:
|
|
260
|
+
parser.error("--profiles must contain at least one non-empty entry")
|
|
261
|
+
else:
|
|
262
|
+
ladder = IMPERSONATE_LADDER
|
|
263
|
+
|
|
264
|
+
try:
|
|
265
|
+
report = asyncio.run(
|
|
266
|
+
run_canary(
|
|
267
|
+
args.url,
|
|
268
|
+
ladder=ladder,
|
|
269
|
+
timeout=args.timeout,
|
|
270
|
+
proxy=args.proxy,
|
|
271
|
+
)
|
|
272
|
+
)
|
|
273
|
+
except (BlockedError, ValueError) as exc:
|
|
274
|
+
sys.stderr.write(f"canary error: {exc}\n")
|
|
275
|
+
return 2
|
|
276
|
+
|
|
277
|
+
if args.json:
|
|
278
|
+
sys.stdout.write(json.dumps(report, indent=2))
|
|
279
|
+
sys.stdout.write("\n")
|
|
280
|
+
else:
|
|
281
|
+
sys.stdout.write(_format_text(report))
|
|
282
|
+
sys.stdout.write("\n")
|
|
283
|
+
|
|
284
|
+
exit_code = report["exit_code"]
|
|
285
|
+
assert isinstance(exit_code, int)
|
|
286
|
+
return exit_code
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
if __name__ == "__main__": # pragma: no cover
|
|
290
|
+
raise SystemExit(main())
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
__all__ = [
|
|
294
|
+
"main",
|
|
295
|
+
"probe_profile",
|
|
296
|
+
"run_canary",
|
|
297
|
+
]
|
scrapper_tool/errors.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Exception hierarchy for scrapper-tool.
|
|
2
|
+
|
|
3
|
+
::
|
|
4
|
+
|
|
5
|
+
ScrapingError (base — all lib-specific exceptions inherit)
|
|
6
|
+
├── VendorHTTPError (transport-error or 5xx/429 retry-exhaustion)
|
|
7
|
+
├── BlockedError (403 / Cloudflare challenge / Akamai EVA / Distil — anti-bot)
|
|
8
|
+
├── ParseError (extractor couldn't find expected fields in the response)
|
|
9
|
+
└── VendorUnavailable (alias for VendorHTTPError; intended for circuit-breaker
|
|
10
|
+
feeders to map "their fault" failures cleanly)
|
|
11
|
+
|
|
12
|
+
Consumers wrapping the lib in their own circuit breaker typically
|
|
13
|
+
trigger on ``VendorHTTPError`` / ``VendorUnavailable`` / ``BlockedError``
|
|
14
|
+
but *not* on ``ParseError`` — the latter is "our bug" (parser drift),
|
|
15
|
+
not "vendor down". See ``scrapper_tool.adapter`` (M7) for the generic
|
|
16
|
+
Adapter Protocol that codifies this distinction.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ScrapingError(Exception):
|
|
23
|
+
"""Base for all scrapper-tool exceptions."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class VendorHTTPError(ScrapingError):
|
|
27
|
+
"""Raised when all retry attempts exhaust on a retriable failure.
|
|
28
|
+
|
|
29
|
+
Non-retriable HTTP 4xx responses (other than 429) are NOT wrapped —
|
|
30
|
+
the caller sees a plain response object via the underlying client.
|
|
31
|
+
The distinction matters for circuit breakers: 4xx is "our bug",
|
|
32
|
+
5xx/429/transport is "their fault".
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class VendorUnavailable(VendorHTTPError):
|
|
37
|
+
"""Alias of :class:`VendorHTTPError` for circuit-breaker call-sites.
|
|
38
|
+
|
|
39
|
+
Subclasses :class:`VendorHTTPError` so existing handlers keep
|
|
40
|
+
working; the dedicated name reads better at the breaker boundary.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class BlockedError(ScrapingError):
|
|
45
|
+
"""Raised when an anti-bot platform blocks the request.
|
|
46
|
+
|
|
47
|
+
Distinct from :class:`VendorHTTPError` because the remediation
|
|
48
|
+
differs: the breaker should NOT trip (the vendor is up, just
|
|
49
|
+
fingerprinting us). Consumer should escalate to the next ladder
|
|
50
|
+
profile or to Pattern D (Scrapling).
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ParseError(ScrapingError):
|
|
55
|
+
"""Raised when the extractor cannot find expected fields.
|
|
56
|
+
|
|
57
|
+
Indicates parser drift (vendor changed markup) or a fixture-vs-live
|
|
58
|
+
mismatch. NOT a circuit-breaker signal — re-fetching won't help.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
__all__ = [
|
|
63
|
+
"BlockedError",
|
|
64
|
+
"ParseError",
|
|
65
|
+
"ScrapingError",
|
|
66
|
+
"VendorHTTPError",
|
|
67
|
+
"VendorUnavailable",
|
|
68
|
+
]
|