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 +21 -0
- fetchgate/app.py +20 -0
- fetchgate/config.py +91 -0
- fetchgate/detect.py +196 -0
- fetchgate/envelope.py +83 -0
- fetchgate/eval/__init__.py +1 -0
- fetchgate/eval/corpus/eval_manifest.json +564 -0
- fetchgate/eval/corpus/sample_envelope.json +46 -0
- fetchgate/eval/run_eval.py +122 -0
- fetchgate/eval/spine.py +124 -0
- fetchgate/extract.py +116 -0
- fetchgate/fetch.py +112 -0
- fetchgate/gate.py +100 -0
- fetchgate/mcp_server.py +55 -0
- fetchgate/transports.py +106 -0
- fetchgate/types.py +150 -0
- fetchgate/verify.py +75 -0
- fetchgate-0.1.0.dist-info/METADATA +147 -0
- fetchgate-0.1.0.dist-info/RECORD +22 -0
- fetchgate-0.1.0.dist-info/WHEEL +4 -0
- fetchgate-0.1.0.dist-info/entry_points.txt +2 -0
- fetchgate-0.1.0.dist-info/licenses/LICENSE +21 -0
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 #", "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."""
|