webget-cli 0.10.0__tar.gz → 0.11.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.10.0 → webget_cli-0.11.0}/PKG-INFO +1 -1
- {webget_cli-0.10.0 → webget_cli-0.11.0}/pyproject.toml +1 -1
- webget_cli-0.11.0/tests/test_discovery_map.py +29 -0
- webget_cli-0.11.0/tests/test_ladder_retry.py +41 -0
- {webget_cli-0.10.0 → webget_cli-0.11.0}/tests/test_login_flow.py +17 -6
- webget_cli-0.11.0/tests/test_mcp_map.py +18 -0
- {webget_cli-0.10.0 → webget_cli-0.11.0}/tests/test_mcp_server.py +1 -1
- webget_cli-0.11.0/tests/test_ssrf_dual_dns.py +34 -0
- {webget_cli-0.10.0 → webget_cli-0.11.0}/tests/test_webget.py +25 -10
- {webget_cli-0.10.0 → webget_cli-0.11.0}/webget/__init__.py +6 -1
- {webget_cli-0.10.0 → webget_cli-0.11.0}/webget/cache.py +2 -1
- {webget_cli-0.10.0 → webget_cli-0.11.0}/webget/cli.py +38 -8
- webget_cli-0.11.0/webget/discovery.py +94 -0
- {webget_cli-0.10.0 → webget_cli-0.11.0}/webget/firecrawl.py +2 -1
- {webget_cli-0.10.0 → webget_cli-0.11.0}/webget/http.py +3 -4
- {webget_cli-0.10.0 → webget_cli-0.11.0}/webget/ladder.py +39 -2
- {webget_cli-0.10.0 → webget_cli-0.11.0}/webget/profile.py +2 -1
- {webget_cli-0.10.0 → webget_cli-0.11.0}/webget/search.py +2 -1
- {webget_cli-0.10.0 → webget_cli-0.11.0}/webget/ssrf.py +53 -5
- {webget_cli-0.10.0 → webget_cli-0.11.0}/webget_cli.egg-info/PKG-INFO +1 -1
- {webget_cli-0.10.0 → webget_cli-0.11.0}/webget_cli.egg-info/SOURCES.txt +5 -0
- {webget_cli-0.10.0 → webget_cli-0.11.0}/webget_cli.py +11 -1
- {webget_cli-0.10.0 → webget_cli-0.11.0}/webget_mcp.py +22 -0
- {webget_cli-0.10.0 → webget_cli-0.11.0}/LICENSE +0 -0
- {webget_cli-0.10.0 → webget_cli-0.11.0}/README.md +0 -0
- {webget_cli-0.10.0 → webget_cli-0.11.0}/setup.cfg +0 -0
- {webget_cli-0.10.0 → webget_cli-0.11.0}/tests/test_adversarial_auth.py +0 -0
- {webget_cli-0.10.0 → webget_cli-0.11.0}/tests/test_adversarial_cache.py +0 -0
- {webget_cli-0.10.0 → webget_cli-0.11.0}/tests/test_adversarial_concurrency.py +0 -0
- {webget_cli-0.10.0 → webget_cli-0.11.0}/tests/test_adversarial_http.py +0 -0
- {webget_cli-0.10.0 → webget_cli-0.11.0}/tests/test_adversarial_mcp.py +0 -0
- {webget_cli-0.10.0 → webget_cli-0.11.0}/tests/test_adversarial_ssrf.py +0 -0
- {webget_cli-0.10.0 → webget_cli-0.11.0}/tests/test_auth_review.py +0 -0
- {webget_cli-0.10.0 → webget_cli-0.11.0}/tests/test_browser_ssrf.py +0 -0
- {webget_cli-0.10.0 → webget_cli-0.11.0}/tests/test_cache_review.py +0 -0
- {webget_cli-0.10.0 → webget_cli-0.11.0}/tests/test_concurrency_review.py +0 -0
- {webget_cli-0.10.0 → webget_cli-0.11.0}/tests/test_extraction_markdown.py +0 -0
- {webget_cli-0.10.0 → webget_cli-0.11.0}/tests/test_firecrawl_policy.py +0 -0
- {webget_cli-0.10.0 → webget_cli-0.11.0}/tests/test_integration_ladder.py +0 -0
- {webget_cli-0.10.0 → webget_cli-0.11.0}/tests/test_mcp_leak_review.py +0 -0
- {webget_cli-0.10.0 → webget_cli-0.11.0}/tests/test_mcp_profile.py +0 -0
- {webget_cli-0.10.0 → webget_cli-0.11.0}/tests/test_mcp_smoke.py +0 -0
- {webget_cli-0.10.0 → webget_cli-0.11.0}/tests/test_security_review.py +0 -0
- {webget_cli-0.10.0 → webget_cli-0.11.0}/tests/test_size_review.py +0 -0
- {webget_cli-0.10.0 → webget_cli-0.11.0}/webget_cli.egg-info/dependency_links.txt +0 -0
- {webget_cli-0.10.0 → webget_cli-0.11.0}/webget_cli.egg-info/entry_points.txt +0 -0
- {webget_cli-0.10.0 → webget_cli-0.11.0}/webget_cli.egg-info/requires.txt +0 -0
- {webget_cli-0.10.0 → webget_cli-0.11.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.11.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
|
|
@@ -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.11.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,29 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
from webget.discovery import _extract_sitemap_urls, discover_urls
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_extract_sitemap_urls_standard_xml():
|
|
7
|
+
xml = """<?xml version="1.0" encoding="UTF-8"?>
|
|
8
|
+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
9
|
+
<url>
|
|
10
|
+
<loc>https://example.com/page1</loc>
|
|
11
|
+
</url>
|
|
12
|
+
<url>
|
|
13
|
+
<loc>https://example.com/page2</loc>
|
|
14
|
+
</url>
|
|
15
|
+
</urlset>
|
|
16
|
+
"""
|
|
17
|
+
urls = _extract_sitemap_urls(xml)
|
|
18
|
+
assert urls == ["https://example.com/page1", "https://example.com/page2"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_extract_sitemap_urls_malformed_regex_fallback():
|
|
22
|
+
xml = """<urlset><url><loc>https://example.com/broken1</loc></unclosed>"""
|
|
23
|
+
urls = _extract_sitemap_urls(xml)
|
|
24
|
+
assert "https://example.com/broken1" in urls
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_discover_urls_private_target_blocked():
|
|
28
|
+
urls = asyncio.run(discover_urls("http://127.0.0.1/sitemap.xml", allow_private=False))
|
|
29
|
+
assert urls == []
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from unittest.mock import patch
|
|
3
|
+
|
|
4
|
+
from webget.ladder import scrape_many
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_scrape_many_retry_transient_timeout(fresh_cache, server):
|
|
8
|
+
calls = []
|
|
9
|
+
|
|
10
|
+
async def mock_fetch(url, *args, **kwargs):
|
|
11
|
+
calls.append(url)
|
|
12
|
+
if len(calls) == 1:
|
|
13
|
+
raise TimeoutError("timeout")
|
|
14
|
+
return {
|
|
15
|
+
"title": "Success After Retry",
|
|
16
|
+
"markdown": "Valid content length " * 10,
|
|
17
|
+
"status": "success",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async def run_without_retry():
|
|
21
|
+
return await scrape_many(
|
|
22
|
+
[server.url("/test")], strategy="http", per_url_timeout=1, retry_transient=False
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
async def run_with_retry():
|
|
26
|
+
return await scrape_many(
|
|
27
|
+
[server.url("/test")], strategy="http", per_url_timeout=1, retry_transient=True
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
with patch("webget.ladder._resolve_fetch_http", return_value=mock_fetch):
|
|
31
|
+
# Without retry flag -> error on first timeout
|
|
32
|
+
res = asyncio.run(run_without_retry())
|
|
33
|
+
target = server.url("/test")
|
|
34
|
+
assert res[target]["status"] == "error"
|
|
35
|
+
assert res[target]["attempts"] == 1
|
|
36
|
+
|
|
37
|
+
# With retry_transient=True -> retries and succeeds on 2nd attempt
|
|
38
|
+
calls.clear()
|
|
39
|
+
res = asyncio.run(run_with_retry())
|
|
40
|
+
assert res[target]["status"] == "success"
|
|
41
|
+
assert res[target]["attempts"] == 2
|
|
@@ -102,7 +102,9 @@ def _spawn_mcp(profile_root):
|
|
|
102
102
|
"WEBGET_PROFILE_DIR": str(profile_root),
|
|
103
103
|
"WEBGET_ALLOW_PRIVATE": "1",
|
|
104
104
|
}
|
|
105
|
-
return StdioServerParameters(
|
|
105
|
+
return StdioServerParameters(
|
|
106
|
+
command=sys.executable, args=[str(ROOT / "webget_mcp.py")], env=env
|
|
107
|
+
)
|
|
106
108
|
|
|
107
109
|
|
|
108
110
|
def test_mcp_login_tool_persists_and_fetch_uses_it(server, tmp_path):
|
|
@@ -117,14 +119,18 @@ def test_mcp_login_tool_persists_and_fetch_uses_it(server, tmp_path):
|
|
|
117
119
|
gated_url = server.url("/cookie-gated")
|
|
118
120
|
|
|
119
121
|
async def run():
|
|
120
|
-
async with
|
|
122
|
+
async with (
|
|
123
|
+
stdio_client(_spawn_mcp(root)) as (read, write),
|
|
124
|
+
ClientSession(read, write) as session,
|
|
125
|
+
):
|
|
121
126
|
await session.initialize()
|
|
122
127
|
res = await session.call_tool(
|
|
123
128
|
"login",
|
|
124
129
|
{"url": login_url, "profile": "mcplogin", "headless": True, "wait_seconds": 30},
|
|
125
130
|
)
|
|
126
131
|
authed = await session.call_tool(
|
|
127
|
-
"fetch",
|
|
132
|
+
"fetch",
|
|
133
|
+
{"url": gated_url, "strategy": "http", "no_cache": True, "profile": "mcplogin"},
|
|
128
134
|
)
|
|
129
135
|
anon = await session.call_tool(
|
|
130
136
|
"fetch", {"url": gated_url, "strategy": "http", "no_cache": True}
|
|
@@ -155,16 +161,21 @@ def test_mcp_login_rejects_bad_url_and_profile(server, tmp_path):
|
|
|
155
161
|
root = _profile_root(tmp_path)
|
|
156
162
|
|
|
157
163
|
async def run():
|
|
158
|
-
async with
|
|
164
|
+
async with (
|
|
165
|
+
stdio_client(_spawn_mcp(root)) as (read, write),
|
|
166
|
+
ClientSession(read, write) as session,
|
|
167
|
+
):
|
|
159
168
|
await session.initialize()
|
|
160
169
|
bad_url = await session.call_tool(
|
|
161
170
|
"login", {"url": "not a url", "profile": "x", "headless": True, "wait_seconds": 5}
|
|
162
171
|
)
|
|
163
172
|
bad_name = await session.call_tool(
|
|
164
|
-
"login",
|
|
173
|
+
"login",
|
|
174
|
+
{"url": server.url("/"), "profile": "../evil", "headless": True, "wait_seconds": 5},
|
|
165
175
|
)
|
|
166
176
|
bad_secs = await session.call_tool(
|
|
167
|
-
"login",
|
|
177
|
+
"login",
|
|
178
|
+
{"url": server.url("/"), "profile": "x", "headless": True, "wait_seconds": 1},
|
|
168
179
|
)
|
|
169
180
|
return bad_url, bad_name, bad_secs
|
|
170
181
|
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from unittest.mock import AsyncMock, patch
|
|
3
|
+
|
|
4
|
+
from webget_mcp import map as mcp_map
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_mcp_map_clamps_limit():
|
|
8
|
+
res = asyncio.run(mcp_map("https://example.com", limit=-5))
|
|
9
|
+
assert len(res) == 1
|
|
10
|
+
assert "error: limit must be between" in res[0]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_mcp_map_calls_discover_urls():
|
|
14
|
+
with patch("webget_cli.discover_urls", new_callable=AsyncMock) as mock_disc:
|
|
15
|
+
mock_disc.return_value = ["https://example.com/p1", "https://example.com/p2"]
|
|
16
|
+
res = asyncio.run(mcp_map("https://example.com", limit=50))
|
|
17
|
+
assert res == ["https://example.com/p1", "https://example.com/p2"]
|
|
18
|
+
mock_disc.assert_awaited_once_with("https://example.com", limit=50, timeout=15)
|
|
@@ -31,7 +31,7 @@ def test_tools_listed():
|
|
|
31
31
|
|
|
32
32
|
# order is not a contract; membership is
|
|
33
33
|
assert sorted(_run(run())) == sorted(
|
|
34
|
-
["search", "fetch", "search_fetch", "list_profiles", "login"]
|
|
34
|
+
["search", "fetch", "search_fetch", "list_profiles", "login", "map"]
|
|
35
35
|
)
|
|
36
36
|
|
|
37
37
|
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import socket
|
|
2
|
+
from unittest.mock import patch
|
|
3
|
+
|
|
4
|
+
from webget.ssrf import _hostname_private, _resolve_hostname_ips
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_resolve_hostname_ips_primary_success():
|
|
8
|
+
ips = _resolve_hostname_ips("localhost")
|
|
9
|
+
assert any(ip.startswith("127.") or ip == "::1" for ip in ips)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_resolve_hostname_ips_fallback_on_primary_failure():
|
|
13
|
+
orig_getaddrinfo = socket.getaddrinfo
|
|
14
|
+
|
|
15
|
+
def mock_getaddrinfo(host, port, *args, **kwargs):
|
|
16
|
+
if host == "flaky.example":
|
|
17
|
+
raise socket.gaierror(socket.EAI_NONAME, "Name or service not known")
|
|
18
|
+
return orig_getaddrinfo(host, port, *args, **kwargs)
|
|
19
|
+
|
|
20
|
+
# Secondary resolver gives fallback IP
|
|
21
|
+
with (
|
|
22
|
+
patch("socket.getaddrinfo", side_effect=mock_getaddrinfo),
|
|
23
|
+
patch("webget.ssrf._doh_resolve", return_value=["93.184.216.34"]),
|
|
24
|
+
):
|
|
25
|
+
ips = _resolve_hostname_ips("flaky.example")
|
|
26
|
+
assert "93.184.216.34" in ips
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_hostname_private_uses_fallback_and_detects_private():
|
|
30
|
+
with (
|
|
31
|
+
patch("socket.getaddrinfo", side_effect=socket.gaierror(socket.EAI_NONAME, "Fail")),
|
|
32
|
+
patch("webget.ssrf._doh_resolve", return_value=["192.168.1.1"]),
|
|
33
|
+
):
|
|
34
|
+
assert _hostname_private("router.local") is True
|
|
@@ -22,12 +22,21 @@ def auth_state(md="", html="", status=None, profile=None):
|
|
|
22
22
|
|
|
23
23
|
class TestParseOpts:
|
|
24
24
|
def test_positional(self):
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
(
|
|
26
|
+
remaining,
|
|
27
|
+
*_,
|
|
28
|
+
limit,
|
|
29
|
+
strategy,
|
|
30
|
+
profile,
|
|
31
|
+
no_cache,
|
|
32
|
+
headless,
|
|
33
|
+
concurrency,
|
|
34
|
+
retry_transient,
|
|
35
|
+
) = opts("u", "https://x.com")
|
|
28
36
|
assert remaining == ["u", "https://x.com"]
|
|
29
37
|
assert limit is None and strategy == "auto" and profile is None
|
|
30
38
|
assert no_cache is False and headless is False and concurrency is None
|
|
39
|
+
assert retry_transient is False
|
|
31
40
|
|
|
32
41
|
def test_cookies_short_and_long(self, tmp_path):
|
|
33
42
|
ck = tmp_path / "ck.txt"
|
|
@@ -45,27 +54,31 @@ class TestParseOpts:
|
|
|
45
54
|
assert mc1 == 500 and mc2 == 500
|
|
46
55
|
|
|
47
56
|
def test_limit(self):
|
|
48
|
-
*_, limit, _, _, _, _, _ = opts("s", "q", "--limit", "7")
|
|
57
|
+
*_, limit, _, _, _, _, _, _ = opts("s", "q", "--limit", "7")
|
|
49
58
|
assert limit == 7
|
|
50
59
|
|
|
51
60
|
def test_profile_and_no_cache(self):
|
|
52
|
-
*_, profile, no_cache, _, _ = opts("u", "https://x.com", "--profile", "campus")
|
|
61
|
+
*_, profile, no_cache, _, _, _ = opts("u", "https://x.com", "--profile", "campus")
|
|
53
62
|
assert profile == "campus" and no_cache is False
|
|
54
|
-
*_, profile, no_cache, _, _ = opts("u", "https://x.com", "--no-cache")
|
|
63
|
+
*_, profile, no_cache, _, _, _ = opts("u", "https://x.com", "--no-cache")
|
|
55
64
|
assert profile is None and no_cache is True
|
|
56
65
|
|
|
57
66
|
def test_strategy(self):
|
|
58
|
-
*_, strategy, _, _, _, _ = opts("u", "https://x.com", "--strategy", "crawl4ai")
|
|
67
|
+
*_, strategy, _, _, _, _, _ = opts("u", "https://x.com", "--strategy", "crawl4ai")
|
|
59
68
|
assert strategy == "crawl4ai"
|
|
60
69
|
|
|
61
70
|
def test_concurrency(self):
|
|
62
|
-
*_, concurrency = opts("u", "https://x.com", "--concurrency", "5")
|
|
71
|
+
*_, concurrency, _ = opts("u", "https://x.com", "--concurrency", "5")
|
|
63
72
|
assert concurrency == 5
|
|
64
73
|
|
|
65
74
|
def test_headless(self):
|
|
66
|
-
*_, headless, _ = opts("login", "https://x.com", "--headless")
|
|
75
|
+
*_, headless, _, _ = opts("login", "https://x.com", "--headless")
|
|
67
76
|
assert headless is True
|
|
68
77
|
|
|
78
|
+
def test_retry_flag(self):
|
|
79
|
+
*_, retry_transient = opts("u", "https://x.com", "--retry")
|
|
80
|
+
assert retry_transient is True
|
|
81
|
+
|
|
69
82
|
def test_unknown_flag_passthrough(self):
|
|
70
83
|
remaining, *_ = opts("u", "https://x.com", "--weird")
|
|
71
84
|
assert "--weird" in remaining
|
|
@@ -533,7 +546,9 @@ class TestStrategyMemory:
|
|
|
533
546
|
webget._learn_strategy("old.com", "crawl4ai")
|
|
534
547
|
# Age the entry beyond the TTL by shifting the clock forward.
|
|
535
548
|
real_time = webget.time.time
|
|
536
|
-
monkeypatch.setattr(
|
|
549
|
+
monkeypatch.setattr(
|
|
550
|
+
webget.time, "time", lambda: real_time() + webget._STRATEGY_MEMORY_TTL + 1
|
|
551
|
+
)
|
|
537
552
|
assert webget._load_strategy_memory() == {}
|
|
538
553
|
|
|
539
554
|
def test_fresh_entry_survives(self, isolated_env):
|
|
@@ -17,6 +17,7 @@ Sub-modules:
|
|
|
17
17
|
The legacy single-file import `import webget_cli as webget` keeps
|
|
18
18
|
working via the compatibility shim at ./webget_cli.py.
|
|
19
19
|
"""
|
|
20
|
+
|
|
20
21
|
from __future__ import annotations
|
|
21
22
|
|
|
22
23
|
from .cache import (
|
|
@@ -29,6 +30,7 @@ from .cache import (
|
|
|
29
30
|
parse_headers,
|
|
30
31
|
)
|
|
31
32
|
from .cli import main, parse_opts
|
|
33
|
+
from .discovery import discover_urls
|
|
32
34
|
from .firecrawl import fetch_firecrawl, firecrawl_key
|
|
33
35
|
from .http import MAX_RESPONSE_BYTES, ResponseTooLarge, _extract_markdown, fetch_http
|
|
34
36
|
from .ladder import (
|
|
@@ -78,6 +80,7 @@ from .ssrf import (
|
|
|
78
80
|
_is_private_target,
|
|
79
81
|
_private_ip_for,
|
|
80
82
|
_request_body_bytes,
|
|
83
|
+
_resolve_hostname_ips,
|
|
81
84
|
)
|
|
82
85
|
|
|
83
86
|
# Sorted to satisfy ruff RUF022; module grouping lives in the imports above.
|
|
@@ -118,6 +121,7 @@ __all__ = [
|
|
|
118
121
|
"_read_json",
|
|
119
122
|
"_reorder_steps_by_domain",
|
|
120
123
|
"_request_body_bytes",
|
|
124
|
+
"_resolve_hostname_ips",
|
|
121
125
|
"_save_strategy_memory",
|
|
122
126
|
"_strategy_memory_path",
|
|
123
127
|
"_terminal_state",
|
|
@@ -127,6 +131,7 @@ __all__ = [
|
|
|
127
131
|
"_write_json",
|
|
128
132
|
"cache_get",
|
|
129
133
|
"cache_put",
|
|
134
|
+
"discover_urls",
|
|
130
135
|
"fetch_firecrawl",
|
|
131
136
|
"fetch_http",
|
|
132
137
|
"firecrawl_key",
|
|
@@ -141,4 +146,4 @@ __all__ = [
|
|
|
141
146
|
"profile_state_path",
|
|
142
147
|
"scrape_many",
|
|
143
148
|
"search",
|
|
144
|
-
]
|
|
149
|
+
]
|
|
@@ -12,6 +12,7 @@ in the test suite: the canonical location for the path is the shim's
|
|
|
12
12
|
module attribute, and package sub-modules follow whatever the shim
|
|
13
13
|
currently exposes.
|
|
14
14
|
"""
|
|
15
|
+
|
|
15
16
|
from __future__ import annotations
|
|
16
17
|
|
|
17
18
|
import hashlib
|
|
@@ -184,4 +185,4 @@ def cache_put(url, cookies, headers, max_chars, data, profile=None):
|
|
|
184
185
|
try:
|
|
185
186
|
os.remove(tmp)
|
|
186
187
|
except OSError:
|
|
187
|
-
pass
|
|
188
|
+
pass
|
|
@@ -9,6 +9,7 @@ Usage (from --help):
|
|
|
9
9
|
webget login URL --profile X Open browser, log in manually, persist session
|
|
10
10
|
webget profiles [--json] List profiles and session status
|
|
11
11
|
webget logout URL --profile X Clear auth for one domain, keep the rest
|
|
12
|
+
webget map URL [--limit N] Discover URLs via sitemaps and robots.txt
|
|
12
13
|
Aliases: search = s, fetch = u, search-fetch = su
|
|
13
14
|
|
|
14
15
|
Options:
|
|
@@ -34,6 +35,7 @@ Session management:
|
|
|
34
35
|
webget login never stores passwords and never fills forms. You log in
|
|
35
36
|
yourself in the opened browser window; webget just persists the session.
|
|
36
37
|
"""
|
|
38
|
+
|
|
37
39
|
from __future__ import annotations
|
|
38
40
|
|
|
39
41
|
import asyncio
|
|
@@ -56,17 +58,18 @@ from .profile import (
|
|
|
56
58
|
from .search import search
|
|
57
59
|
|
|
58
60
|
__doc__ = (
|
|
59
|
-
|
|
60
|
-
|
|
61
|
+
"webget - local search + scrape, zero API keys, unlimited usage.\n"
|
|
62
|
+
"Usage:\n"
|
|
61
63
|
' webget s "query" [n] Search via DuckDuckGo (default 5)\n'
|
|
62
64
|
' webget u "https://..." Scrape URL -> markdown (HTTP fast path, falls back)\n'
|
|
63
65
|
' webget su "query" [n] Search + scrape top n results (default 3, parallel)\n'
|
|
64
66
|
' webget s "q" | webget u - Pipe: pass URL from search via stdin\n'
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
67
|
+
" (multi-line stdin = batch scrape)\n"
|
|
68
|
+
" webget login URL --profile X Open browser, log in manually, persist session\n"
|
|
69
|
+
" webget profiles [--json] List profiles and session status\n"
|
|
70
|
+
" webget logout URL --profile X Clear auth for one domain, keep the rest\n"
|
|
71
|
+
" webget map URL [--limit N] Discover URLs via sitemaps and robots.txt\n"
|
|
72
|
+
"Aliases: search = s, fetch = u, search-fetch = su\n"
|
|
70
73
|
)
|
|
71
74
|
|
|
72
75
|
|
|
@@ -85,6 +88,7 @@ def parse_opts(args):
|
|
|
85
88
|
no_cache = False
|
|
86
89
|
headless = False
|
|
87
90
|
concurrency = None
|
|
91
|
+
retry_transient = False
|
|
88
92
|
remaining = []
|
|
89
93
|
i = 0
|
|
90
94
|
while i < len(args):
|
|
@@ -97,6 +101,9 @@ def parse_opts(args):
|
|
|
97
101
|
elif args[i] == "--concurrency" and i + 1 < len(args):
|
|
98
102
|
concurrency = int(args[i + 1])
|
|
99
103
|
i += 2
|
|
104
|
+
elif args[i] in ("-r", "--retry", "--retry-transient"):
|
|
105
|
+
retry_transient = True
|
|
106
|
+
i += 1
|
|
100
107
|
elif args[i] == "--no-cache":
|
|
101
108
|
no_cache = True
|
|
102
109
|
i += 1
|
|
@@ -145,6 +152,7 @@ def parse_opts(args):
|
|
|
145
152
|
no_cache,
|
|
146
153
|
headless,
|
|
147
154
|
concurrency,
|
|
155
|
+
retry_transient,
|
|
148
156
|
)
|
|
149
157
|
|
|
150
158
|
|
|
@@ -228,6 +236,7 @@ def main():
|
|
|
228
236
|
no_cache,
|
|
229
237
|
headless,
|
|
230
238
|
concurrency,
|
|
239
|
+
retry_transient,
|
|
231
240
|
) = parse_opts(args)
|
|
232
241
|
|
|
233
242
|
if concurrency is not None and concurrency < 1:
|
|
@@ -260,6 +269,26 @@ def main():
|
|
|
260
269
|
return
|
|
261
270
|
elif cmd == "logout":
|
|
262
271
|
sys.exit(cmd_logout(q, profile))
|
|
272
|
+
elif cmd == "map":
|
|
273
|
+
from .discovery import discover_urls
|
|
274
|
+
|
|
275
|
+
n = limit or 100
|
|
276
|
+
timeout = timeout_override or 10
|
|
277
|
+
urls = asyncio.run(
|
|
278
|
+
discover_urls(
|
|
279
|
+
q,
|
|
280
|
+
limit=n,
|
|
281
|
+
timeout=timeout,
|
|
282
|
+
headers=headers,
|
|
283
|
+
allow_private=no_cache,
|
|
284
|
+
)
|
|
285
|
+
)
|
|
286
|
+
if json_out:
|
|
287
|
+
print(json.dumps(urls, indent=2))
|
|
288
|
+
return
|
|
289
|
+
for u in urls:
|
|
290
|
+
print(u)
|
|
291
|
+
return
|
|
263
292
|
|
|
264
293
|
if cmd == "s":
|
|
265
294
|
n = limit or (int(args[2]) if len(args) > 2 else 5)
|
|
@@ -291,6 +320,7 @@ def main():
|
|
|
291
320
|
profile=profile,
|
|
292
321
|
no_cache=no_cache,
|
|
293
322
|
max_concurrency=concurrency,
|
|
323
|
+
retry_transient=retry_transient,
|
|
294
324
|
)
|
|
295
325
|
)
|
|
296
326
|
if json_out:
|
|
@@ -374,4 +404,4 @@ def main():
|
|
|
374
404
|
|
|
375
405
|
|
|
376
406
|
if __name__ == "__main__":
|
|
377
|
-
main()
|
|
407
|
+
main()
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import xml.etree.ElementTree as ET
|
|
3
|
+
from urllib.parse import urljoin, urlparse
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from .ssrf import _is_private_target
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _extract_sitemap_urls(xml_content):
|
|
11
|
+
urls = []
|
|
12
|
+
try:
|
|
13
|
+
root = ET.fromstring(xml_content)
|
|
14
|
+
# Handle namespaces like {http://www.sitemaps.org/schemas/sitemap/0.9}loc
|
|
15
|
+
for elem in root.iter():
|
|
16
|
+
if elem.tag.endswith("loc") and elem.text:
|
|
17
|
+
text = elem.text.strip()
|
|
18
|
+
if text.startswith(("http://", "https://")):
|
|
19
|
+
urls.append(text)
|
|
20
|
+
except Exception: # noqa: BLE001
|
|
21
|
+
# Simple regex fallback if malformed XML
|
|
22
|
+
matches = re.findall(r"<loc>\s*(https?://[^\s<]+)\s*</loc>", xml_content, re.IGNORECASE)
|
|
23
|
+
urls.extend(matches)
|
|
24
|
+
return urls
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def discover_urls(
|
|
28
|
+
target_url,
|
|
29
|
+
limit=100,
|
|
30
|
+
timeout=10,
|
|
31
|
+
headers=None,
|
|
32
|
+
allow_private=None,
|
|
33
|
+
):
|
|
34
|
+
"""Discover URLs for a domain by checking standard sitemap endpoints and robots.txt.
|
|
35
|
+
Returns list of URLs bounded by limit.
|
|
36
|
+
"""
|
|
37
|
+
if _is_private_target(target_url, allow_private=allow_private):
|
|
38
|
+
return []
|
|
39
|
+
|
|
40
|
+
parsed = urlparse(target_url)
|
|
41
|
+
if not parsed.scheme or not parsed.netloc:
|
|
42
|
+
return []
|
|
43
|
+
|
|
44
|
+
base_origin = f"{parsed.scheme}://{parsed.netloc}"
|
|
45
|
+
sitemap_candidates = [
|
|
46
|
+
urljoin(base_origin, "/sitemap.xml"),
|
|
47
|
+
urljoin(base_origin, "/sitemap_index.xml"),
|
|
48
|
+
urljoin(base_origin, "/sitemap/sitemap.xml"),
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
discovered = set()
|
|
52
|
+
req_headers = {"User-Agent": "webget/discovery"}
|
|
53
|
+
if headers:
|
|
54
|
+
req_headers.update(headers)
|
|
55
|
+
|
|
56
|
+
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
|
|
57
|
+
# First check robots.txt for custom Sitemap directives
|
|
58
|
+
try:
|
|
59
|
+
robots_resp = await client.get(urljoin(base_origin, "/robots.txt"), headers=req_headers)
|
|
60
|
+
if robots_resp.status_code == 200:
|
|
61
|
+
for line in robots_resp.text.splitlines():
|
|
62
|
+
if line.strip().lower().startswith("sitemap:"):
|
|
63
|
+
sm = line.split(":", 1)[1].strip()
|
|
64
|
+
if sm.startswith(("http://", "https://")):
|
|
65
|
+
sitemap_candidates.insert(0, sm)
|
|
66
|
+
except Exception: # noqa: BLE001, S110
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
# Check sitemaps
|
|
70
|
+
for sm_url in sitemap_candidates:
|
|
71
|
+
if len(discovered) >= limit:
|
|
72
|
+
break
|
|
73
|
+
if _is_private_target(sm_url, allow_private=allow_private):
|
|
74
|
+
continue
|
|
75
|
+
try:
|
|
76
|
+
resp = await client.get(sm_url, headers=req_headers)
|
|
77
|
+
if resp.status_code == 200 and resp.text:
|
|
78
|
+
found = _extract_sitemap_urls(resp.text)
|
|
79
|
+
for u in found:
|
|
80
|
+
# Check sub-sitemaps if any
|
|
81
|
+
if (
|
|
82
|
+
(u.endswith(".xml") or "sitemap" in u)
|
|
83
|
+
and u not in sitemap_candidates
|
|
84
|
+
and len(sitemap_candidates) < 10
|
|
85
|
+
):
|
|
86
|
+
sitemap_candidates.append(u)
|
|
87
|
+
else:
|
|
88
|
+
discovered.add(u)
|
|
89
|
+
if len(discovered) >= limit:
|
|
90
|
+
break
|
|
91
|
+
except Exception: # noqa: BLE001, S112
|
|
92
|
+
continue
|
|
93
|
+
|
|
94
|
+
return sorted(discovered)[:limit]
|
|
@@ -8,6 +8,7 @@ hosts/IPs. webget guarantees only that a private/internal URL is
|
|
|
8
8
|
never SENT to Firecrawl (pre-check in scrape_many blocks it before the
|
|
9
9
|
ladder runs) and that Firecrawl is strictly opt-in.
|
|
10
10
|
"""
|
|
11
|
+
|
|
11
12
|
from __future__ import annotations
|
|
12
13
|
|
|
13
14
|
import os
|
|
@@ -57,4 +58,4 @@ async def fetch_firecrawl(url, max_chars, key, timeout=30):
|
|
|
57
58
|
"markdown": md[:max_chars],
|
|
58
59
|
"status_code": r.status_code,
|
|
59
60
|
"html": "",
|
|
60
|
-
}
|
|
61
|
+
}
|
|
@@ -5,6 +5,7 @@ following so the SSRF guard runs on every hop, and a streaming body cap
|
|
|
5
5
|
so a giant/binary download cannot exhaust memory. Extraction tries
|
|
6
6
|
trafilatura first (clean article text), then markdownify as a fallback.
|
|
7
7
|
"""
|
|
8
|
+
|
|
8
9
|
from __future__ import annotations
|
|
9
10
|
|
|
10
11
|
import asyncio
|
|
@@ -93,9 +94,7 @@ async def fetch_http(url, max_chars, cookies=None, headers=None, timeout=15):
|
|
|
93
94
|
except ImportError:
|
|
94
95
|
_fn = None
|
|
95
96
|
resolver = _fn if (_fn is not None and _fn is not _private_ip_for) else _private_ip_for
|
|
96
|
-
ip = await asyncio.wait_for(
|
|
97
|
-
asyncio.to_thread(resolver, target), max(remaining, 0.1)
|
|
98
|
-
)
|
|
97
|
+
ip = await asyncio.wait_for(asyncio.to_thread(resolver, target), max(remaining, 0.1))
|
|
99
98
|
except TimeoutError:
|
|
100
99
|
raise TimeoutError(f"DNS resolution exceeded {timeout}s") from None
|
|
101
100
|
if ip is not None:
|
|
@@ -172,4 +171,4 @@ async def fetch_http(url, max_chars, cookies=None, headers=None, timeout=15):
|
|
|
172
171
|
"markdown": md[:max_chars],
|
|
173
172
|
"status_code": r.status_code,
|
|
174
173
|
"html": html[:8000],
|
|
175
|
-
}
|
|
174
|
+
}
|
|
@@ -5,6 +5,7 @@ is recorded with reasons and the ladder escalates. Per-domain strategy
|
|
|
5
5
|
memory promotes a known-good strategy to the front of the ladder so
|
|
6
6
|
subsequent 'auto' runs skip likely-failures.
|
|
7
7
|
"""
|
|
8
|
+
|
|
8
9
|
from __future__ import annotations
|
|
9
10
|
|
|
10
11
|
import asyncio
|
|
@@ -316,6 +317,7 @@ async def scrape_many(
|
|
|
316
317
|
profile=None,
|
|
317
318
|
no_cache=False,
|
|
318
319
|
max_concurrency=None,
|
|
320
|
+
retry_transient=False,
|
|
319
321
|
):
|
|
320
322
|
steps = _ladder(strategy, _firecrawl_module.firecrawl_key())
|
|
321
323
|
# Per-domain strategy memory: when strategy is "auto" and we have
|
|
@@ -478,6 +480,39 @@ async def scrape_many(
|
|
|
478
480
|
results[url] = out
|
|
479
481
|
pending = [u for u in pending if u not in results]
|
|
480
482
|
|
|
483
|
+
# Optional retry pass for transient timeouts on the fast HTTP path
|
|
484
|
+
if retry_transient and pending and "http" in steps:
|
|
485
|
+
transient_candidates = [
|
|
486
|
+
u
|
|
487
|
+
for u in pending
|
|
488
|
+
if reasons[u]
|
|
489
|
+
and reasons[u][-1][0] == "error"
|
|
490
|
+
and "timeout" in str(reasons[u][-1][2]).lower()
|
|
491
|
+
]
|
|
492
|
+
if transient_candidates:
|
|
493
|
+
retry_timeout = int(per_url_timeout * 1.5)
|
|
494
|
+
|
|
495
|
+
async def http_retry_one(u):
|
|
496
|
+
async with sem:
|
|
497
|
+
try:
|
|
498
|
+
res = await _resolve_fetch_http()(
|
|
499
|
+
u,
|
|
500
|
+
max_chars,
|
|
501
|
+
_effective_cookies(cookies, profile),
|
|
502
|
+
headers,
|
|
503
|
+
timeout=retry_timeout,
|
|
504
|
+
)
|
|
505
|
+
return u, await record(u, "http", res=res)
|
|
506
|
+
except Exception as e: # noqa: BLE001
|
|
507
|
+
return u, await record(u, "http", exc=e)
|
|
508
|
+
|
|
509
|
+
for url, out in await asyncio.gather(
|
|
510
|
+
*(http_retry_one(u) for u in transient_candidates)
|
|
511
|
+
):
|
|
512
|
+
if out:
|
|
513
|
+
results[url] = out
|
|
514
|
+
pending = [u for u in pending if u not in results]
|
|
515
|
+
|
|
481
516
|
# Pass 2: Crawl4AI browser - only launched if something still needs it.
|
|
482
517
|
if pending and "crawl4ai" in steps:
|
|
483
518
|
try:
|
|
@@ -507,7 +542,9 @@ async def scrape_many(
|
|
|
507
542
|
async def crawl_one(url):
|
|
508
543
|
async with sem:
|
|
509
544
|
try:
|
|
510
|
-
res = await _resolve_crawl4ai_once()(
|
|
545
|
+
res = await _resolve_crawl4ai_once()(
|
|
546
|
+
crawler_ctx, cfg, url, per_url_timeout
|
|
547
|
+
)
|
|
511
548
|
res["markdown"] = res.get("markdown", "")[:max_chars]
|
|
512
549
|
return url, await record(url, "crawl4ai", res=res)
|
|
513
550
|
except TimeoutError:
|
|
@@ -573,4 +610,4 @@ async def scrape_many(
|
|
|
573
610
|
"auth": auth,
|
|
574
611
|
"reasons": [{"state": s, "method": m, "detail": d} for s, m, d in reasons[url]],
|
|
575
612
|
}
|
|
576
|
-
return results
|
|
613
|
+
return results
|
|
@@ -5,6 +5,7 @@ Profiles live at ~/.local/share/webget/profiles/<name> by default
|
|
|
5
5
|
(cookies + local storage) so authenticated fetches via the HTTP fast
|
|
6
6
|
path can reuse a session captured once in a real browser.
|
|
7
7
|
"""
|
|
8
|
+
|
|
8
9
|
from __future__ import annotations
|
|
9
10
|
|
|
10
11
|
import asyncio
|
|
@@ -406,4 +407,4 @@ async def _logout_flow(site, profile):
|
|
|
406
407
|
except Exception as e: # noqa: BLE001 - warn, still done
|
|
407
408
|
_warn(f"could not clear browser cookies for {host}: {e}")
|
|
408
409
|
await context.close()
|
|
409
|
-
return True, removed
|
|
410
|
+
return True, removed
|
|
@@ -19,6 +19,7 @@ definition and all call sites were in the same module; the package
|
|
|
19
19
|
layout would otherwise bind the symbol at import time and miss the
|
|
20
20
|
patch.
|
|
21
21
|
"""
|
|
22
|
+
|
|
22
23
|
from __future__ import annotations
|
|
23
24
|
|
|
24
25
|
import asyncio
|
|
@@ -45,6 +46,51 @@ def _ip_is_private(ip):
|
|
|
45
46
|
)
|
|
46
47
|
|
|
47
48
|
|
|
49
|
+
def _doh_resolve(host, timeout=3.0):
|
|
50
|
+
"""Fallback DoH resolver (Cloudflare 1.1.1.1 / Google 8.8.8.8) using httpx.
|
|
51
|
+
Returns list of IP strings if resolvable, else empty list.
|
|
52
|
+
"""
|
|
53
|
+
if os.environ.get("WEBGET_DISABLE_DOH") == "1":
|
|
54
|
+
return []
|
|
55
|
+
import urllib.parse
|
|
56
|
+
|
|
57
|
+
import httpx
|
|
58
|
+
|
|
59
|
+
urls = [
|
|
60
|
+
f"https://1.1.1.1/dns-query?name={urllib.parse.quote(host)}&type=A",
|
|
61
|
+
f"https://dns.google/resolve?name={urllib.parse.quote(host)}&type=A",
|
|
62
|
+
]
|
|
63
|
+
for url in urls:
|
|
64
|
+
try:
|
|
65
|
+
with httpx.Client(timeout=timeout, verify=True) as client:
|
|
66
|
+
res = client.get(url, headers={"accept": "application/dns-json"})
|
|
67
|
+
if res.status_code == 200:
|
|
68
|
+
data = res.json()
|
|
69
|
+
answers = data.get("Answer", [])
|
|
70
|
+
ips = [
|
|
71
|
+
ans.get("data")
|
|
72
|
+
for ans in answers
|
|
73
|
+
if ans.get("type") == 1 and ans.get("data")
|
|
74
|
+
]
|
|
75
|
+
if ips:
|
|
76
|
+
return ips
|
|
77
|
+
except Exception: # noqa: BLE001, S112 - DoH is best-effort fallback
|
|
78
|
+
continue
|
|
79
|
+
return []
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _resolve_hostname_ips(host):
|
|
83
|
+
"""Resolve a hostname to a list of IP address strings.
|
|
84
|
+
Tries system getaddrinfo first; falls back to DoH on DNS resolution errors.
|
|
85
|
+
"""
|
|
86
|
+
try:
|
|
87
|
+
infos = socket.getaddrinfo(host, None)
|
|
88
|
+
return [i[4][0] for i in infos]
|
|
89
|
+
except OSError:
|
|
90
|
+
doh_ips = _doh_resolve(host)
|
|
91
|
+
return doh_ips
|
|
92
|
+
|
|
93
|
+
|
|
48
94
|
def _hostname_private(host):
|
|
49
95
|
"""Resolve a hostname once and check every address. Cached per process.
|
|
50
96
|
|
|
@@ -55,13 +101,15 @@ def _hostname_private(host):
|
|
|
55
101
|
"""
|
|
56
102
|
if host in _PRIVATE_IP_CACHE:
|
|
57
103
|
return _PRIVATE_IP_CACHE[host]
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
except OSError:
|
|
104
|
+
ips = _resolve_hostname_ips(host)
|
|
105
|
+
if not ips:
|
|
61
106
|
# DNS failure is not a privacy violation; let the fetch fail normally.
|
|
62
107
|
_PRIVATE_IP_CACHE[host] = False
|
|
63
108
|
return False
|
|
64
|
-
|
|
109
|
+
try:
|
|
110
|
+
private = any(_ip_is_private(ipaddress.ip_address(ip)) for ip in ips)
|
|
111
|
+
except ValueError:
|
|
112
|
+
private = False
|
|
65
113
|
if len(_PRIVATE_IP_CACHE) < 512:
|
|
66
114
|
_PRIVATE_IP_CACHE[host] = private
|
|
67
115
|
return private
|
|
@@ -229,4 +277,4 @@ async def _guard_browser_routes(crawler_ctx):
|
|
|
229
277
|
# Crawl4AI may create the crawling context lazily on first navigation.
|
|
230
278
|
target.on("context", lambda ctx: asyncio.create_task(register(ctx)))
|
|
231
279
|
else:
|
|
232
|
-
await register(target)
|
|
280
|
+
await register(target)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: webget-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.11.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
|
|
@@ -13,20 +13,25 @@ tests/test_auth_review.py
|
|
|
13
13
|
tests/test_browser_ssrf.py
|
|
14
14
|
tests/test_cache_review.py
|
|
15
15
|
tests/test_concurrency_review.py
|
|
16
|
+
tests/test_discovery_map.py
|
|
16
17
|
tests/test_extraction_markdown.py
|
|
17
18
|
tests/test_firecrawl_policy.py
|
|
18
19
|
tests/test_integration_ladder.py
|
|
20
|
+
tests/test_ladder_retry.py
|
|
19
21
|
tests/test_login_flow.py
|
|
20
22
|
tests/test_mcp_leak_review.py
|
|
23
|
+
tests/test_mcp_map.py
|
|
21
24
|
tests/test_mcp_profile.py
|
|
22
25
|
tests/test_mcp_server.py
|
|
23
26
|
tests/test_mcp_smoke.py
|
|
24
27
|
tests/test_security_review.py
|
|
25
28
|
tests/test_size_review.py
|
|
29
|
+
tests/test_ssrf_dual_dns.py
|
|
26
30
|
tests/test_webget.py
|
|
27
31
|
webget/__init__.py
|
|
28
32
|
webget/cache.py
|
|
29
33
|
webget/cli.py
|
|
34
|
+
webget/discovery.py
|
|
30
35
|
webget/firecrawl.py
|
|
31
36
|
webget/http.py
|
|
32
37
|
webget/ladder.py
|
|
@@ -18,6 +18,7 @@ If you maintain this shim, keep the export set in sync with webget/
|
|
|
18
18
|
public API; new internal helpers should be exposed here too if tests
|
|
19
19
|
touch them.
|
|
20
20
|
"""
|
|
21
|
+
|
|
21
22
|
from __future__ import annotations
|
|
22
23
|
|
|
23
24
|
import time
|
|
@@ -32,6 +33,15 @@ from webget import *
|
|
|
32
33
|
# exactly like before.
|
|
33
34
|
from webget.cli import main
|
|
34
35
|
|
|
36
|
+
|
|
37
|
+
def discover_urls(target_url, limit=100, timeout=10, headers=None, allow_private=None):
|
|
38
|
+
from webget.discovery import discover_urls as _du
|
|
39
|
+
|
|
40
|
+
return _du(
|
|
41
|
+
target_url, limit=limit, timeout=timeout, headers=headers, allow_private=allow_private
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
35
45
|
__all__ = [
|
|
36
46
|
"CACHE_DIR",
|
|
37
47
|
"MAX_RESPONSE_BYTES",
|
|
@@ -45,4 +55,4 @@ __all__ = [
|
|
|
45
55
|
|
|
46
56
|
|
|
47
57
|
if __name__ == "__main__":
|
|
48
|
-
main()
|
|
58
|
+
main()
|
|
@@ -242,6 +242,28 @@ async def search_fetch(
|
|
|
242
242
|
return out
|
|
243
243
|
|
|
244
244
|
|
|
245
|
+
@mcp.tool()
|
|
246
|
+
async def map(
|
|
247
|
+
url: str,
|
|
248
|
+
limit: int = 100,
|
|
249
|
+
timeout: int = 15,
|
|
250
|
+
) -> list[str]:
|
|
251
|
+
"""Discover URLs under a website by probing standard sitemap endpoints and robots.txt.
|
|
252
|
+
Returns list of discovered URLs bounded by limit.
|
|
253
|
+
"""
|
|
254
|
+
for name, value, lo, hi in (
|
|
255
|
+
("limit", limit, 1, 1000),
|
|
256
|
+
("timeout", timeout, 1, _MAX_TIMEOUT),
|
|
257
|
+
):
|
|
258
|
+
err = _clamp(name, value, lo, hi)
|
|
259
|
+
if err:
|
|
260
|
+
return [f"error: {err}"]
|
|
261
|
+
try:
|
|
262
|
+
return await wg.discover_urls(url, limit=limit, timeout=timeout)
|
|
263
|
+
except Exception as e: # noqa: BLE001
|
|
264
|
+
return [f"error: {e}"]
|
|
265
|
+
|
|
266
|
+
|
|
245
267
|
def main() -> None:
|
|
246
268
|
mcp.run()
|
|
247
269
|
|
|
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
|
|
File without changes
|
|
File without changes
|