fetchgate 0.1.0__tar.gz → 0.2.0__tar.gz

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.
Files changed (52) hide show
  1. fetchgate-0.2.0/.claude-plugin/marketplace.json +17 -0
  2. fetchgate-0.2.0/.claude-plugin/plugin.json +16 -0
  3. {fetchgate-0.1.0 → fetchgate-0.2.0}/.gitignore +1 -0
  4. {fetchgate-0.1.0 → fetchgate-0.2.0}/PKG-INFO +39 -4
  5. {fetchgate-0.1.0 → fetchgate-0.2.0}/README.md +36 -3
  6. fetchgate-0.2.0/bundle/manifest.json +17 -0
  7. fetchgate-0.2.0/bundle/requirements.txt +1 -0
  8. fetchgate-0.2.0/bundle/server/main.py +4 -0
  9. fetchgate-0.2.0/docs/claude-code-plugin.md +43 -0
  10. fetchgate-0.2.0/docs/config.md +54 -0
  11. fetchgate-0.2.0/docs/evaluation.md +37 -0
  12. fetchgate-0.2.0/fetchgate.example.toml +22 -0
  13. {fetchgate-0.1.0 → fetchgate-0.2.0}/pyproject.toml +2 -1
  14. fetchgate-0.2.0/smithery.yaml +11 -0
  15. {fetchgate-0.1.0 → fetchgate-0.2.0}/src/fetchgate/__init__.py +3 -3
  16. fetchgate-0.2.0/src/fetchgate/app.py +21 -0
  17. {fetchgate-0.1.0 → fetchgate-0.2.0}/src/fetchgate/config.py +44 -0
  18. {fetchgate-0.1.0 → fetchgate-0.2.0}/src/fetchgate/eval/corpus/eval_manifest.json +30 -30
  19. {fetchgate-0.1.0 → fetchgate-0.2.0}/src/fetchgate/eval/corpus/sample_envelope.json +2 -2
  20. {fetchgate-0.1.0 → fetchgate-0.2.0}/src/fetchgate/eval/run_eval.py +1 -1
  21. {fetchgate-0.1.0 → fetchgate-0.2.0}/src/fetchgate/eval/spine.py +1 -1
  22. fetchgate-0.2.0/src/fetchgate/eval/tier_b.py +107 -0
  23. {fetchgate-0.1.0 → fetchgate-0.2.0}/src/fetchgate/extract.py +53 -10
  24. {fetchgate-0.1.0 → fetchgate-0.2.0}/src/fetchgate/fetch.py +5 -4
  25. {fetchgate-0.1.0 → fetchgate-0.2.0}/src/fetchgate/gate.py +11 -12
  26. fetchgate-0.2.0/src/fetchgate/mcp_server.py +77 -0
  27. {fetchgate-0.1.0 → fetchgate-0.2.0}/src/fetchgate/transports.py +34 -20
  28. {fetchgate-0.1.0 → fetchgate-0.2.0}/src/fetchgate/types.py +11 -0
  29. fetchgate-0.2.0/tests/test_config.py +72 -0
  30. {fetchgate-0.1.0 → fetchgate-0.2.0}/tests/test_gate.py +12 -1
  31. fetchgate-0.2.0/tests/test_mcp.py +68 -0
  32. fetchgate-0.2.0/tests/test_redirect.py +31 -0
  33. fetchgate-0.2.0/tests/test_tier_b.py +23 -0
  34. fetchgate-0.1.0/src/fetchgate/app.py +0 -20
  35. fetchgate-0.1.0/src/fetchgate/mcp_server.py +0 -55
  36. fetchgate-0.1.0/tests/test_mcp.py +0 -40
  37. {fetchgate-0.1.0 → fetchgate-0.2.0}/LICENSE +0 -0
  38. {fetchgate-0.1.0 → fetchgate-0.2.0}/demo.py +0 -0
  39. {fetchgate-0.1.0 → fetchgate-0.2.0}/docs/mcp.md +0 -0
  40. {fetchgate-0.1.0 → fetchgate-0.2.0}/docs/threat-model.md +0 -0
  41. {fetchgate-0.1.0 → fetchgate-0.2.0}/src/fetchgate/detect.py +0 -0
  42. {fetchgate-0.1.0 → fetchgate-0.2.0}/src/fetchgate/envelope.py +0 -0
  43. {fetchgate-0.1.0 → fetchgate-0.2.0}/src/fetchgate/eval/__init__.py +0 -0
  44. {fetchgate-0.1.0 → fetchgate-0.2.0}/src/fetchgate/verify.py +0 -0
  45. {fetchgate-0.1.0 → fetchgate-0.2.0}/tests/helpers.py +0 -0
  46. {fetchgate-0.1.0 → fetchgate-0.2.0}/tests/test_cheat.py +0 -0
  47. {fetchgate-0.1.0 → fetchgate-0.2.0}/tests/test_decision_table_totality.py +0 -0
  48. {fetchgate-0.1.0 → fetchgate-0.2.0}/tests/test_envelope.py +0 -0
  49. {fetchgate-0.1.0 → fetchgate-0.2.0}/tests/test_eval_and_controls.py +0 -0
  50. {fetchgate-0.1.0 → fetchgate-0.2.0}/tests/test_extract.py +0 -0
  51. {fetchgate-0.1.0 → fetchgate-0.2.0}/tests/test_render.py +0 -0
  52. {fetchgate-0.1.0 → fetchgate-0.2.0}/tests/test_transport.py +0 -0
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "fetchgate",
3
+ "description": "FetchGate: a deterministic gate at the fetch boundary for AI agents.",
4
+ "owner": { "name": "Mohamed Azahrioui" },
5
+ "plugins": [
6
+ {
7
+ "name": "fetchgate",
8
+ "source": "./",
9
+ "description": "The agent only answers from a page it actually read.",
10
+ "version": "0.1.0",
11
+ "homepage": "https://github.com/MoAz06/FetchGate",
12
+ "repository": "https://github.com/MoAz06/FetchGate",
13
+ "license": "MIT",
14
+ "keywords": ["mcp", "ai-agents", "guardrails"]
15
+ }
16
+ ]
17
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "fetchgate",
3
+ "displayName": "FetchGate",
4
+ "description": "A gate at the fetch boundary: the agent only answers from a page it actually read.",
5
+ "version": "0.1.0",
6
+ "author": { "name": "Mohamed Azahrioui" },
7
+ "homepage": "https://github.com/MoAz06/FetchGate",
8
+ "repository": "https://github.com/MoAz06/FetchGate",
9
+ "license": "MIT",
10
+ "keywords": ["mcp", "ai-agents", "retrieval", "guardrails"],
11
+ "mcpServers": {
12
+ "fetchgate": {
13
+ "command": "fetchgate-mcp"
14
+ }
15
+ }
16
+ }
@@ -7,3 +7,4 @@ __pycache__/
7
7
  dist/
8
8
  build/
9
9
  .venv/
10
+ *.mcpb
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fetchgate
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: A deterministic gate at the fetch boundary: a 200 is transport success, not a read.
5
5
  Project-URL: Homepage, https://github.com/MoAz06/FetchGate
6
6
  Project-URL: Repository, https://github.com/MoAz06/FetchGate
@@ -25,6 +25,8 @@ Requires-Dist: pytest>=8; extra == 'dev'
25
25
  Requires-Dist: ruff>=0.5; extra == 'dev'
26
26
  Provides-Extra: extract
27
27
  Requires-Dist: trafilatura>=1.9; extra == 'extract'
28
+ Provides-Extra: http
29
+ Requires-Dist: httpx>=0.27; extra == 'http'
28
30
  Provides-Extra: mcp
29
31
  Requires-Dist: mcp>=1.2; extra == 'mcp'
30
32
  Provides-Extra: sign
@@ -72,10 +74,32 @@ pip install -e .
72
74
  python -m unittest discover -s tests
73
75
  ```
74
76
 
75
- ## Use it in Claude Desktop or Cursor
77
+ ## Easiest: one double-click (Claude Desktop)
78
+
79
+ Download `fetchgate.mcpb` from the [Releases](https://github.com/MoAz06/FetchGate/releases)
80
+ page and double-click it. Claude Desktop shows an Install button, no JSON editing.
81
+ The bundle source is in [bundle/](bundle); rebuild it with
82
+ `npx @anthropic-ai/mcpb pack bundle fetchgate.mcpb`.
83
+
84
+ ## Vibecoders: add it from inside your editor
85
+
86
+ **Cursor, one click** (needs `uv`, which most setups have):
87
+
88
+ [Add FetchGate to Cursor](cursor://anysphere.cursor-deeplink/mcp/install?name=fetchgate&config=eyJjb21tYW5kIjogInV2eCIsICJhcmdzIjogWyItLWZyb20iLCAiZmV0Y2hnYXRlW21jcF0iLCAiZmV0Y2hnYXRlLW1jcCJdfQ==)
89
+
90
+ **Or just ask your AI.** Paste this into Cursor, Claude Code, or Windsurf:
91
+
92
+ > Add the fetchgate MCP server to this setup. Run `pip install "fetchgate[mcp]"`
93
+ > and register it as an MCP server named `fetchgate` with the command
94
+ > `fetchgate-mcp`. It stops an agent from answering as if it read a page it could
95
+ > not actually fetch.
96
+
97
+ Your assistant runs the install and wires up the config for you.
98
+
99
+ ## Use it in Claude Desktop or Cursor (manual)
76
100
 
77
101
  ```bash
78
- pip install ".[mcp]" # the fetch tool
102
+ pip install ".[mcp,http,extract]" # the fetch tool, real redirect chain, better extraction
79
103
  pip install ".[browser]" # optional: render JavaScript pages
80
104
  python -m playwright install chromium
81
105
  ```
@@ -89,6 +113,15 @@ Add to your MCP config (details in [docs/mcp.md](docs/mcp.md)):
89
113
  Your agent now has a `fetch_url` tool that returns content only for a confirmed
90
114
  read, and a refusal otherwise.
91
115
 
116
+ This repo is also a Claude Code plugin. After `pip install "fetchgate[mcp]"`:
117
+
118
+ ```bash
119
+ /plugin marketplace add MoAz06/FetchGate
120
+ /plugin install fetchgate@fetchgate
121
+ ```
122
+
123
+ See [docs/claude-code-plugin.md](docs/claude-code-plugin.md).
124
+
92
125
  ## Use it in your own agent
93
126
 
94
127
  ```python
@@ -142,6 +175,8 @@ walled after an honest render stays a non-read.
142
175
 
143
176
  The deterministic core, the offline evaluation with a signed manifest, the
144
177
  build-breaking cheat-test, the MCP server, and the Playwright render tier. Optional
145
- extras: `[mcp]`, `[browser]`, `[extract]` (trafilatura), `[sign]` (Ed25519).
178
+ extras: `[mcp]`, `[http]` (httpx redirect chain), `[extract]` (trafilatura),
179
+ `[browser]` (Playwright), `[sign]` (Ed25519). When installed they are used
180
+ automatically; the core stays stdlib-only.
146
181
 
147
182
  MIT licensed.
@@ -39,10 +39,32 @@ pip install -e .
39
39
  python -m unittest discover -s tests
40
40
  ```
41
41
 
42
- ## Use it in Claude Desktop or Cursor
42
+ ## Easiest: one double-click (Claude Desktop)
43
+
44
+ Download `fetchgate.mcpb` from the [Releases](https://github.com/MoAz06/FetchGate/releases)
45
+ page and double-click it. Claude Desktop shows an Install button, no JSON editing.
46
+ The bundle source is in [bundle/](bundle); rebuild it with
47
+ `npx @anthropic-ai/mcpb pack bundle fetchgate.mcpb`.
48
+
49
+ ## Vibecoders: add it from inside your editor
50
+
51
+ **Cursor, one click** (needs `uv`, which most setups have):
52
+
53
+ [Add FetchGate to Cursor](cursor://anysphere.cursor-deeplink/mcp/install?name=fetchgate&config=eyJjb21tYW5kIjogInV2eCIsICJhcmdzIjogWyItLWZyb20iLCAiZmV0Y2hnYXRlW21jcF0iLCAiZmV0Y2hnYXRlLW1jcCJdfQ==)
54
+
55
+ **Or just ask your AI.** Paste this into Cursor, Claude Code, or Windsurf:
56
+
57
+ > Add the fetchgate MCP server to this setup. Run `pip install "fetchgate[mcp]"`
58
+ > and register it as an MCP server named `fetchgate` with the command
59
+ > `fetchgate-mcp`. It stops an agent from answering as if it read a page it could
60
+ > not actually fetch.
61
+
62
+ Your assistant runs the install and wires up the config for you.
63
+
64
+ ## Use it in Claude Desktop or Cursor (manual)
43
65
 
44
66
  ```bash
45
- pip install ".[mcp]" # the fetch tool
67
+ pip install ".[mcp,http,extract]" # the fetch tool, real redirect chain, better extraction
46
68
  pip install ".[browser]" # optional: render JavaScript pages
47
69
  python -m playwright install chromium
48
70
  ```
@@ -56,6 +78,15 @@ Add to your MCP config (details in [docs/mcp.md](docs/mcp.md)):
56
78
  Your agent now has a `fetch_url` tool that returns content only for a confirmed
57
79
  read, and a refusal otherwise.
58
80
 
81
+ This repo is also a Claude Code plugin. After `pip install "fetchgate[mcp]"`:
82
+
83
+ ```bash
84
+ /plugin marketplace add MoAz06/FetchGate
85
+ /plugin install fetchgate@fetchgate
86
+ ```
87
+
88
+ See [docs/claude-code-plugin.md](docs/claude-code-plugin.md).
89
+
59
90
  ## Use it in your own agent
60
91
 
61
92
  ```python
@@ -109,6 +140,8 @@ walled after an honest render stays a non-read.
109
140
 
110
141
  The deterministic core, the offline evaluation with a signed manifest, the
111
142
  build-breaking cheat-test, the MCP server, and the Playwright render tier. Optional
112
- extras: `[mcp]`, `[browser]`, `[extract]` (trafilatura), `[sign]` (Ed25519).
143
+ extras: `[mcp]`, `[http]` (httpx redirect chain), `[extract]` (trafilatura),
144
+ `[browser]` (Playwright), `[sign]` (Ed25519). When installed they are used
145
+ automatically; the core stays stdlib-only.
113
146
 
114
147
  MIT licensed.
@@ -0,0 +1,17 @@
1
+ {
2
+ "manifest_version": "0.2",
3
+ "name": "fetchgate",
4
+ "display_name": "FetchGate",
5
+ "version": "0.1.0",
6
+ "description": "A gate at the fetch boundary: the agent only answers from a page it actually read.",
7
+ "author": { "name": "Mohamed Azahrioui" },
8
+ "homepage": "https://github.com/MoAz06/FetchGate",
9
+ "server": {
10
+ "type": "python",
11
+ "entry_point": "server/main.py",
12
+ "mcp_config": {
13
+ "command": "python",
14
+ "args": ["${__dirname}/server/main.py"]
15
+ }
16
+ }
17
+ }
@@ -0,0 +1 @@
1
+ fetchgate[mcp]
@@ -0,0 +1,4 @@
1
+ from fetchgate.mcp_server import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,43 @@
1
+ # FetchGate as a Claude Code plugin
2
+
3
+ This repo is also a Claude Code plugin. Installing it registers the `fetchgate`
4
+ MCP server so Claude Code gets the `fetch_url` tool.
5
+
6
+ ## Prerequisite
7
+
8
+ The plugin registers the server config, it does not install the Python package.
9
+ So the `fetchgate-mcp` command has to be on your PATH first:
10
+
11
+ ```bash
12
+ pip install "fetchgate[mcp]"
13
+ pip install "fetchgate[browser]" # optional: render JavaScript pages
14
+ python -m playwright install chromium # optional, for the render tier
15
+ ```
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ /plugin marketplace add MoAz06/FetchGate
21
+ /plugin install fetchgate@fetchgate
22
+ /mcp # confirm the fetchgate server is listed
23
+ ```
24
+
25
+ Update later with:
26
+
27
+ ```bash
28
+ /plugin marketplace update fetchgate
29
+ /plugin update fetchgate@fetchgate
30
+ ```
31
+
32
+ ## One-click once it is on PyPI
33
+
34
+ After publishing to PyPI you can drop the manual pip step by having the plugin run
35
+ the server through `uvx` (needs `uv` installed). Change the command in
36
+ `.claude-plugin/plugin.json` to:
37
+
38
+ ```json
39
+ { "command": "uvx", "args": ["--from", "fetchgate[mcp]", "fetchgate-mcp"] }
40
+ ```
41
+
42
+ Then installing the plugin fetches and runs the server on first use, no separate
43
+ pip install.
@@ -0,0 +1,54 @@
1
+ # Configuring FetchGate
2
+
3
+ FetchGate reads a `fetchgate.toml` if it finds one, so you can set policy without
4
+ touching code. This also configures the MCP server, since it uses `default_gate()`.
5
+
6
+ ## Where it looks
7
+
8
+ In order:
9
+ 1. a path you pass to `load_policy(path)`
10
+ 2. `$FETCHGATE_CONFIG`
11
+ 3. `./fetchgate.toml`
12
+ 4. `~/.config/fetchgate/config.toml`
13
+
14
+ If none exists, sensible defaults apply (render when the `[browser]` extra is
15
+ installed, otherwise static only).
16
+
17
+ ## Example
18
+
19
+ ```toml
20
+ default_mode = "render_allowed" # never_render | render_allowed | always_hard_fail
21
+ html_extractor = "auto" # auto | stdlib | trafilatura
22
+ unverified_disposition = "stop" # stop | warn
23
+ max_redirects = 5
24
+ block_cross_origin_redirect = true
25
+ unsupported_type_policy = "UNKNOWN" # UNKNOWN | BYTES_OK
26
+
27
+ [domains]
28
+ "docs.mycompany.com" = "render_allowed"
29
+ "api.stripe.com" = "never_render"
30
+ "pinterest.com" = "always_hard_fail"
31
+ ```
32
+
33
+ ## Stop or warn on an unconfirmed read
34
+
35
+ The verdict is always deterministic. `unverified_disposition` only decides what the
36
+ MCP tool does with an `UNKNOWN` read (a fetch that arrived but could not be confirmed
37
+ as readable, for example a thin or JavaScript-rendered page):
38
+
39
+ - `stop` (default, fail-closed): return a refusal with no page content.
40
+ - `warn`: return a warning banner plus whatever text was extracted, clearly labelled
41
+ as unverified, so the agent can proceed with caution instead of being blocked.
42
+
43
+ A hard `FAILED` (transport error, 403, downgrade) always refuses regardless of this
44
+ setting, because there is no content to hand over.
45
+
46
+ See [fetchgate.example.toml](../fetchgate.example.toml).
47
+
48
+ ## In code
49
+
50
+ ```python
51
+ from fetchgate import load_policy, FetchGate, HttpTransport
52
+
53
+ gate = FetchGate(load_policy(), HttpTransport())
54
+ ```
@@ -0,0 +1,37 @@
1
+ # FetchGate evaluation
2
+
3
+ Two numbers, two tiers.
4
+
5
+ ## Headline (deterministic, offline, no model)
6
+
7
+ ```bash
8
+ python -m fetchgate.eval.run_eval
9
+ ```
10
+
11
+ Replays the 9-scenario spine through the gate and verifies it offline by
12
+ recomputing every verdict from the stored signals (no re-extraction). Result:
13
+
14
+ > the gate admitted the 3 true reads and refused the 6 non-reads. 0 non-reads
15
+ > reached answerable state.
16
+
17
+ This needs no model and is re-checkable by anyone.
18
+
19
+ ## Model exhibit (the author runs this once)
20
+
21
+ ```bash
22
+ # deterministic proof of the harness (a mock model that always answers):
23
+ python -m fetchgate.eval.tier_b
24
+
25
+ # the real number (needs the anthropic SDK and ANTHROPIC_API_KEY):
26
+ python -m fetchgate.eval.tier_b claude-<pinned-model-id>
27
+ ```
28
+
29
+ Over the pages FetchGate calls non-reads, it measures how often an ungated agent
30
+ answers anyway, versus a gated one:
31
+
32
+ > ungated answered N of M non-reads; gated answered 0; and still answered every
33
+ > real read.
34
+
35
+ The gated 0 is by construction: on a non-read the gate refuses before the model
36
+ is ever asked. Pin the exact dated model id and temperature 0.0 so the number is
37
+ reproducible.
@@ -0,0 +1,22 @@
1
+ # FetchGate configuration. Copy to fetchgate.toml, or point $FETCHGATE_CONFIG at it,
2
+ # or drop it at ~/.config/fetchgate/config.toml.
3
+
4
+ # never_render | render_allowed | always_hard_fail
5
+ default_mode = "render_allowed"
6
+
7
+ # auto (trafilatura if installed) | stdlib | trafilatura
8
+ html_extractor = "auto"
9
+
10
+ # What the MCP tool does with an UNKNOWN read (thin or JS page):
11
+ # stop = refuse with no content (fail-closed), warn = hand over the text with a warning.
12
+ # A hard FAILED always refuses either way.
13
+ unverified_disposition = "stop" # stop | warn
14
+
15
+ max_redirects = 5
16
+ block_cross_origin_redirect = true
17
+ unsupported_type_policy = "UNKNOWN" # UNKNOWN | BYTES_OK
18
+
19
+ [domains]
20
+ "docs.mycompany.com" = "render_allowed"
21
+ "api.stripe.com" = "never_render" # structured JSON, rendering is pointless
22
+ "pinterest.com" = "always_hard_fail" # never even try
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "fetchgate"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "A deterministic gate at the fetch boundary: a 200 is transport success, not a read."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -29,6 +29,7 @@ Issues = "https://github.com/MoAz06/FetchGate/issues"
29
29
 
30
30
  [project.optional-dependencies]
31
31
  mcp = ["mcp>=1.2"] # run FetchGate as a local MCP server
32
+ http = ["httpx>=0.27"] # real redirect chain for the static transport
32
33
  extract = ["trafilatura>=1.9"] # production HTML main-text extractor (seam)
33
34
  browser = ["playwright>=1.44"] # optional headless render tier
34
35
  sign = ["cryptography>=42"] # optional Ed25519 manifest signing
@@ -0,0 +1,11 @@
1
+ # Smithery config for FetchGate.
2
+ # Runs the server locally over stdio via uvx, which fetches fetchgate from PyPI
3
+ # on first use, so there is no manual pip step for the user.
4
+ startCommand:
5
+ type: stdio
6
+ configSchema:
7
+ type: object
8
+ properties: {}
9
+ additionalProperties: false
10
+ commandFunction: |
11
+ (config) => ({ command: 'uvx', args: ['--from', 'fetchgate[mcp]', 'fetchgate-mcp'] })
@@ -1,7 +1,7 @@
1
1
  """FetchGate: a deterministic gate at the fetch boundary. A 200 is not a read."""
2
2
 
3
3
  from .app import default_gate
4
- from .config import DomainPolicy, FetchPolicy
4
+ from .config import DomainPolicy, FetchPolicy, load_policy
5
5
  from .detect import decide
6
6
  from .fetch import build_envelope, fetch
7
7
  from .gate import FetchGate, GateOutcome
@@ -11,10 +11,10 @@ from .types import (
11
11
  )
12
12
  from .verify import envelope_from_dict, recompute_verdict
13
13
 
14
- __version__ = "0.1.0"
14
+ __version__ = "0.2.0"
15
15
 
16
16
  __all__ = [
17
- "default_gate", "FetchPolicy", "DomainPolicy", "decide", "fetch", "build_envelope",
17
+ "default_gate", "load_policy", "FetchPolicy", "DomainPolicy", "decide", "fetch", "build_envelope",
18
18
  "FetchGate", "GateOutcome", "Transport", "MockTransport", "HttpTransport",
19
19
  "BrowserTransport", "RawResponse", "ProvenanceEnvelope", "GateResult", "Verdict",
20
20
  "GateDisposition", "envelope_from_dict", "recompute_verdict", "__version__",
@@ -0,0 +1,21 @@
1
+ """Convenience factory: a gate from config, with the render tier if available."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .config import load_policy
6
+ from .gate import FetchGate
7
+ from .transports import BrowserTransport, HttpTransport
8
+
9
+
10
+ def default_gate() -> FetchGate:
11
+ """A ready gate. Reads fetchgate.toml if present, renders JS pages when the
12
+ [browser] extra is installed (unless the config overrides default_mode)."""
13
+ heavy = None
14
+ fallback = "never_render"
15
+ try:
16
+ import playwright # noqa: F401
17
+ heavy = BrowserTransport()
18
+ fallback = "render_allowed"
19
+ except ImportError:
20
+ pass
21
+ return FetchGate(load_policy(default_mode_fallback=fallback), HttpTransport(), heavy)
@@ -36,6 +36,8 @@ class FetchPolicy:
36
36
  block_cross_origin_redirect: bool = True
37
37
  max_redirects: int = 5
38
38
  unsupported_type_policy: str = "UNKNOWN"
39
+ html_extractor: str = "auto" # auto | stdlib | trafilatura
40
+ unverified_disposition: str = "stop" # stop | warn: what an unconfirmed read does at the boundary
39
41
  domains: dict = field(default_factory=dict) # registrable-domain -> DomainPolicy
40
42
 
41
43
  def profile_for(self, content_type: Optional[str]) -> ExtractionProfile:
@@ -72,12 +74,54 @@ class FetchPolicy:
72
74
  "block_cross_origin_redirect": self.block_cross_origin_redirect,
73
75
  "max_redirects": self.max_redirects,
74
76
  "unsupported_type_policy": self.unsupported_type_policy,
77
+ "html_extractor": self.html_extractor,
78
+ "unverified_disposition": self.unverified_disposition,
75
79
  "domains": {k: v.__dict__ for k, v in sorted(self.domains.items())},
76
80
  }
77
81
  raw = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
78
82
  return hashlib.sha256(raw).hexdigest()
79
83
 
80
84
 
85
+ def _read_config(path: Optional[str]) -> Optional[dict]:
86
+ import os
87
+ import tomllib
88
+ candidates = [path, os.environ.get("FETCHGATE_CONFIG"), "fetchgate.toml",
89
+ os.path.join(os.path.expanduser("~"), ".config", "fetchgate", "config.toml")]
90
+ for c in candidates:
91
+ if c and os.path.isfile(c):
92
+ with open(c, "rb") as f:
93
+ return tomllib.load(f)
94
+ return None
95
+
96
+
97
+ def load_policy(path: Optional[str] = None, default_mode_fallback: str = "never_render") -> FetchPolicy:
98
+ """Build a FetchPolicy from a fetchgate.toml, or defaults if none is found.
99
+ Looked up in: path arg, $FETCHGATE_CONFIG, ./fetchgate.toml, ~/.config/fetchgate/config.toml."""
100
+ data = _read_config(path)
101
+ if data is None:
102
+ return FetchPolicy(default_mode=default_mode_fallback)
103
+ raw_domains = data.get("domains") or {}
104
+ domains = {
105
+ k: DomainPolicy(mode=(v if isinstance(v, str) else v.get("mode", "never_render")))
106
+ for k, v in raw_domains.items()
107
+ }
108
+ return FetchPolicy(
109
+ policy_id=data.get("policy_id", "config"),
110
+ default_mode=data.get("default_mode", default_mode_fallback),
111
+ block_cross_origin_redirect=bool(data.get("block_cross_origin_redirect", True)),
112
+ max_redirects=int(data.get("max_redirects", 5)),
113
+ unsupported_type_policy=data.get("unsupported_type_policy", "UNKNOWN"),
114
+ html_extractor=data.get("html_extractor", "auto"),
115
+ unverified_disposition=_disposition(data.get("unverified_disposition", "stop")),
116
+ domains=domains,
117
+ )
118
+
119
+
120
+ def _disposition(value: object) -> str:
121
+ v = str(value).strip().lower()
122
+ return v if v in ("stop", "warn") else "stop"
123
+
124
+
81
125
  def _host_of(url: str) -> str:
82
126
  from urllib.parse import urlparse
83
127
  return (urlparse(url).hostname or "").lower()