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.
@@ -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"]
@@ -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
+ ]
@@ -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
+ ]
@@ -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
+ ]