fetchgate 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.
fetchgate/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ """FetchGate: a deterministic gate at the fetch boundary. A 200 is not a read."""
2
+
3
+ from .app import default_gate
4
+ from .config import DomainPolicy, FetchPolicy
5
+ from .detect import decide
6
+ from .fetch import build_envelope, fetch
7
+ from .gate import FetchGate, GateOutcome
8
+ from .transports import BrowserTransport, HttpTransport, MockTransport, Transport
9
+ from .types import (
10
+ GateDisposition, GateResult, ProvenanceEnvelope, RawResponse, Verdict,
11
+ )
12
+ from .verify import envelope_from_dict, recompute_verdict
13
+
14
+ __version__ = "0.1.0"
15
+
16
+ __all__ = [
17
+ "default_gate", "FetchPolicy", "DomainPolicy", "decide", "fetch", "build_envelope",
18
+ "FetchGate", "GateOutcome", "Transport", "MockTransport", "HttpTransport",
19
+ "BrowserTransport", "RawResponse", "ProvenanceEnvelope", "GateResult", "Verdict",
20
+ "GateDisposition", "envelope_from_dict", "recompute_verdict", "__version__",
21
+ ]
fetchgate/app.py ADDED
@@ -0,0 +1,20 @@
1
+ """Convenience factory: a gate with the static reader plus the render tier if available."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .config import FetchPolicy
6
+ from .gate import FetchGate
7
+ from .transports import BrowserTransport, HttpTransport
8
+
9
+
10
+ def default_gate() -> FetchGate:
11
+ """A ready gate. Renders JS pages when the [browser] extra is installed."""
12
+ heavy = None
13
+ default_mode = "never_render"
14
+ try:
15
+ import playwright # noqa: F401
16
+ heavy = BrowserTransport()
17
+ default_mode = "render_allowed"
18
+ except ImportError:
19
+ pass
20
+ return FetchGate(FetchPolicy(default_mode=default_mode), HttpTransport(), heavy)
fetchgate/config.py ADDED
@@ -0,0 +1,91 @@
1
+ """Fetch policy: thresholds and per-domain rules for the classifiers and ladder."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ from dataclasses import dataclass, field
8
+ from typing import Optional
9
+
10
+ # 404 is not a hard fail: soft-404s carry a body and fall through to extraction.
11
+ HARD_FAIL_STATUS = frozenset({401, 403, 407, 408, 429, 451,
12
+ 500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511})
13
+ NO_BODY_STATUS = frozenset({204, 205, 304})
14
+ OK_STATUS = frozenset({200, 203, 206, 226})
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class ExtractionProfile:
19
+ kind: str # html | structured | xml | plaintext | binary
20
+ min_extracted_bytes: int = 64 # below this on html -> EMPTY
21
+ sufficient_extracted_bytes: int = 512 # at/above this -> EXTRACT_OK regardless of ratio
22
+ min_ratio: float = 0.10 # in the 64..512 band, below this -> THIN
23
+ min_raw_bytes: int = 64 # EMPTY with raw below this -> empty_transport (FAILED)
24
+ min_text_chars: int = 1 # plaintext floor
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class DomainPolicy:
29
+ mode: str = "never_render" # never_render | render_allowed | always_hard_fail
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class FetchPolicy:
34
+ policy_id: str = "default"
35
+ default_mode: str = "never_render"
36
+ block_cross_origin_redirect: bool = True
37
+ max_redirects: int = 5
38
+ unsupported_type_policy: str = "UNKNOWN"
39
+ domains: dict = field(default_factory=dict) # registrable-domain -> DomainPolicy
40
+
41
+ def profile_for(self, content_type: Optional[str]) -> ExtractionProfile:
42
+ ct = (content_type or "").split(";")[0].strip().lower()
43
+ if ct in ("text/html", "application/xhtml+xml"):
44
+ return ExtractionProfile(kind="html")
45
+ if ct == "application/json" or ct.endswith("+json"):
46
+ return ExtractionProfile(kind="structured")
47
+ if ct in ("application/xml", "text/xml", "application/rss+xml",
48
+ "application/atom+xml"):
49
+ return ExtractionProfile(kind="xml")
50
+ if ct in ("text/plain", "text/csv", "text/markdown"):
51
+ return ExtractionProfile(kind="plaintext")
52
+ return ExtractionProfile(kind="binary")
53
+
54
+ def domain_policy(self, url_final: str) -> DomainPolicy:
55
+ host = _host_of(url_final)
56
+ reg = _registrable_domain(host)
57
+ # longest-suffix match against configured domains
58
+ best: Optional[DomainPolicy] = None
59
+ best_len = -1
60
+ for dom, dp in self.domains.items():
61
+ if host == dom or host.endswith("." + dom) or reg == dom:
62
+ if len(dom) > best_len:
63
+ best, best_len = dp, len(dom)
64
+ if best is not None:
65
+ return best
66
+ return DomainPolicy(mode=self.default_mode)
67
+
68
+ def sha256(self) -> str:
69
+ payload = {
70
+ "policy_id": self.policy_id,
71
+ "default_mode": self.default_mode,
72
+ "block_cross_origin_redirect": self.block_cross_origin_redirect,
73
+ "max_redirects": self.max_redirects,
74
+ "unsupported_type_policy": self.unsupported_type_policy,
75
+ "domains": {k: v.__dict__ for k, v in sorted(self.domains.items())},
76
+ }
77
+ raw = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
78
+ return hashlib.sha256(raw).hexdigest()
79
+
80
+
81
+ def _host_of(url: str) -> str:
82
+ from urllib.parse import urlparse
83
+ return (urlparse(url).hostname or "").lower()
84
+
85
+
86
+ def _registrable_domain(host: str) -> str:
87
+ """Naive eTLD+1 (last two labels); production vendors a public-suffix-list."""
88
+ parts = [p for p in host.split(".") if p]
89
+ if len(parts) <= 2:
90
+ return host
91
+ return ".".join(parts[-2:])
fetchgate/detect.py ADDED
@@ -0,0 +1,196 @@
1
+ """Layers A/C/freshness and decide(): true minimum over layers, then a reason tie-break."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+ from urllib.parse import urlparse
7
+
8
+ from .config import FetchPolicy, HARD_FAIL_STATUS, NO_BODY_STATUS, OK_STATUS, _registrable_domain
9
+ from .types import (
10
+ ExtractionClass, Freshness, FreshnessState, GateResult, ProvenanceEnvelope,
11
+ RawResponse, SEV, TransportClass, TransportSignal, ValiditySeverity,
12
+ ValiditySignal, Verdict,
13
+ )
14
+
15
+ # Layer A: transport
16
+
17
+ def classify_transport(raw: RawResponse, policy: FetchPolicy) -> TransportSignal:
18
+ req = urlparse(raw.url_requested)
19
+ fin = urlparse(raw.url_final)
20
+ downgrade = req.scheme == "https" and fin.scheme == "http"
21
+ cross = _registrable_domain((req.hostname or "").lower()) != _registrable_domain((fin.hostname or "").lower())
22
+ max_hit = len(raw.redirect_chain) >= policy.max_redirects
23
+
24
+ if downgrade:
25
+ return TransportSignal(TransportClass.FAIL, "downgrade", cross, max_hit)
26
+ if raw.transport_error is not None:
27
+ return TransportSignal(TransportClass.FAIL, raw.transport_error, cross, max_hit)
28
+ if raw.status is None:
29
+ return TransportSignal(TransportClass.FAIL, "no_status", cross, max_hit)
30
+ if raw.status in HARD_FAIL_STATUS:
31
+ return TransportSignal(TransportClass.FAIL, f"http_{raw.status}", cross, max_hit)
32
+ if raw.status in NO_BODY_STATUS:
33
+ return TransportSignal(TransportClass.AMBIGUOUS, "no_content", cross, max_hit)
34
+ if raw.status in OK_STATUS:
35
+ return TransportSignal(TransportClass.OK, "ok", cross, max_hit)
36
+ return TransportSignal(TransportClass.AMBIGUOUS, f"other_{raw.status}", cross, max_hit)
37
+
38
+
39
+ # Layer C: content-validity fingerprint (downgrade-only; never FAILED/RETRIEVED)
40
+
41
+ _CHALLENGE_URL_MARKERS = ("/cdn-cgi/challenge-platform/", "/_incapsula_resource")
42
+ _CHALLENGE_BODY = {
43
+ "cloudflare": ("just a moment", "challenges.cloudflare.com/turnstile", "cf-chl"),
44
+ "datadome": ("geo.captcha-delivery.com", "datadome"),
45
+ "perimeterx": ("access to this page has been denied", "px-captcha"),
46
+ "akamai": ("reference&#32;#", "akamai reference"),
47
+ }
48
+ _SUSPECT_BODY = {
49
+ "consent": ("this site uses cookies", "__tcfapi", "we value your privacy"),
50
+ "paywall": ("subscribe to continue", "create a free account to read",
51
+ "to read the full", "already a subscriber"),
52
+ "soft_404": ("page not found", "404", "no longer exists", "cannot be found"),
53
+ }
54
+
55
+
56
+ def fingerprint_validity(raw: RawResponse) -> ValiditySignal:
57
+ headers = {k.lower(): (v or "").lower() for k, v in raw.headers.items()}
58
+ host = (urlparse(raw.url_final).hostname or "").lower()
59
+ path = (urlparse(raw.url_final).path or "").lower()
60
+ body = raw.body.decode("utf-8", errors="replace").lower()
61
+
62
+ # 1. headers (least rot-prone) -> challenge
63
+ if "cf-mitigated" in headers and "challenge" in headers.get("cf-mitigated", ""):
64
+ return ValiditySignal(ValiditySeverity.CHALLENGE, True, "cloudflare_header")
65
+ if "x-datadome" in headers or "datadome" in headers.get("set-cookie", ""):
66
+ return ValiditySignal(ValiditySeverity.CHALLENGE, True, "datadome_header")
67
+ if raw.status == 200 and "www-authenticate" in headers:
68
+ return ValiditySignal(ValiditySeverity.CHALLENGE, True, "www_authenticate_200")
69
+
70
+ # 2. final-url routes
71
+ if any(m in path for m in _CHALLENGE_URL_MARKERS):
72
+ return ValiditySignal(ValiditySeverity.CHALLENGE, True, "challenge_url")
73
+ if host.startswith(("login.", "accounts.", "sso.")):
74
+ return ValiditySignal(ValiditySeverity.CHALLENGE, True, "auth_redirect")
75
+ if host.startswith("consent."):
76
+ return ValiditySignal(ValiditySeverity.SUSPECT, True, "consent_host")
77
+
78
+ # 3. body signatures: scan all families, >=2 same-family markers -> challenge.
79
+ challenge_family = None
80
+ single_family = None
81
+ for family, markers in _CHALLENGE_BODY.items():
82
+ hits = sum(1 for m in markers if m in body)
83
+ if hits >= 2 and challenge_family is None:
84
+ challenge_family = family
85
+ elif hits == 1 and single_family is None:
86
+ single_family = family
87
+ if challenge_family is not None:
88
+ return ValiditySignal(ValiditySeverity.CHALLENGE, True, f"{challenge_family}_body")
89
+ if single_family is not None:
90
+ return ValiditySignal(ValiditySeverity.SUSPECT, True, f"{single_family}_single")
91
+ for family, markers in _SUSPECT_BODY.items():
92
+ if any(m in body for m in markers):
93
+ return ValiditySignal(ValiditySeverity.SUSPECT, True, f"{family}")
94
+
95
+ return ValiditySignal(ValiditySeverity.CLEAN, False, None)
96
+
97
+
98
+ # Freshness
99
+
100
+ def classify_freshness(
101
+ fetched_at_epoch: float,
102
+ required_max_age_seconds: Optional[int],
103
+ resource_timestamp_epoch: Optional[float],
104
+ resource_timestamp_iso: Optional[str],
105
+ resource_timestamp_source: Optional[str],
106
+ ) -> Freshness:
107
+ if required_max_age_seconds is None:
108
+ return Freshness(None, resource_timestamp_iso, resource_timestamp_source,
109
+ None, FreshnessState.NOT_REQUIRED)
110
+ if resource_timestamp_epoch is None:
111
+ return Freshness(required_max_age_seconds, None, None, None, FreshnessState.UNKNOWN)
112
+ age = int(fetched_at_epoch - resource_timestamp_epoch)
113
+ state = FreshnessState.STALE if age > required_max_age_seconds else FreshnessState.FRESH
114
+ return Freshness(required_max_age_seconds, resource_timestamp_iso,
115
+ resource_timestamp_source, age, state)
116
+
117
+
118
+ # decide(): true minimum over all layers, then fixed reason precedence
119
+
120
+ REASON_PRECEDENCE = [
121
+ "transport.", "redirect_boundary", "too_many_redirects",
122
+ "structured_empty", "empty_transport", "stale",
123
+ "empty_extract", "structured_parse", "unsupported_", "thin_ratio",
124
+ "freshness_unknown", "challenge_", "validity_suspect",
125
+ ]
126
+
127
+
128
+ def _precedence(reason: str) -> int:
129
+ for i, prefix in enumerate(REASON_PRECEDENCE):
130
+ if reason.startswith(prefix):
131
+ return i
132
+ return len(REASON_PRECEDENCE)
133
+
134
+
135
+ def _verdict_of(sev: int) -> Verdict:
136
+ for v, s in SEV.items():
137
+ if s == sev:
138
+ return v
139
+ return Verdict.UNKNOWN
140
+
141
+
142
+ def decide(env: ProvenanceEnvelope, policy: FetchPolicy) -> GateResult:
143
+ contributions: list[tuple[int, str]] = []
144
+
145
+ # ---- Layer A: transport ----
146
+ t = env.signals.transport
147
+ if t.cls is TransportClass.FAIL:
148
+ contributions.append((SEV[Verdict.FAILED], "transport." + t.reason))
149
+ elif t.cls is TransportClass.AMBIGUOUS:
150
+ contributions.append((SEV[Verdict.UNKNOWN], "transport.no_content"))
151
+ if t.cross_registrable_domain_redirect and policy.block_cross_origin_redirect:
152
+ contributions.append((SEV[Verdict.FAILED], "redirect_boundary"))
153
+ if t.max_redirects_hit:
154
+ contributions.append((SEV[Verdict.FAILED], "too_many_redirects"))
155
+
156
+ # ---- Layer B: extraction ----
157
+ e = env.signals.extraction.cls
158
+ profile = policy.profile_for(env.content_type)
159
+ if e is ExtractionClass.STRUCTURED_EMPTY:
160
+ contributions.append((SEV[Verdict.FAILED], "structured_empty"))
161
+ elif e is ExtractionClass.EMPTY:
162
+ if env.raw_bytes < profile.min_raw_bytes:
163
+ contributions.append((SEV[Verdict.FAILED], "empty_transport"))
164
+ else:
165
+ contributions.append((SEV[Verdict.UNKNOWN], "empty_extract"))
166
+ elif e is ExtractionClass.THIN:
167
+ contributions.append((SEV[Verdict.UNKNOWN], "thin_ratio"))
168
+ elif e is ExtractionClass.STRUCTURED_PARSE_FAIL:
169
+ contributions.append((SEV[Verdict.UNKNOWN], "structured_parse"))
170
+ elif e is ExtractionClass.UNSUPPORTED:
171
+ if policy.unsupported_type_policy != "BYTES_OK":
172
+ contributions.append((SEV[Verdict.UNKNOWN], "unsupported_" + env.signals.extraction.profile))
173
+ # EXTRACT_OK contributes nothing
174
+
175
+ # ---- Freshness ----
176
+ f = env.freshness.age_vs_required_freshness
177
+ if f is FreshnessState.STALE:
178
+ contributions.append((SEV[Verdict.FAILED], "stale"))
179
+ elif f is FreshnessState.UNKNOWN:
180
+ contributions.append((SEV[Verdict.UNKNOWN], "freshness_unknown"))
181
+
182
+ # ---- Layer C: content-validity (downgrade-only) ----
183
+ c = env.signals.content_validity.severity
184
+ if c is ValiditySeverity.CHALLENGE:
185
+ fid = env.signals.content_validity.fingerprint_id or "generic"
186
+ contributions.append((SEV[Verdict.UNKNOWN], "challenge_" + fid))
187
+ elif c is ValiditySeverity.SUSPECT:
188
+ contributions.append((SEV[Verdict.UNKNOWN], "validity_suspect"))
189
+
190
+ # ---- compose: true minimum, then reason precedence ----
191
+ if not contributions:
192
+ return GateResult(Verdict.RETRIEVED, "clean")
193
+ min_sev = min(sev for sev, _ in contributions)
194
+ fired = [reason for sev, reason in contributions if sev == min_sev]
195
+ reason = min(fired, key=_precedence)
196
+ return GateResult(_verdict_of(min_sev), reason)
fetchgate/envelope.py ADDED
@@ -0,0 +1,83 @@
1
+ """Envelope serialization and hashing. Canonical JSON is bit-stable and holds no verdict."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+
9
+ from .types import ProvenanceEnvelope
10
+
11
+
12
+ def content_sha256(raw: bytes) -> str | None:
13
+ if not raw:
14
+ return None
15
+ return hashlib.sha256(raw).hexdigest()
16
+
17
+
18
+ def extracted_sha256(text: str) -> str | None:
19
+ if not text:
20
+ return None
21
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
22
+
23
+
24
+ def mint_handle(url: str) -> str:
25
+ """A non-forgeable, gate-issued handle."""
26
+ return hashlib.sha256(url.encode("utf-8") + os.urandom(16)).hexdigest()[:32]
27
+
28
+
29
+ def envelope_to_dict(env: ProvenanceEnvelope) -> dict:
30
+ s = env.signals
31
+ return {
32
+ "envelope_version": env.envelope_version,
33
+ "runtime_version": env.runtime_version,
34
+ "request_id": env.request_id,
35
+ "url_requested": env.url_requested,
36
+ "url_final": env.url_final,
37
+ "redirect_chain": [
38
+ {"status": r.status, "from": r.from_url, "to": r.to_url}
39
+ for r in env.redirect_chain
40
+ ],
41
+ "http_status": env.http_status,
42
+ "fetched_at": env.fetched_at,
43
+ "content_type": env.content_type,
44
+ "raw_bytes": env.raw_bytes,
45
+ "extracted_bytes": env.extracted_bytes,
46
+ "renderer_used": env.renderer_used,
47
+ "truncated": env.truncated,
48
+ "content_sha256": env.content_sha256,
49
+ "hash_target": env.hash_target,
50
+ "extracted_sha256": env.extracted_sha256,
51
+ "policy_id": env.policy_id,
52
+ "policy_sha256": env.policy_sha256,
53
+ "freshness": {
54
+ "required_max_age_seconds": env.freshness.required_max_age_seconds,
55
+ "resource_timestamp": env.freshness.resource_timestamp,
56
+ "resource_timestamp_source": env.freshness.resource_timestamp_source,
57
+ "age_seconds": env.freshness.age_seconds,
58
+ "age_vs_required_freshness": env.freshness.age_vs_required_freshness.value,
59
+ },
60
+ "signals": {
61
+ "transport": {
62
+ "class": s.transport.cls.value,
63
+ "reason": s.transport.reason,
64
+ "cross_registrable_domain_redirect": s.transport.cross_registrable_domain_redirect,
65
+ "max_redirects_hit": s.transport.max_redirects_hit,
66
+ },
67
+ "extraction": {
68
+ "class": s.extraction.cls.value,
69
+ "profile": s.extraction.profile,
70
+ "extractor": s.extraction.extractor,
71
+ },
72
+ "content_validity": {
73
+ "severity": s.content_validity.severity.value,
74
+ "fingerprint_hit": s.content_validity.fingerprint_hit,
75
+ "fingerprint_id": s.content_validity.fingerprint_id,
76
+ "fingerprint_pack_version": s.content_validity.fingerprint_pack_version,
77
+ },
78
+ },
79
+ }
80
+
81
+
82
+ def canonical_json(env: ProvenanceEnvelope) -> str:
83
+ return json.dumps(envelope_to_dict(env), sort_keys=True, separators=(",", ":"))
@@ -0,0 +1 @@
1
+ """Deterministic offline evaluation: replay the spine, write and re-check the manifest."""