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.
Files changed (48) hide show
  1. {webget_cli-0.10.0 → webget_cli-0.11.0}/PKG-INFO +1 -1
  2. {webget_cli-0.10.0 → webget_cli-0.11.0}/pyproject.toml +1 -1
  3. webget_cli-0.11.0/tests/test_discovery_map.py +29 -0
  4. webget_cli-0.11.0/tests/test_ladder_retry.py +41 -0
  5. {webget_cli-0.10.0 → webget_cli-0.11.0}/tests/test_login_flow.py +17 -6
  6. webget_cli-0.11.0/tests/test_mcp_map.py +18 -0
  7. {webget_cli-0.10.0 → webget_cli-0.11.0}/tests/test_mcp_server.py +1 -1
  8. webget_cli-0.11.0/tests/test_ssrf_dual_dns.py +34 -0
  9. {webget_cli-0.10.0 → webget_cli-0.11.0}/tests/test_webget.py +25 -10
  10. {webget_cli-0.10.0 → webget_cli-0.11.0}/webget/__init__.py +6 -1
  11. {webget_cli-0.10.0 → webget_cli-0.11.0}/webget/cache.py +2 -1
  12. {webget_cli-0.10.0 → webget_cli-0.11.0}/webget/cli.py +38 -8
  13. webget_cli-0.11.0/webget/discovery.py +94 -0
  14. {webget_cli-0.10.0 → webget_cli-0.11.0}/webget/firecrawl.py +2 -1
  15. {webget_cli-0.10.0 → webget_cli-0.11.0}/webget/http.py +3 -4
  16. {webget_cli-0.10.0 → webget_cli-0.11.0}/webget/ladder.py +39 -2
  17. {webget_cli-0.10.0 → webget_cli-0.11.0}/webget/profile.py +2 -1
  18. {webget_cli-0.10.0 → webget_cli-0.11.0}/webget/search.py +2 -1
  19. {webget_cli-0.10.0 → webget_cli-0.11.0}/webget/ssrf.py +53 -5
  20. {webget_cli-0.10.0 → webget_cli-0.11.0}/webget_cli.egg-info/PKG-INFO +1 -1
  21. {webget_cli-0.10.0 → webget_cli-0.11.0}/webget_cli.egg-info/SOURCES.txt +5 -0
  22. {webget_cli-0.10.0 → webget_cli-0.11.0}/webget_cli.py +11 -1
  23. {webget_cli-0.10.0 → webget_cli-0.11.0}/webget_mcp.py +22 -0
  24. {webget_cli-0.10.0 → webget_cli-0.11.0}/LICENSE +0 -0
  25. {webget_cli-0.10.0 → webget_cli-0.11.0}/README.md +0 -0
  26. {webget_cli-0.10.0 → webget_cli-0.11.0}/setup.cfg +0 -0
  27. {webget_cli-0.10.0 → webget_cli-0.11.0}/tests/test_adversarial_auth.py +0 -0
  28. {webget_cli-0.10.0 → webget_cli-0.11.0}/tests/test_adversarial_cache.py +0 -0
  29. {webget_cli-0.10.0 → webget_cli-0.11.0}/tests/test_adversarial_concurrency.py +0 -0
  30. {webget_cli-0.10.0 → webget_cli-0.11.0}/tests/test_adversarial_http.py +0 -0
  31. {webget_cli-0.10.0 → webget_cli-0.11.0}/tests/test_adversarial_mcp.py +0 -0
  32. {webget_cli-0.10.0 → webget_cli-0.11.0}/tests/test_adversarial_ssrf.py +0 -0
  33. {webget_cli-0.10.0 → webget_cli-0.11.0}/tests/test_auth_review.py +0 -0
  34. {webget_cli-0.10.0 → webget_cli-0.11.0}/tests/test_browser_ssrf.py +0 -0
  35. {webget_cli-0.10.0 → webget_cli-0.11.0}/tests/test_cache_review.py +0 -0
  36. {webget_cli-0.10.0 → webget_cli-0.11.0}/tests/test_concurrency_review.py +0 -0
  37. {webget_cli-0.10.0 → webget_cli-0.11.0}/tests/test_extraction_markdown.py +0 -0
  38. {webget_cli-0.10.0 → webget_cli-0.11.0}/tests/test_firecrawl_policy.py +0 -0
  39. {webget_cli-0.10.0 → webget_cli-0.11.0}/tests/test_integration_ladder.py +0 -0
  40. {webget_cli-0.10.0 → webget_cli-0.11.0}/tests/test_mcp_leak_review.py +0 -0
  41. {webget_cli-0.10.0 → webget_cli-0.11.0}/tests/test_mcp_profile.py +0 -0
  42. {webget_cli-0.10.0 → webget_cli-0.11.0}/tests/test_mcp_smoke.py +0 -0
  43. {webget_cli-0.10.0 → webget_cli-0.11.0}/tests/test_security_review.py +0 -0
  44. {webget_cli-0.10.0 → webget_cli-0.11.0}/tests/test_size_review.py +0 -0
  45. {webget_cli-0.10.0 → webget_cli-0.11.0}/webget_cli.egg-info/dependency_links.txt +0 -0
  46. {webget_cli-0.10.0 → webget_cli-0.11.0}/webget_cli.egg-info/entry_points.txt +0 -0
  47. {webget_cli-0.10.0 → webget_cli-0.11.0}/webget_cli.egg-info/requires.txt +0 -0
  48. {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.10.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.10.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(command=sys.executable, args=[str(ROOT / "webget_mcp.py")], env=env)
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 stdio_client(_spawn_mcp(root)) as (read, write), ClientSession(read, write) as session:
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", {"url": gated_url, "strategy": "http", "no_cache": True, "profile": "mcplogin"}
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 stdio_client(_spawn_mcp(root)) as (read, write), ClientSession(read, write) as session:
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", {"url": server.url("/"), "profile": "../evil", "headless": True, "wait_seconds": 5}
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", {"url": server.url("/"), "profile": "x", "headless": True, "wait_seconds": 1}
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
- remaining, *_, limit, strategy, profile, no_cache, headless, concurrency = opts(
26
- "u", "https://x.com"
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(webget.time, "time", lambda: real_time() + webget._STRATEGY_MEMORY_TTL + 1)
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
- 'webget - local search + scrape, zero API keys, unlimited usage.\n'
60
- 'Usage:\n'
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
- ' (multi-line stdin = batch scrape)\n'
66
- ' webget login URL --profile X Open browser, log in manually, persist session\n'
67
- ' webget profiles [--json] List profiles and session status\n'
68
- ' webget logout URL --profile X Clear auth for one domain, keep the rest\n'
69
- 'Aliases: search = s, fetch = u, search-fetch = su\n'
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()(crawler_ctx, cfg, url, per_url_timeout)
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
@@ -1,4 +1,5 @@
1
1
  """DuckDuckGo search + atomic JSON helpers for webget."""
2
+
2
3
  from __future__ import annotations
3
4
 
4
5
  import json
@@ -34,4 +35,4 @@ def _write_json(path, data):
34
35
  try:
35
36
  os.remove(tmp)
36
37
  except OSError:
37
- pass
38
+ pass
@@ -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
- try:
59
- infos = socket.getaddrinfo(host, None)
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
- private = any(_ip_is_private(ipaddress.ip_address(i[4][0])) for i in infos)
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.10.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