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/transports.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Transports return only a RawResponse, never a verdict."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Protocol
|
|
6
|
+
from urllib.parse import urlparse
|
|
7
|
+
|
|
8
|
+
from .types import RawResponse
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Transport(Protocol):
|
|
12
|
+
def fetch(self, url: str) -> RawResponse: ...
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class MockTransport:
|
|
16
|
+
"""Replays captured fixtures; raises on an unknown URL."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, fixtures: dict[str, RawResponse], renderer_used: str = "static") -> None:
|
|
19
|
+
self._fixtures = fixtures
|
|
20
|
+
self._renderer_used = renderer_used
|
|
21
|
+
|
|
22
|
+
def fetch(self, url: str) -> RawResponse:
|
|
23
|
+
if url not in self._fixtures:
|
|
24
|
+
raise KeyError(f"MockTransport: no fixture for {url!r}")
|
|
25
|
+
raw = self._fixtures[url]
|
|
26
|
+
raw.renderer_used = self._renderer_used
|
|
27
|
+
return raw
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class HttpTransport:
|
|
31
|
+
"""Static-tier transport over stdlib urllib; production swaps in httpx."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, timeout: float = 10.0, max_bytes: int = 5_000_000) -> None:
|
|
34
|
+
self._timeout = timeout
|
|
35
|
+
self._max_bytes = max_bytes
|
|
36
|
+
|
|
37
|
+
def fetch(self, url: str) -> RawResponse:
|
|
38
|
+
import urllib.error
|
|
39
|
+
import urllib.request
|
|
40
|
+
|
|
41
|
+
scheme = urlparse(url).scheme
|
|
42
|
+
if scheme not in ("http", "https"):
|
|
43
|
+
raise ValueError(f"unsupported scheme: {scheme!r}")
|
|
44
|
+
|
|
45
|
+
req = urllib.request.Request(url, headers={"User-Agent": "FetchGate/0.1 (+honest)"})
|
|
46
|
+
try:
|
|
47
|
+
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
|
|
48
|
+
body = resp.read(self._max_bytes + 1)
|
|
49
|
+
truncated = len(body) > self._max_bytes
|
|
50
|
+
if truncated:
|
|
51
|
+
body = body[: self._max_bytes]
|
|
52
|
+
final = resp.geturl()
|
|
53
|
+
return RawResponse(
|
|
54
|
+
url_requested=url,
|
|
55
|
+
url_final=final,
|
|
56
|
+
status=resp.status,
|
|
57
|
+
headers={k: v for k, v in resp.headers.items()},
|
|
58
|
+
body=body,
|
|
59
|
+
redirect_chain=[], # urllib does not expose the chain
|
|
60
|
+
transport_error=None,
|
|
61
|
+
renderer_used="static",
|
|
62
|
+
truncated=truncated,
|
|
63
|
+
)
|
|
64
|
+
except urllib.error.HTTPError as e:
|
|
65
|
+
body = e.read() if hasattr(e, "read") else b""
|
|
66
|
+
return RawResponse(url, e.geturl() if hasattr(e, "geturl") else url,
|
|
67
|
+
e.code, {k: v for k, v in (e.headers or {}).items()},
|
|
68
|
+
body, renderer_used="static")
|
|
69
|
+
except urllib.error.URLError as e:
|
|
70
|
+
reason = str(getattr(e, "reason", "")).lower()
|
|
71
|
+
err = "timeout" if "timed out" in reason else "dns" if "name" in reason else "reset"
|
|
72
|
+
return RawResponse(url, url, None, {}, b"", transport_error=err, renderer_used="static")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class BrowserTransport:
|
|
76
|
+
"""Headless render tier (Playwright). Honest: default UA, no stealth."""
|
|
77
|
+
|
|
78
|
+
def __init__(self, timeout: float = 20.0, settle_ms: int = 2000, max_bytes: int = 5_000_000) -> None:
|
|
79
|
+
self._timeout = timeout
|
|
80
|
+
self._settle = settle_ms
|
|
81
|
+
self._max_bytes = max_bytes
|
|
82
|
+
|
|
83
|
+
def fetch(self, url: str) -> RawResponse:
|
|
84
|
+
if urlparse(url).scheme not in ("http", "https"):
|
|
85
|
+
raise ValueError(f"unsupported scheme: {urlparse(url).scheme!r}")
|
|
86
|
+
try:
|
|
87
|
+
from playwright.sync_api import sync_playwright
|
|
88
|
+
except ImportError:
|
|
89
|
+
return RawResponse(url, url, None, {}, b"", transport_error="browser_unavailable", renderer_used="headless")
|
|
90
|
+
try:
|
|
91
|
+
with sync_playwright() as p:
|
|
92
|
+
browser = p.chromium.launch(headless=True)
|
|
93
|
+
try:
|
|
94
|
+
page = browser.new_page()
|
|
95
|
+
resp = page.goto(url, wait_until="networkidle", timeout=int(self._timeout * 1000))
|
|
96
|
+
page.wait_for_timeout(self._settle)
|
|
97
|
+
html = page.content().encode("utf-8")[: self._max_bytes]
|
|
98
|
+
headers = dict(resp.headers) if resp else {}
|
|
99
|
+
if not any(k.lower() == "content-type" for k in headers):
|
|
100
|
+
headers["Content-Type"] = "text/html; charset=utf-8"
|
|
101
|
+
return RawResponse(url, page.url, resp.status if resp else None,
|
|
102
|
+
headers, html, renderer_used="headless")
|
|
103
|
+
finally:
|
|
104
|
+
browser.close()
|
|
105
|
+
except Exception:
|
|
106
|
+
return RawResponse(url, url, None, {}, b"", transport_error="render_error", renderer_used="headless")
|
fetchgate/types.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Enums and frozen dataclasses for envelopes, signals, and gate results."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Verdict(str, Enum):
|
|
11
|
+
RETRIEVED = "RETRIEVED"
|
|
12
|
+
FAILED = "FAILED"
|
|
13
|
+
UNKNOWN = "UNKNOWN"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TransportClass(str, Enum):
|
|
17
|
+
OK = "OK"
|
|
18
|
+
FAIL = "FAIL"
|
|
19
|
+
AMBIGUOUS = "AMBIGUOUS"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ExtractionClass(str, Enum):
|
|
23
|
+
EXTRACT_OK = "EXTRACT_OK"
|
|
24
|
+
EMPTY = "EMPTY"
|
|
25
|
+
THIN = "THIN"
|
|
26
|
+
UNSUPPORTED = "UNSUPPORTED"
|
|
27
|
+
STRUCTURED_EMPTY = "STRUCTURED_EMPTY"
|
|
28
|
+
STRUCTURED_PARSE_FAIL = "STRUCTURED_PARSE_FAIL"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ValiditySeverity(str, Enum):
|
|
32
|
+
CLEAN = "clean"
|
|
33
|
+
CHALLENGE = "challenge"
|
|
34
|
+
SUSPECT = "suspect"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class FreshnessState(str, Enum):
|
|
38
|
+
FRESH = "FRESH"
|
|
39
|
+
STALE = "STALE"
|
|
40
|
+
UNKNOWN = "UNKNOWN"
|
|
41
|
+
NOT_REQUIRED = "NOT_REQUIRED"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class GateDisposition(str, Enum):
|
|
45
|
+
ALLOW = "ALLOW"
|
|
46
|
+
STOP = "STOP"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# Severity lattice: FAILED < UNKNOWN < RETRIEVED. decide() returns the minimum.
|
|
50
|
+
SEV = {Verdict.FAILED: 0, Verdict.UNKNOWN: 1, Verdict.RETRIEVED: 2}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class Redirect:
|
|
55
|
+
status: int
|
|
56
|
+
from_url: str
|
|
57
|
+
to_url: str
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class RawResponse:
|
|
62
|
+
"""What a transport returns. Never a verdict; the runtime rebuilds the envelope."""
|
|
63
|
+
|
|
64
|
+
url_requested: str
|
|
65
|
+
url_final: str
|
|
66
|
+
status: Optional[int]
|
|
67
|
+
headers: dict
|
|
68
|
+
body: bytes
|
|
69
|
+
redirect_chain: list = field(default_factory=list)
|
|
70
|
+
transport_error: Optional[str] = None # None | dns | timeout | reset | tls | too_many_redirects | redirect_loop | downgrade
|
|
71
|
+
elapsed_ms: int = 0
|
|
72
|
+
renderer_used: str = "static" # static | headless
|
|
73
|
+
truncated: bool = False
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def ok(self) -> bool:
|
|
77
|
+
return self.transport_error is None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True)
|
|
81
|
+
class TransportSignal:
|
|
82
|
+
cls: TransportClass
|
|
83
|
+
reason: str
|
|
84
|
+
cross_registrable_domain_redirect: bool = False
|
|
85
|
+
max_redirects_hit: bool = False
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True)
|
|
89
|
+
class ExtractionSignal:
|
|
90
|
+
cls: ExtractionClass
|
|
91
|
+
profile: str
|
|
92
|
+
extractor: str
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True)
|
|
96
|
+
class ValiditySignal:
|
|
97
|
+
severity: ValiditySeverity
|
|
98
|
+
fingerprint_hit: bool = False
|
|
99
|
+
fingerprint_id: Optional[str] = None
|
|
100
|
+
fingerprint_pack_version: str = "2026-07-01"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass(frozen=True)
|
|
104
|
+
class Signals:
|
|
105
|
+
transport: TransportSignal
|
|
106
|
+
extraction: ExtractionSignal
|
|
107
|
+
content_validity: ValiditySignal
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(frozen=True)
|
|
111
|
+
class Freshness:
|
|
112
|
+
required_max_age_seconds: Optional[int]
|
|
113
|
+
resource_timestamp: Optional[str]
|
|
114
|
+
resource_timestamp_source: Optional[str]
|
|
115
|
+
age_seconds: Optional[int]
|
|
116
|
+
age_vs_required_freshness: FreshnessState
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass(frozen=True)
|
|
120
|
+
class ProvenanceEnvelope:
|
|
121
|
+
envelope_version: str
|
|
122
|
+
runtime_version: str
|
|
123
|
+
request_id: str
|
|
124
|
+
url_requested: str
|
|
125
|
+
url_final: str
|
|
126
|
+
redirect_chain: list
|
|
127
|
+
http_status: Optional[int]
|
|
128
|
+
fetched_at: str
|
|
129
|
+
content_type: Optional[str]
|
|
130
|
+
raw_bytes: int
|
|
131
|
+
extracted_bytes: int
|
|
132
|
+
renderer_used: str
|
|
133
|
+
truncated: bool
|
|
134
|
+
content_sha256: Optional[str]
|
|
135
|
+
hash_target: str
|
|
136
|
+
extracted_sha256: Optional[str]
|
|
137
|
+
policy_id: str
|
|
138
|
+
policy_sha256: str
|
|
139
|
+
freshness: Freshness
|
|
140
|
+
signals: Signals
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass(frozen=True)
|
|
144
|
+
class GateResult:
|
|
145
|
+
verdict: Verdict
|
|
146
|
+
reason: str
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def disposition(self) -> GateDisposition:
|
|
150
|
+
return GateDisposition.ALLOW if self.verdict is Verdict.RETRIEVED else GateDisposition.STOP
|
fetchgate/verify.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Offline verifier: recompute the verdict from stored signals, bound to policy_sha256."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from .config import FetchPolicy
|
|
8
|
+
from .detect import decide
|
|
9
|
+
from .types import (
|
|
10
|
+
ExtractionClass, ExtractionSignal, Freshness, FreshnessState, GateResult,
|
|
11
|
+
ProvenanceEnvelope, Redirect, Signals, TransportClass, TransportSignal,
|
|
12
|
+
ValiditySeverity, ValiditySignal,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def envelope_from_dict(d: dict) -> ProvenanceEnvelope:
|
|
17
|
+
s = d["signals"]
|
|
18
|
+
fr = d["freshness"]
|
|
19
|
+
return ProvenanceEnvelope(
|
|
20
|
+
envelope_version=d["envelope_version"],
|
|
21
|
+
runtime_version=d["runtime_version"],
|
|
22
|
+
request_id=d["request_id"],
|
|
23
|
+
url_requested=d["url_requested"],
|
|
24
|
+
url_final=d["url_final"],
|
|
25
|
+
redirect_chain=[Redirect(r["status"], r["from"], r["to"]) for r in d["redirect_chain"]],
|
|
26
|
+
http_status=d["http_status"],
|
|
27
|
+
fetched_at=d["fetched_at"],
|
|
28
|
+
content_type=d["content_type"],
|
|
29
|
+
raw_bytes=d["raw_bytes"],
|
|
30
|
+
extracted_bytes=d["extracted_bytes"],
|
|
31
|
+
renderer_used=d["renderer_used"],
|
|
32
|
+
truncated=d["truncated"],
|
|
33
|
+
content_sha256=d["content_sha256"],
|
|
34
|
+
hash_target=d["hash_target"],
|
|
35
|
+
extracted_sha256=d["extracted_sha256"],
|
|
36
|
+
policy_id=d["policy_id"],
|
|
37
|
+
policy_sha256=d["policy_sha256"],
|
|
38
|
+
freshness=Freshness(
|
|
39
|
+
fr["required_max_age_seconds"], fr["resource_timestamp"],
|
|
40
|
+
fr["resource_timestamp_source"], fr["age_seconds"],
|
|
41
|
+
FreshnessState(fr["age_vs_required_freshness"]),
|
|
42
|
+
),
|
|
43
|
+
signals=Signals(
|
|
44
|
+
transport=TransportSignal(
|
|
45
|
+
TransportClass(s["transport"]["class"]), s["transport"]["reason"],
|
|
46
|
+
s["transport"]["cross_registrable_domain_redirect"],
|
|
47
|
+
s["transport"]["max_redirects_hit"],
|
|
48
|
+
),
|
|
49
|
+
extraction=ExtractionSignal(
|
|
50
|
+
ExtractionClass(s["extraction"]["class"]),
|
|
51
|
+
s["extraction"]["profile"], s["extraction"]["extractor"],
|
|
52
|
+
),
|
|
53
|
+
content_validity=ValiditySignal(
|
|
54
|
+
ValiditySeverity(s["content_validity"]["severity"]),
|
|
55
|
+
s["content_validity"]["fingerprint_hit"],
|
|
56
|
+
s["content_validity"]["fingerprint_id"],
|
|
57
|
+
s["content_validity"]["fingerprint_pack_version"],
|
|
58
|
+
),
|
|
59
|
+
),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class PolicyMismatch(ValueError):
|
|
64
|
+
"""The supplied policy does not match the one recorded in the envelope."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def recompute_verdict(envelope_dict: dict, policy: Optional[FetchPolicy] = None) -> GateResult:
|
|
68
|
+
"""Recompute the verdict; raises PolicyMismatch if policy != the receipt's policy."""
|
|
69
|
+
policy = policy or FetchPolicy()
|
|
70
|
+
recorded = envelope_dict.get("policy_sha256")
|
|
71
|
+
if recorded is not None and policy.sha256() != recorded:
|
|
72
|
+
raise PolicyMismatch(
|
|
73
|
+
f"policy_sha256 mismatch: envelope={recorded} supplied={policy.sha256()}"
|
|
74
|
+
)
|
|
75
|
+
return decide(envelope_from_dict(envelope_dict), policy)
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fetchgate
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A deterministic gate at the fetch boundary: a 200 is transport success, not a read.
|
|
5
|
+
Project-URL: Homepage, https://github.com/MoAz06/FetchGate
|
|
6
|
+
Project-URL: Repository, https://github.com/MoAz06/FetchGate
|
|
7
|
+
Project-URL: Issues, https://github.com/MoAz06/FetchGate/issues
|
|
8
|
+
Author: Mohamed Azahrioui
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: ai-agents,guardrails,llm,mcp,provenance,retrieval
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Provides-Extra: browser
|
|
21
|
+
Requires-Dist: playwright>=1.44; extra == 'browser'
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
24
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
25
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
26
|
+
Provides-Extra: extract
|
|
27
|
+
Requires-Dist: trafilatura>=1.9; extra == 'extract'
|
|
28
|
+
Provides-Extra: mcp
|
|
29
|
+
Requires-Dist: mcp>=1.2; extra == 'mcp'
|
|
30
|
+
Provides-Extra: sign
|
|
31
|
+
Requires-Dist: cryptography>=42; extra == 'sign'
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# FetchGate
|
|
35
|
+
|
|
36
|
+
**A 200 is not a read.**
|
|
37
|
+
|
|
38
|
+
When an AI agent fetches a page and it quietly comes back empty (a JavaScript app,
|
|
39
|
+
a 403, a Cloudflare wall), the agent often does not stop. It answers from memory
|
|
40
|
+
and never tells you it did not actually read the page. FetchGate is a small gate
|
|
41
|
+
at the fetch boundary that refuses to let that happen: the agent gets the content
|
|
42
|
+
only when the page was really read, and an honest refusal otherwise.
|
|
43
|
+
|
|
44
|
+
It is deterministic (no model, no API keys), stdlib-only at its core, and free.
|
|
45
|
+
|
|
46
|
+
## Quickstart
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
git clone https://github.com/MoAz06/FetchGate
|
|
50
|
+
cd FetchGate
|
|
51
|
+
python demo.py https://example.com https://httpbin.org/status/403
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
https://example.com
|
|
56
|
+
verdict : RETRIEVED (clean)
|
|
57
|
+
answerable : ALLOW
|
|
58
|
+
bytes : raw 559 / extracted 127
|
|
59
|
+
tiers : static
|
|
60
|
+
gate-confirmed content (127 chars): Example Domain This domain is for use ...
|
|
61
|
+
|
|
62
|
+
https://httpbin.org/status/403
|
|
63
|
+
verdict : FAILED (transport.http_403)
|
|
64
|
+
answerable : STOP
|
|
65
|
+
refused: the model gets no content and cannot answer as if it read this page.
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Run the tests (no network, no model):
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
pip install -e .
|
|
72
|
+
python -m unittest discover -s tests
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Use it in Claude Desktop or Cursor
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
pip install ".[mcp]" # the fetch tool
|
|
79
|
+
pip install ".[browser]" # optional: render JavaScript pages
|
|
80
|
+
python -m playwright install chromium
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Add to your MCP config (details in [docs/mcp.md](docs/mcp.md)):
|
|
84
|
+
|
|
85
|
+
```json
|
|
86
|
+
{ "mcpServers": { "fetchgate": { "command": "fetchgate-mcp" } } }
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Your agent now has a `fetch_url` tool that returns content only for a confirmed
|
|
90
|
+
read, and a refusal otherwise.
|
|
91
|
+
|
|
92
|
+
## Use it in your own agent
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
from fetchgate import default_gate
|
|
96
|
+
|
|
97
|
+
gate = default_gate() # renders JS pages if the [browser] extra is installed
|
|
98
|
+
|
|
99
|
+
def read_url(url: str) -> str:
|
|
100
|
+
out = gate.guarded_fetch(url)
|
|
101
|
+
content = gate.content_for(out.handle)
|
|
102
|
+
if content is None:
|
|
103
|
+
return f"NOT READ: {url} ({out.result.reason}). Do not answer as if you read it."
|
|
104
|
+
return content
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## How it decides
|
|
108
|
+
|
|
109
|
+
Every fetch gets one verdict:
|
|
110
|
+
|
|
111
|
+
- **RETRIEVED**: a real read. The agent may answer and cite it.
|
|
112
|
+
- **FAILED**: a definite non-read (a 4xx or 5xx, a timeout, an https to http
|
|
113
|
+
downgrade, an empty body). Stop.
|
|
114
|
+
- **UNKNOWN**: fetched, but not enough readable content to confirm a read (often a
|
|
115
|
+
JavaScript page). Stop, or render and try again.
|
|
116
|
+
|
|
117
|
+
Three layers feed the verdict, and the result is the strictest of them:
|
|
118
|
+
|
|
119
|
+
- **transport**: status and redirect chain.
|
|
120
|
+
- **extraction**: readable text kept separate from raw bytes. A 17 byte price JSON
|
|
121
|
+
is a read, a 400 KB article at 2 percent text is a read, a JS shell with no text
|
|
122
|
+
is not.
|
|
123
|
+
- **content-validity**: a best-effort fingerprint for Cloudflare, consent,
|
|
124
|
+
paywall, and soft-404 walls. It can only downgrade to UNKNOWN, never fake a read.
|
|
125
|
+
|
|
126
|
+
When a page looks thin or empty and the render tier is on, FetchGate escalates to
|
|
127
|
+
a real headless browser and re-checks. It renders, it does not evade: a page still
|
|
128
|
+
walled after an honest render stays a non-read.
|
|
129
|
+
|
|
130
|
+
## Stated limits
|
|
131
|
+
|
|
132
|
+
- `content_sha256` is a receipt anchor, not tamper-evidence. A cloaked page hashes
|
|
133
|
+
cleanly.
|
|
134
|
+
- Provenance is not authenticity. The gate closes fail-silent-on-empty, not
|
|
135
|
+
fetch-succeeds-on-wrong-content.
|
|
136
|
+
- A read is not an answer. RETRIEVED means enough content arrived, not that it held
|
|
137
|
+
the fact you need. Claim support is a downstream job.
|
|
138
|
+
- Fail-closed has an availability cost. Anyone who can make your fetch fail can
|
|
139
|
+
force a refusal. That is the trade for correctness.
|
|
140
|
+
|
|
141
|
+
## What is built
|
|
142
|
+
|
|
143
|
+
The deterministic core, the offline evaluation with a signed manifest, the
|
|
144
|
+
build-breaking cheat-test, the MCP server, and the Playwright render tier. Optional
|
|
145
|
+
extras: `[mcp]`, `[browser]`, `[extract]` (trafilatura), `[sign]` (Ed25519).
|
|
146
|
+
|
|
147
|
+
MIT licensed.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
fetchgate/__init__.py,sha256=JVXm2Yreodh_dpAdModgMEoY0ck_Ud_WyEJdkJVwgSc,878
|
|
2
|
+
fetchgate/app.py,sha256=QkTJktb3a2JXnXe-ur0UTgi6_G12KrmlBtHvtiu1jKI,657
|
|
3
|
+
fetchgate/config.py,sha256=yKzfVcwmPhkuszxF705aY2rHWnEFY7NlDOPd9JkOG5U,3694
|
|
4
|
+
fetchgate/detect.py,sha256=wnjEYgxGi2lV1MIC1oHX_K1QWUoNsdAZ20TE86I3-ww,9051
|
|
5
|
+
fetchgate/envelope.py,sha256=zzjQfWSv5EH-DkIJSYnMfi_-zhXmGH2txDSfNhUg5m0,3018
|
|
6
|
+
fetchgate/extract.py,sha256=y_6jHmwHOcmofaa_5WqL7yBRtJsc-ObLKP_zKC4TkL4,3984
|
|
7
|
+
fetchgate/fetch.py,sha256=oZhtzd9e5N5TGjCHfxHJHImt-c4rZ7StOXrSS21-TyQ,3724
|
|
8
|
+
fetchgate/gate.py,sha256=MnbfK0nDTWj9pJkNuFtjK345-U3G_mzGDr9saGqjovw,4075
|
|
9
|
+
fetchgate/mcp_server.py,sha256=t_FtAmbUJUtuTCvebT2SI4adfwerWWVBER2wg1Hc6E4,1875
|
|
10
|
+
fetchgate/transports.py,sha256=YBJ5XFpYE0vPL2AI_rX1zzI4WFqNxXMtXDiL-QZBVs8,4527
|
|
11
|
+
fetchgate/types.py,sha256=ZLUoQV4SK4CWz0wz3dWhOhu3U8mpTmimsGu-NQjc3aU,3450
|
|
12
|
+
fetchgate/verify.py,sha256=fqHuYGTImBVxtpf2Jl_izeaK37vWuIkYxZ8vz8xSnLE,3017
|
|
13
|
+
fetchgate/eval/__init__.py,sha256=09j08Q6i9rs0kMIfPxhT8bPV96PAEzpbY3VpHTjDW54,91
|
|
14
|
+
fetchgate/eval/run_eval.py,sha256=kGMRxZ4rBMZ6eCH-1V3Uz2BiDqBraVoZj6lGRmtmWT4,4392
|
|
15
|
+
fetchgate/eval/spine.py,sha256=gSWLCNMNeKeBYeE-KIJNTk4s5FLjAlmqIdb_HREMK44,6626
|
|
16
|
+
fetchgate/eval/corpus/eval_manifest.json,sha256=hK-Ku3cCQWL3aUwbZqiOsVtk4vOyYvZ0pEivtsJyShU,20712
|
|
17
|
+
fetchgate/eval/corpus/sample_envelope.json,sha256=WqxSZJDE6uwXjznu-Ja7Xv_pp9Ehc8XcXOu04gVTiqo,1482
|
|
18
|
+
fetchgate-0.1.0.dist-info/METADATA,sha256=vxi39xcs2ukxa-ea4A4lv9G4qdDx8Yp9JC5GS8aM3h4,5132
|
|
19
|
+
fetchgate-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
20
|
+
fetchgate-0.1.0.dist-info/entry_points.txt,sha256=SlR-YZR_sOGbvespC0Ihk-vHC81XHmbSwe3QkvInGlY,60
|
|
21
|
+
fetchgate-0.1.0.dist-info/licenses/LICENSE,sha256=8pw6L2DaNOig8vyqHsnRamv51zKosOBeA9SZA9wzrZQ,1074
|
|
22
|
+
fetchgate-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mohamed Azahrioui
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|