webget-cli 0.7.3__tar.gz → 0.8.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.
- {webget_cli-0.7.3 → webget_cli-0.8.0}/PKG-INFO +26 -4
- {webget_cli-0.7.3 → webget_cli-0.8.0}/README.md +25 -3
- {webget_cli-0.7.3 → webget_cli-0.8.0}/pyproject.toml +1 -1
- webget_cli-0.8.0/tests/test_mcp_profile.py +115 -0
- webget_cli-0.8.0/tests/test_mcp_server.py +199 -0
- {webget_cli-0.7.3 → webget_cli-0.8.0}/tests/test_webget.py +41 -0
- {webget_cli-0.7.3 → webget_cli-0.8.0}/webget_cli.egg-info/PKG-INFO +26 -4
- {webget_cli-0.7.3 → webget_cli-0.8.0}/webget_cli.egg-info/SOURCES.txt +1 -0
- {webget_cli-0.7.3 → webget_cli-0.8.0}/webget_cli.py +44 -13
- {webget_cli-0.7.3 → webget_cli-0.8.0}/webget_mcp.py +46 -0
- webget_cli-0.7.3/tests/test_mcp_server.py +0 -102
- {webget_cli-0.7.3 → webget_cli-0.8.0}/LICENSE +0 -0
- {webget_cli-0.7.3 → webget_cli-0.8.0}/setup.cfg +0 -0
- {webget_cli-0.7.3 → webget_cli-0.8.0}/tests/test_adversarial_auth.py +0 -0
- {webget_cli-0.7.3 → webget_cli-0.8.0}/tests/test_adversarial_cache.py +0 -0
- {webget_cli-0.7.3 → webget_cli-0.8.0}/tests/test_adversarial_concurrency.py +0 -0
- {webget_cli-0.7.3 → webget_cli-0.8.0}/tests/test_adversarial_http.py +0 -0
- {webget_cli-0.7.3 → webget_cli-0.8.0}/tests/test_adversarial_mcp.py +0 -0
- {webget_cli-0.7.3 → webget_cli-0.8.0}/tests/test_adversarial_ssrf.py +0 -0
- {webget_cli-0.7.3 → webget_cli-0.8.0}/tests/test_auth_review.py +0 -0
- {webget_cli-0.7.3 → webget_cli-0.8.0}/tests/test_browser_ssrf.py +0 -0
- {webget_cli-0.7.3 → webget_cli-0.8.0}/tests/test_cache_review.py +0 -0
- {webget_cli-0.7.3 → webget_cli-0.8.0}/tests/test_concurrency_review.py +0 -0
- {webget_cli-0.7.3 → webget_cli-0.8.0}/tests/test_extraction_markdown.py +0 -0
- {webget_cli-0.7.3 → webget_cli-0.8.0}/tests/test_firecrawl_policy.py +0 -0
- {webget_cli-0.7.3 → webget_cli-0.8.0}/tests/test_integration_ladder.py +0 -0
- {webget_cli-0.7.3 → webget_cli-0.8.0}/tests/test_mcp_leak_review.py +0 -0
- {webget_cli-0.7.3 → webget_cli-0.8.0}/tests/test_mcp_smoke.py +0 -0
- {webget_cli-0.7.3 → webget_cli-0.8.0}/tests/test_security_review.py +0 -0
- {webget_cli-0.7.3 → webget_cli-0.8.0}/tests/test_size_review.py +0 -0
- {webget_cli-0.7.3 → webget_cli-0.8.0}/webget_cli.egg-info/dependency_links.txt +0 -0
- {webget_cli-0.7.3 → webget_cli-0.8.0}/webget_cli.egg-info/entry_points.txt +0 -0
- {webget_cli-0.7.3 → webget_cli-0.8.0}/webget_cli.egg-info/requires.txt +0 -0
- {webget_cli-0.7.3 → webget_cli-0.8.0}/webget_cli.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: webget-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.0
|
|
4
4
|
Summary: Local search + scrape CLI with an HTTP fast path, optional browser fallback, and authenticated session profiles. Zero API keys.
|
|
5
5
|
Author-email: David Tarigan <tarigansdavid@gmail.com>
|
|
6
6
|
License: Apache-2.0
|
|
@@ -244,9 +244,31 @@ Register as a local MCP server in opencode:
|
|
|
244
244
|
Then prompt with `use webget` for search and scrape tasks. Run the server
|
|
245
245
|
standalone with `webget-mcp` (stdio transport) or `python webget_mcp.py`.
|
|
246
246
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
247
|
+
### Authenticated sessions (profiles)
|
|
248
|
+
|
|
249
|
+
MCP tools can use locally stored login sessions. Create one first with the
|
|
250
|
+
CLI:
|
|
251
|
+
|
|
252
|
+
```bash
|
|
253
|
+
webget login https://portal.example.com --profile portal
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
Then the agent can discover sessions and fetch authenticated pages:
|
|
257
|
+
|
|
258
|
+
- `list_profiles` - lists available sessions (name, last used, size,
|
|
259
|
+
status). Cookie values are never returned.
|
|
260
|
+
- `fetch(..., profile="portal")` / `search_fetch(..., profile="portal")` -
|
|
261
|
+
scrape using that session.
|
|
262
|
+
|
|
263
|
+
```text
|
|
264
|
+
agent: "check my portal for new announcements"
|
|
265
|
+
1. list_profiles -> portal (authenticated)
|
|
266
|
+
2. fetch(url, profile="portal")
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
Invalid profile names and unknown profiles are hard errors (no silent
|
|
270
|
+
anonymous fallback). Sessions are local to the machine running the MCP
|
|
271
|
+
server.
|
|
250
272
|
|
|
251
273
|
## Development
|
|
252
274
|
|
|
@@ -210,9 +210,31 @@ Register as a local MCP server in opencode:
|
|
|
210
210
|
Then prompt with `use webget` for search and scrape tasks. Run the server
|
|
211
211
|
standalone with `webget-mcp` (stdio transport) or `python webget_mcp.py`.
|
|
212
212
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
213
|
+
### Authenticated sessions (profiles)
|
|
214
|
+
|
|
215
|
+
MCP tools can use locally stored login sessions. Create one first with the
|
|
216
|
+
CLI:
|
|
217
|
+
|
|
218
|
+
```bash
|
|
219
|
+
webget login https://portal.example.com --profile portal
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Then the agent can discover sessions and fetch authenticated pages:
|
|
223
|
+
|
|
224
|
+
- `list_profiles` - lists available sessions (name, last used, size,
|
|
225
|
+
status). Cookie values are never returned.
|
|
226
|
+
- `fetch(..., profile="portal")` / `search_fetch(..., profile="portal")` -
|
|
227
|
+
scrape using that session.
|
|
228
|
+
|
|
229
|
+
```text
|
|
230
|
+
agent: "check my portal for new announcements"
|
|
231
|
+
1. list_profiles -> portal (authenticated)
|
|
232
|
+
2. fetch(url, profile="portal")
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
Invalid profile names and unknown profiles are hard errors (no silent
|
|
236
|
+
anonymous fallback). Sessions are local to the machine running the MCP
|
|
237
|
+
server.
|
|
216
238
|
|
|
217
239
|
## Development
|
|
218
240
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "webget-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.8.0"
|
|
8
8
|
description = "Local search + scrape CLI with an HTTP fast path, optional browser fallback, and authenticated session profiles. Zero API keys."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Integration: MCP fetch with a real profile uses the stored session.
|
|
2
|
+
|
|
3
|
+
A cookie-gated local server returns 200 only when the request carries the
|
|
4
|
+
session cookie from a profile's storage_state.json:
|
|
5
|
+
- fetch(profile="testp") -> success (session used)
|
|
6
|
+
- fetch() (anonymous) -> blocked/login_required (no session)
|
|
7
|
+
|
|
8
|
+
Spawns webget_mcp.py as a stdio subprocess with WEBGET_PROFILE_DIR pointing
|
|
9
|
+
at a temp profile root and WEBGET_ALLOW_PRIVATE=1 (the test server lives on
|
|
10
|
+
127.0.0.1, which the SSRF guard blocks by default).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from mcp import ClientSession, StdioServerParameters
|
|
20
|
+
from mcp.client.stdio import stdio_client
|
|
21
|
+
|
|
22
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
23
|
+
MCP = ROOT / "webget_mcp.py"
|
|
24
|
+
sys.path.insert(0, str(ROOT))
|
|
25
|
+
|
|
26
|
+
from tests.http_server import TestServer
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _spawn(profile_root):
|
|
30
|
+
env = {
|
|
31
|
+
**os.environ,
|
|
32
|
+
"WEBGET_PROFILE_DIR": str(profile_root),
|
|
33
|
+
"WEBGET_ALLOW_PRIVATE": "1",
|
|
34
|
+
}
|
|
35
|
+
return StdioServerParameters(command=sys.executable, args=[str(MCP)], env=env)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _make_profile(root, name):
|
|
39
|
+
d = root / name
|
|
40
|
+
(d / "Default").mkdir(parents=True)
|
|
41
|
+
(d / "storage_state.json").write_text(
|
|
42
|
+
json.dumps(
|
|
43
|
+
{
|
|
44
|
+
"cookies": [
|
|
45
|
+
{
|
|
46
|
+
"name": "s",
|
|
47
|
+
"value": "1",
|
|
48
|
+
"domain": "127.0.0.1",
|
|
49
|
+
"path": "/",
|
|
50
|
+
"expires": 9999999999,
|
|
51
|
+
}
|
|
52
|
+
]
|
|
53
|
+
}
|
|
54
|
+
)
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_mcp_fetch_with_profile_uses_session(tmp_path):
|
|
59
|
+
server = TestServer().start()
|
|
60
|
+
try:
|
|
61
|
+
root = tmp_path / "profiles"
|
|
62
|
+
_make_profile(root, "testp")
|
|
63
|
+
url = server.url("/cookie-gated")
|
|
64
|
+
|
|
65
|
+
async def run():
|
|
66
|
+
async with (
|
|
67
|
+
stdio_client(_spawn(root)) as (read, write),
|
|
68
|
+
ClientSession(read, write) as session,
|
|
69
|
+
):
|
|
70
|
+
await session.initialize()
|
|
71
|
+
ok = await session.call_tool(
|
|
72
|
+
"fetch", {"url": url, "strategy": "http", "no_cache": True, "profile": "testp"}
|
|
73
|
+
)
|
|
74
|
+
anon = await session.call_tool(
|
|
75
|
+
"fetch", {"url": url, "strategy": "http", "no_cache": True}
|
|
76
|
+
)
|
|
77
|
+
return ok, anon
|
|
78
|
+
|
|
79
|
+
ok, anon = asyncio.run(asyncio.wait_for(run(), timeout=60))
|
|
80
|
+
ok_text = ok.content[0].text if ok.content else ""
|
|
81
|
+
anon_text = anon.content[0].text if anon.content else ""
|
|
82
|
+
assert not ok.isError, ok_text
|
|
83
|
+
assert '"status":"success"' in ok_text, ok_text
|
|
84
|
+
assert "you are authenticated" in ok_text, ok_text
|
|
85
|
+
assert '"authenticated":true' in ok_text, ok_text # session was used
|
|
86
|
+
# anonymous fetch must NOT succeed on the gated page
|
|
87
|
+
assert ok_text != anon_text
|
|
88
|
+
assert '"status":"success"' not in anon_text, f"anonymous fetch leaked: {anon_text}"
|
|
89
|
+
finally:
|
|
90
|
+
server.stop()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_mcp_fetch_unknown_profile_is_hard_error(tmp_path):
|
|
94
|
+
"""Even with a profile root, a name that does not exist is an error
|
|
95
|
+
(never a silent anonymous fallback)."""
|
|
96
|
+
|
|
97
|
+
async def run():
|
|
98
|
+
env = {**os.environ, "WEBGET_PROFILE_DIR": str(tmp_path / "profiles")}
|
|
99
|
+
params = StdioServerParameters(command=sys.executable, args=[str(MCP)], env=env)
|
|
100
|
+
async with stdio_client(params) as (read, write), ClientSession(read, write) as session:
|
|
101
|
+
await session.initialize()
|
|
102
|
+
res = await session.call_tool(
|
|
103
|
+
"fetch", {"url": "https://example.com", "profile": "ghost", "no_cache": True}
|
|
104
|
+
)
|
|
105
|
+
return res
|
|
106
|
+
|
|
107
|
+
res = asyncio.run(asyncio.wait_for(run(), timeout=60))
|
|
108
|
+
assert not res.isError
|
|
109
|
+
assert "profile 'ghost' not found" in res.content[0].text
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
if __name__ == "__main__":
|
|
113
|
+
import pytest
|
|
114
|
+
|
|
115
|
+
sys.exit(pytest.main([__file__, "-v"]))
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""MCP server regression tests.
|
|
2
|
+
|
|
3
|
+
Spawns webget_mcp.py as a stdio subprocess and drives it like an MCP
|
|
4
|
+
client would. Requires the mcp client SDK (installed via the [mcp] extra).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from mcp import ClientSession, StdioServerParameters
|
|
12
|
+
from mcp.client.stdio import stdio_client
|
|
13
|
+
|
|
14
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
15
|
+
MCP = ROOT / "webget_mcp.py"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _run(coro, timeout=60):
|
|
19
|
+
"""Run a coroutine with a hard wall-clock cap so a dead server
|
|
20
|
+
(the bug this guards against) fails the test instead of hanging CI."""
|
|
21
|
+
return asyncio.run(asyncio.wait_for(coro, timeout=timeout))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_tools_listed():
|
|
25
|
+
async def run():
|
|
26
|
+
params = StdioServerParameters(command=sys.executable, args=[str(MCP)])
|
|
27
|
+
async with stdio_client(params) as (read, write), ClientSession(read, write) as session:
|
|
28
|
+
await session.initialize()
|
|
29
|
+
tools = await session.list_tools()
|
|
30
|
+
return [t.name for t in tools.tools]
|
|
31
|
+
|
|
32
|
+
# order is not a contract; membership is
|
|
33
|
+
assert sorted(_run(run())) == sorted(["search", "fetch", "search_fetch", "list_profiles"])
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_invalid_strategy_returns_error_not_crash():
|
|
37
|
+
"""The server must survive a client sending strategy='bogus'.
|
|
38
|
+
|
|
39
|
+
Before the guard, _ladder raised SystemExit (a BaseException) which
|
|
40
|
+
killed the whole server process and hung the client.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
async def run():
|
|
44
|
+
params = StdioServerParameters(command=sys.executable, args=[str(MCP)])
|
|
45
|
+
async with stdio_client(params) as (read, write), ClientSession(read, write) as session:
|
|
46
|
+
await session.initialize()
|
|
47
|
+
res = await session.call_tool(
|
|
48
|
+
"fetch",
|
|
49
|
+
{"url": "https://example.com", "strategy": "bogus", "no_cache": True},
|
|
50
|
+
)
|
|
51
|
+
return res
|
|
52
|
+
|
|
53
|
+
res = _run(run())
|
|
54
|
+
assert not res.isError
|
|
55
|
+
assert "error" in res.content[0].text
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_firecrawl_without_key_returns_error_not_crash():
|
|
59
|
+
"""strategy='firecrawl' without WEBGET_FIRECRAWL_KEY must not kill the
|
|
60
|
+
server either (SystemExit from _ladder)."""
|
|
61
|
+
|
|
62
|
+
async def run():
|
|
63
|
+
params = StdioServerParameters(command=sys.executable, args=[str(MCP)])
|
|
64
|
+
async with stdio_client(params) as (read, write), ClientSession(read, write) as session:
|
|
65
|
+
await session.initialize()
|
|
66
|
+
res = await session.call_tool(
|
|
67
|
+
"fetch",
|
|
68
|
+
{"url": "https://example.com", "strategy": "firecrawl", "no_cache": True},
|
|
69
|
+
)
|
|
70
|
+
return res
|
|
71
|
+
|
|
72
|
+
res = _run(run())
|
|
73
|
+
assert not res.isError
|
|
74
|
+
assert "error" in res.content[0].text
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_server_stays_alive_after_bad_calls():
|
|
78
|
+
"""The same server process that handled a bad strategy call must still
|
|
79
|
+
answer a normal fetch afterwards."""
|
|
80
|
+
|
|
81
|
+
async def run():
|
|
82
|
+
params = StdioServerParameters(command=sys.executable, args=[str(MCP)])
|
|
83
|
+
async with stdio_client(params) as (read, write), ClientSession(read, write) as session:
|
|
84
|
+
await session.initialize()
|
|
85
|
+
await session.call_tool(
|
|
86
|
+
"fetch",
|
|
87
|
+
{"url": "https://example.com", "strategy": "bogus", "no_cache": True},
|
|
88
|
+
)
|
|
89
|
+
res = await session.call_tool(
|
|
90
|
+
"fetch",
|
|
91
|
+
{"url": "https://example.com", "strategy": "http", "no_cache": True},
|
|
92
|
+
)
|
|
93
|
+
return res
|
|
94
|
+
|
|
95
|
+
res = _run(run())
|
|
96
|
+
assert not res.isError
|
|
97
|
+
assert "success" in res.content[0].text
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_fetch_invalid_profile_returns_error_not_crash():
|
|
101
|
+
"""profile name with path traversal must return a clean error and the
|
|
102
|
+
server must stay alive (SystemExit from profile_dir must not escape)."""
|
|
103
|
+
|
|
104
|
+
async def run():
|
|
105
|
+
params = StdioServerParameters(command=sys.executable, args=[str(MCP)])
|
|
106
|
+
async with stdio_client(params) as (read, write), ClientSession(read, write) as session:
|
|
107
|
+
await session.initialize()
|
|
108
|
+
res = await session.call_tool(
|
|
109
|
+
"fetch",
|
|
110
|
+
{"url": "https://example.com", "profile": "../etc", "no_cache": True},
|
|
111
|
+
)
|
|
112
|
+
ok = await session.call_tool(
|
|
113
|
+
"fetch",
|
|
114
|
+
{"url": "https://example.com", "strategy": "http", "no_cache": True},
|
|
115
|
+
)
|
|
116
|
+
return res, ok
|
|
117
|
+
|
|
118
|
+
res, ok = _run(run())
|
|
119
|
+
assert not res.isError
|
|
120
|
+
assert "invalid profile name" in res.content[0].text
|
|
121
|
+
assert "success" in ok.content[0].text # server alive after the bad call
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_fetch_nonexistent_profile_returns_error():
|
|
125
|
+
"""A valid-looking profile that does not exist is a hard error, never a
|
|
126
|
+
silent anonymous fallback (which would return login_required while the
|
|
127
|
+
agent believes the session was used)."""
|
|
128
|
+
|
|
129
|
+
async def run():
|
|
130
|
+
params = StdioServerParameters(command=sys.executable, args=[str(MCP)])
|
|
131
|
+
async with stdio_client(params) as (read, write), ClientSession(read, write) as session:
|
|
132
|
+
await session.initialize()
|
|
133
|
+
res = await session.call_tool(
|
|
134
|
+
"fetch",
|
|
135
|
+
{"url": "https://example.com", "profile": "ghost", "no_cache": True},
|
|
136
|
+
)
|
|
137
|
+
return res
|
|
138
|
+
|
|
139
|
+
res = _run(run())
|
|
140
|
+
assert not res.isError
|
|
141
|
+
assert "profile 'ghost' not found" in res.content[0].text
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_search_fetch_invalid_profile_returns_error_not_crash():
|
|
145
|
+
"""search_fetch must validate profile the same way and stay alive."""
|
|
146
|
+
|
|
147
|
+
async def run():
|
|
148
|
+
params = StdioServerParameters(command=sys.executable, args=[str(MCP)])
|
|
149
|
+
async with stdio_client(params) as (read, write), ClientSession(read, write) as session:
|
|
150
|
+
await session.initialize()
|
|
151
|
+
res = await session.call_tool(
|
|
152
|
+
"search_fetch",
|
|
153
|
+
{"query": "test", "n": 1, "profile": "../etc"},
|
|
154
|
+
)
|
|
155
|
+
ok = await session.call_tool(
|
|
156
|
+
"search_fetch",
|
|
157
|
+
{"query": "example.com", "n": 1},
|
|
158
|
+
)
|
|
159
|
+
return res, ok
|
|
160
|
+
|
|
161
|
+
res, ok = _run(run())
|
|
162
|
+
assert not res.isError
|
|
163
|
+
assert "invalid profile name" in res.content[0].text
|
|
164
|
+
assert not ok.isError # server alive after the bad call
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_list_profiles_tool_metadata_only(tmp_path):
|
|
168
|
+
"""list_profiles must list the session and NEVER leak cookie values."""
|
|
169
|
+
import json
|
|
170
|
+
import os
|
|
171
|
+
|
|
172
|
+
root = tmp_path / "profiles"
|
|
173
|
+
d = root / "sion"
|
|
174
|
+
(d / "Default").mkdir(parents=True)
|
|
175
|
+
(d / "storage_state.json").write_text(
|
|
176
|
+
json.dumps(
|
|
177
|
+
{"cookies": [{"name": "s", "value": "SUPERSECRET", "domain": ".x.com", "expires": -1}]}
|
|
178
|
+
)
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
async def run():
|
|
182
|
+
env = {**os.environ, "WEBGET_PROFILE_DIR": str(root)}
|
|
183
|
+
params = StdioServerParameters(command=sys.executable, args=[str(MCP)], env=env)
|
|
184
|
+
async with stdio_client(params) as (read, write), ClientSession(read, write) as session:
|
|
185
|
+
await session.initialize()
|
|
186
|
+
res = await session.call_tool("list_profiles", {})
|
|
187
|
+
return res
|
|
188
|
+
|
|
189
|
+
res = _run(run())
|
|
190
|
+
assert not res.isError
|
|
191
|
+
text = res.content[0].text
|
|
192
|
+
assert "sion" in text
|
|
193
|
+
assert "SUPERSECRET" not in text # cookie values never exposed
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
if __name__ == "__main__":
|
|
197
|
+
import pytest
|
|
198
|
+
|
|
199
|
+
sys.exit(pytest.main([__file__, "-v"]))
|
|
@@ -85,6 +85,47 @@ class TestProfileSafety:
|
|
|
85
85
|
p = webget.profile_state_path("campus")
|
|
86
86
|
assert p.endswith(os.path.join("campus", "storage_state.json"))
|
|
87
87
|
|
|
88
|
+
def test_env_override(self, monkeypatch):
|
|
89
|
+
monkeypatch.setenv("WEBGET_PROFILE_DIR", "/tmp/override-profiles")
|
|
90
|
+
assert webget.profile_dir("campus").startswith("/tmp/override-profiles")
|
|
91
|
+
assert webget.profile_state_path("campus").startswith("/tmp/override-profiles")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ---------- list_profiles / profile_exists (MCP reuse) ----------
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class TestListProfiles:
|
|
98
|
+
def test_empty_root(self, tmp_path, monkeypatch):
|
|
99
|
+
monkeypatch.setattr(webget, "PROFILE_DIR", str(tmp_path / "profiles"))
|
|
100
|
+
assert webget.list_profiles() == []
|
|
101
|
+
|
|
102
|
+
def test_returns_metadata_only(self, tmp_path, monkeypatch):
|
|
103
|
+
import json
|
|
104
|
+
|
|
105
|
+
monkeypatch.setattr(webget, "PROFILE_DIR", str(tmp_path / "profiles"))
|
|
106
|
+
d = tmp_path / "profiles" / "sion"
|
|
107
|
+
(d / "Default").mkdir(parents=True)
|
|
108
|
+
state = {
|
|
109
|
+
"cookies": [
|
|
110
|
+
{"name": "secret", "value": "SUPERSECRET", "domain": ".x.com", "expires": -1}
|
|
111
|
+
]
|
|
112
|
+
}
|
|
113
|
+
(d / "storage_state.json").write_text(json.dumps(state))
|
|
114
|
+
meta = webget.list_profiles()
|
|
115
|
+
assert len(meta) == 1
|
|
116
|
+
assert meta[0]["profile"] == "sion"
|
|
117
|
+
assert "status" in meta[0]
|
|
118
|
+
# cookie values must NEVER appear in the listing
|
|
119
|
+
assert "SUPERSECRET" not in json.dumps(meta)
|
|
120
|
+
|
|
121
|
+
def test_profile_exists(self, tmp_path, monkeypatch):
|
|
122
|
+
monkeypatch.setattr(webget, "PROFILE_DIR", str(tmp_path / "profiles"))
|
|
123
|
+
(tmp_path / "profiles" / "sion").mkdir(parents=True)
|
|
124
|
+
assert webget.profile_exists("sion") is True
|
|
125
|
+
assert webget.profile_exists("nope") is False
|
|
126
|
+
assert webget.profile_exists("../evil") is False # invalid = not exists
|
|
127
|
+
assert webget.profile_exists("") is False
|
|
128
|
+
|
|
88
129
|
|
|
89
130
|
# ---------- auth state classifier ----------
|
|
90
131
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: webget-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.0
|
|
4
4
|
Summary: Local search + scrape CLI with an HTTP fast path, optional browser fallback, and authenticated session profiles. Zero API keys.
|
|
5
5
|
Author-email: David Tarigan <tarigansdavid@gmail.com>
|
|
6
6
|
License: Apache-2.0
|
|
@@ -244,9 +244,31 @@ Register as a local MCP server in opencode:
|
|
|
244
244
|
Then prompt with `use webget` for search and scrape tasks. Run the server
|
|
245
245
|
standalone with `webget-mcp` (stdio transport) or `python webget_mcp.py`.
|
|
246
246
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
247
|
+
### Authenticated sessions (profiles)
|
|
248
|
+
|
|
249
|
+
MCP tools can use locally stored login sessions. Create one first with the
|
|
250
|
+
CLI:
|
|
251
|
+
|
|
252
|
+
```bash
|
|
253
|
+
webget login https://portal.example.com --profile portal
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
Then the agent can discover sessions and fetch authenticated pages:
|
|
257
|
+
|
|
258
|
+
- `list_profiles` - lists available sessions (name, last used, size,
|
|
259
|
+
status). Cookie values are never returned.
|
|
260
|
+
- `fetch(..., profile="portal")` / `search_fetch(..., profile="portal")` -
|
|
261
|
+
scrape using that session.
|
|
262
|
+
|
|
263
|
+
```text
|
|
264
|
+
agent: "check my portal for new announcements"
|
|
265
|
+
1. list_profiles -> portal (authenticated)
|
|
266
|
+
2. fetch(url, profile="portal")
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
Invalid profile names and unknown profiles are hard errors (no silent
|
|
270
|
+
anonymous fallback). Sessions are local to the machine running the MCP
|
|
271
|
+
server.
|
|
250
272
|
|
|
251
273
|
## Development
|
|
252
274
|
|
|
@@ -517,7 +517,22 @@ async def _guard_browser_routes(crawler_ctx):
|
|
|
517
517
|
browser.on("context", lambda ctx: asyncio.create_task(register(ctx)))
|
|
518
518
|
|
|
519
519
|
|
|
520
|
-
|
|
520
|
+
def _profile_root():
|
|
521
|
+
"""Profile root dir; WEBGET_PROFILE_DIR env override for tests/ops.
|
|
522
|
+
|
|
523
|
+
Order: env override first, then a monkeypatched/overridden PROFILE_DIR
|
|
524
|
+
module attribute (existing tests patch it directly), then the default.
|
|
525
|
+
"""
|
|
526
|
+
env = os.environ.get("WEBGET_PROFILE_DIR")
|
|
527
|
+
if env:
|
|
528
|
+
return env
|
|
529
|
+
patched = globals().get("PROFILE_DIR")
|
|
530
|
+
if patched:
|
|
531
|
+
return patched
|
|
532
|
+
return os.path.expanduser("~/.local/share/webget/profiles")
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
PROFILE_DIR = _profile_root()
|
|
521
536
|
_PROFILE_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$")
|
|
522
537
|
|
|
523
538
|
|
|
@@ -525,7 +540,7 @@ def profile_dir(name):
|
|
|
525
540
|
"""Return profile dir, rejecting path traversal / weird names."""
|
|
526
541
|
if not name or not _PROFILE_NAME_RE.match(name) or name in (".", ".."):
|
|
527
542
|
raise SystemExit(f"invalid profile name: {name!r}")
|
|
528
|
-
return os.path.join(
|
|
543
|
+
return os.path.join(_profile_root(), name)
|
|
529
544
|
|
|
530
545
|
|
|
531
546
|
def profile_state_path(profile):
|
|
@@ -1046,25 +1061,41 @@ def _fmt_age(ts):
|
|
|
1046
1061
|
return f"{int(diff // 86400)}d ago"
|
|
1047
1062
|
|
|
1048
1063
|
|
|
1049
|
-
def
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
return
|
|
1056
|
-
profiles = []
|
|
1057
|
-
for name in sorted(os.listdir(PROFILE_DIR)):
|
|
1064
|
+
def list_profiles():
|
|
1065
|
+
"""Non-sensitive metadata for every profile dir. Never cookie values."""
|
|
1066
|
+
if not os.path.isdir(_profile_root()):
|
|
1067
|
+
return []
|
|
1068
|
+
out = []
|
|
1069
|
+
for name in sorted(os.listdir(_profile_root())):
|
|
1058
1070
|
if not os.path.isdir(profile_dir(name)):
|
|
1059
1071
|
continue
|
|
1060
1072
|
try:
|
|
1061
|
-
profile_dir(name) # validates
|
|
1073
|
+
profile_dir(name) # validates; raises SystemExit on bad names
|
|
1062
1074
|
meta = _profile_meta(name)
|
|
1063
1075
|
if meta["size"] > 0 or meta["status"] != "unknown":
|
|
1064
|
-
|
|
1076
|
+
out.append(meta)
|
|
1065
1077
|
except SystemExit:
|
|
1066
1078
|
# Skip malformed dir names that fail validation (e.g. "..").
|
|
1067
1079
|
continue
|
|
1080
|
+
return out
|
|
1081
|
+
|
|
1082
|
+
|
|
1083
|
+
def profile_exists(name):
|
|
1084
|
+
"""True if name is a valid profile name AND its dir exists."""
|
|
1085
|
+
try:
|
|
1086
|
+
return os.path.isdir(profile_dir(name))
|
|
1087
|
+
except SystemExit:
|
|
1088
|
+
return False
|
|
1089
|
+
|
|
1090
|
+
|
|
1091
|
+
def cmd_profiles(json_out):
|
|
1092
|
+
if not os.path.isdir(_profile_root()):
|
|
1093
|
+
if json_out:
|
|
1094
|
+
print("{}")
|
|
1095
|
+
else:
|
|
1096
|
+
print("PROFILE LAST USED SIZE STATUS\n(no profiles yet)")
|
|
1097
|
+
return
|
|
1098
|
+
profiles = list_profiles()
|
|
1068
1099
|
if json_out:
|
|
1069
1100
|
print(json.dumps({p["profile"]: p for p in profiles}, indent=2))
|
|
1070
1101
|
return
|
|
@@ -22,6 +22,7 @@ Dependencies: mcp (pip install mcp). webget_cli.py must live next to this file.
|
|
|
22
22
|
from __future__ import annotations
|
|
23
23
|
|
|
24
24
|
import asyncio
|
|
25
|
+
import os
|
|
25
26
|
import sys
|
|
26
27
|
from pathlib import Path
|
|
27
28
|
|
|
@@ -52,6 +53,23 @@ def _clamp(name, value, lo, hi):
|
|
|
52
53
|
return None
|
|
53
54
|
|
|
54
55
|
|
|
56
|
+
def _validate_profile(profile):
|
|
57
|
+
"""Validate a profile name for authenticated fetches.
|
|
58
|
+
|
|
59
|
+
Returns an error string, or None when the profile exists and is usable.
|
|
60
|
+
Invalid names (path traversal etc.) and unknown profiles are HARD errors:
|
|
61
|
+
a silent anonymous fallback would return login_required while the agent
|
|
62
|
+
believes its session was used.
|
|
63
|
+
"""
|
|
64
|
+
try:
|
|
65
|
+
pdir = wg.profile_dir(profile) # raises SystemExit on invalid names
|
|
66
|
+
except SystemExit:
|
|
67
|
+
return f"invalid profile name: {profile!r}"
|
|
68
|
+
if not os.path.isdir(pdir):
|
|
69
|
+
return f"profile '{profile}' not found"
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
55
73
|
@mcp.tool()
|
|
56
74
|
async def search(query: str, n: int = 5) -> list[dict]:
|
|
57
75
|
"""Search the web (DuckDuckGo). Returns up to n results with title/url/snippet."""
|
|
@@ -61,6 +79,14 @@ async def search(query: str, n: int = 5) -> list[dict]:
|
|
|
61
79
|
return await asyncio.to_thread(wg.search, query, n)
|
|
62
80
|
|
|
63
81
|
|
|
82
|
+
@mcp.tool()
|
|
83
|
+
def list_profiles() -> list[dict]:
|
|
84
|
+
"""List locally stored login sessions (profiles) with non-sensitive
|
|
85
|
+
metadata: name, last used, size, status. Cookie values are never
|
|
86
|
+
returned. Use with fetch(profile=...) to scrape authenticated pages."""
|
|
87
|
+
return wg.list_profiles()
|
|
88
|
+
|
|
89
|
+
|
|
64
90
|
@mcp.tool()
|
|
65
91
|
async def fetch(
|
|
66
92
|
url: str,
|
|
@@ -68,11 +94,16 @@ async def fetch(
|
|
|
68
94
|
timeout: int = 20,
|
|
69
95
|
strategy: str = "auto",
|
|
70
96
|
no_cache: bool = False,
|
|
97
|
+
profile: str | None = None,
|
|
71
98
|
) -> dict:
|
|
72
99
|
"""Scrape a single URL to markdown, with metadata.
|
|
73
100
|
|
|
74
101
|
strategy: auto|http|crawl4ai|firecrawl. Status values:
|
|
75
102
|
success|login_required|challenge|blocked|error.
|
|
103
|
+
|
|
104
|
+
profile: name of a locally stored login session (created with
|
|
105
|
+
'webget login URL --profile NAME'). Invalid names and unknown
|
|
106
|
+
profiles are hard errors (never a silent anonymous fallback).
|
|
76
107
|
"""
|
|
77
108
|
if strategy not in _VALID_STRATEGIES:
|
|
78
109
|
return {"status": "error", "error": f"unknown strategy: {strategy}"}
|
|
@@ -85,12 +116,17 @@ async def fetch(
|
|
|
85
116
|
err = _clamp(name, value, lo, hi)
|
|
86
117
|
if err:
|
|
87
118
|
return {"status": "error", "error": err}
|
|
119
|
+
if profile is not None:
|
|
120
|
+
err = _validate_profile(profile)
|
|
121
|
+
if err:
|
|
122
|
+
return {"status": "error", "error": err}
|
|
88
123
|
res = await wg.scrape_many(
|
|
89
124
|
[url],
|
|
90
125
|
max_chars=max_chars,
|
|
91
126
|
per_url_timeout=timeout,
|
|
92
127
|
strategy=strategy,
|
|
93
128
|
no_cache=no_cache,
|
|
129
|
+
profile=profile,
|
|
94
130
|
)
|
|
95
131
|
return res.get(url, {})
|
|
96
132
|
|
|
@@ -102,11 +138,16 @@ async def search_fetch(
|
|
|
102
138
|
max_chars: int = 4000,
|
|
103
139
|
timeout: int = 20,
|
|
104
140
|
no_cache: bool = False,
|
|
141
|
+
profile: str | None = None,
|
|
105
142
|
) -> list[dict]:
|
|
106
143
|
"""Search the web, then scrape the top n results in parallel.
|
|
107
144
|
|
|
108
145
|
Returns one entry per URL with rank, search snippet, and scrape result
|
|
109
146
|
(status/method/markdown).
|
|
147
|
+
|
|
148
|
+
profile: name of a locally stored login session (created with
|
|
149
|
+
'webget login URL --profile NAME'). Invalid names and unknown
|
|
150
|
+
profiles are hard errors (never a silent anonymous fallback).
|
|
110
151
|
"""
|
|
111
152
|
for name, value, lo, hi in (
|
|
112
153
|
("n", n, 1, _MAX_SEARCH_N),
|
|
@@ -116,6 +157,10 @@ async def search_fetch(
|
|
|
116
157
|
err = _clamp(name, value, lo, hi)
|
|
117
158
|
if err:
|
|
118
159
|
return [{"error": err}]
|
|
160
|
+
if profile is not None:
|
|
161
|
+
err = _validate_profile(profile)
|
|
162
|
+
if err:
|
|
163
|
+
return [{"error": err}]
|
|
119
164
|
results = await asyncio.to_thread(wg.search, query, n)
|
|
120
165
|
urls = [r["url"] for r in results]
|
|
121
166
|
scraped = await wg.scrape_many(
|
|
@@ -123,6 +168,7 @@ async def search_fetch(
|
|
|
123
168
|
max_chars=max_chars,
|
|
124
169
|
per_url_timeout=timeout,
|
|
125
170
|
no_cache=no_cache,
|
|
171
|
+
profile=profile,
|
|
126
172
|
)
|
|
127
173
|
out = []
|
|
128
174
|
for i, r in enumerate(results):
|
|
@@ -1,102 +0,0 @@
|
|
|
1
|
-
"""MCP server regression tests.
|
|
2
|
-
|
|
3
|
-
Spawns webget_mcp.py as a stdio subprocess and drives it like an MCP
|
|
4
|
-
client would. Requires the mcp client SDK (installed via the [mcp] extra).
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
import asyncio
|
|
8
|
-
import sys
|
|
9
|
-
from pathlib import Path
|
|
10
|
-
|
|
11
|
-
from mcp import ClientSession, StdioServerParameters
|
|
12
|
-
from mcp.client.stdio import stdio_client
|
|
13
|
-
|
|
14
|
-
ROOT = Path(__file__).resolve().parent.parent
|
|
15
|
-
MCP = ROOT / "webget_mcp.py"
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def _run(coro, timeout=60):
|
|
19
|
-
"""Run a coroutine with a hard wall-clock cap so a dead server
|
|
20
|
-
(the bug this guards against) fails the test instead of hanging CI."""
|
|
21
|
-
return asyncio.run(asyncio.wait_for(coro, timeout=timeout))
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
def test_tools_listed():
|
|
25
|
-
async def run():
|
|
26
|
-
params = StdioServerParameters(command=sys.executable, args=[str(MCP)])
|
|
27
|
-
async with stdio_client(params) as (read, write), ClientSession(read, write) as session:
|
|
28
|
-
await session.initialize()
|
|
29
|
-
tools = await session.list_tools()
|
|
30
|
-
return [t.name for t in tools.tools]
|
|
31
|
-
|
|
32
|
-
assert _run(run()) == ["search", "fetch", "search_fetch"]
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
def test_invalid_strategy_returns_error_not_crash():
|
|
36
|
-
"""The server must survive a client sending strategy='bogus'.
|
|
37
|
-
|
|
38
|
-
Before the guard, _ladder raised SystemExit (a BaseException) which
|
|
39
|
-
killed the whole server process and hung the client.
|
|
40
|
-
"""
|
|
41
|
-
|
|
42
|
-
async def run():
|
|
43
|
-
params = StdioServerParameters(command=sys.executable, args=[str(MCP)])
|
|
44
|
-
async with stdio_client(params) as (read, write), ClientSession(read, write) as session:
|
|
45
|
-
await session.initialize()
|
|
46
|
-
res = await session.call_tool(
|
|
47
|
-
"fetch",
|
|
48
|
-
{"url": "https://example.com", "strategy": "bogus", "no_cache": True},
|
|
49
|
-
)
|
|
50
|
-
return res
|
|
51
|
-
|
|
52
|
-
res = _run(run())
|
|
53
|
-
assert not res.isError
|
|
54
|
-
assert "error" in res.content[0].text
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
def test_firecrawl_without_key_returns_error_not_crash():
|
|
58
|
-
"""strategy='firecrawl' without WEBGET_FIRECRAWL_KEY must not kill the
|
|
59
|
-
server either (SystemExit from _ladder)."""
|
|
60
|
-
|
|
61
|
-
async def run():
|
|
62
|
-
params = StdioServerParameters(command=sys.executable, args=[str(MCP)])
|
|
63
|
-
async with stdio_client(params) as (read, write), ClientSession(read, write) as session:
|
|
64
|
-
await session.initialize()
|
|
65
|
-
res = await session.call_tool(
|
|
66
|
-
"fetch",
|
|
67
|
-
{"url": "https://example.com", "strategy": "firecrawl", "no_cache": True},
|
|
68
|
-
)
|
|
69
|
-
return res
|
|
70
|
-
|
|
71
|
-
res = _run(run())
|
|
72
|
-
assert not res.isError
|
|
73
|
-
assert "error" in res.content[0].text
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
def test_server_stays_alive_after_bad_calls():
|
|
77
|
-
"""The same server process that handled a bad strategy call must still
|
|
78
|
-
answer a normal fetch afterwards."""
|
|
79
|
-
|
|
80
|
-
async def run():
|
|
81
|
-
params = StdioServerParameters(command=sys.executable, args=[str(MCP)])
|
|
82
|
-
async with stdio_client(params) as (read, write), ClientSession(read, write) as session:
|
|
83
|
-
await session.initialize()
|
|
84
|
-
await session.call_tool(
|
|
85
|
-
"fetch",
|
|
86
|
-
{"url": "https://example.com", "strategy": "bogus", "no_cache": True},
|
|
87
|
-
)
|
|
88
|
-
res = await session.call_tool(
|
|
89
|
-
"fetch",
|
|
90
|
-
{"url": "https://example.com", "strategy": "http", "no_cache": True},
|
|
91
|
-
)
|
|
92
|
-
return res
|
|
93
|
-
|
|
94
|
-
res = _run(run())
|
|
95
|
-
assert not res.isError
|
|
96
|
-
assert "success" in res.content[0].text
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
if __name__ == "__main__":
|
|
100
|
-
import pytest
|
|
101
|
-
|
|
102
|
-
sys.exit(pytest.main([__file__, "-v"]))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|