webget-cli 0.10.0__tar.gz → 0.12.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 (50) hide show
  1. {webget_cli-0.10.0/webget_cli.egg-info → webget_cli-0.12.0}/PKG-INFO +17 -1
  2. {webget_cli-0.10.0 → webget_cli-0.12.0}/README.md +15 -0
  3. {webget_cli-0.10.0 → webget_cli-0.12.0}/pyproject.toml +2 -1
  4. webget_cli-0.12.0/tests/test_discovery_map.py +29 -0
  5. webget_cli-0.12.0/tests/test_ladder_retry.py +41 -0
  6. {webget_cli-0.10.0 → webget_cli-0.12.0}/tests/test_login_flow.py +17 -6
  7. {webget_cli-0.10.0 → webget_cli-0.12.0}/tests/test_mcp_leak_review.py +1 -1
  8. webget_cli-0.12.0/tests/test_mcp_map.py +18 -0
  9. {webget_cli-0.10.0 → webget_cli-0.12.0}/tests/test_mcp_server.py +1 -1
  10. webget_cli-0.12.0/tests/test_metadata.py +129 -0
  11. webget_cli-0.12.0/tests/test_nonhtml.py +110 -0
  12. webget_cli-0.12.0/tests/test_ssrf_dual_dns.py +34 -0
  13. {webget_cli-0.10.0 → webget_cli-0.12.0}/tests/test_webget.py +25 -10
  14. {webget_cli-0.10.0 → webget_cli-0.12.0}/webget/__init__.py +16 -2
  15. {webget_cli-0.10.0 → webget_cli-0.12.0}/webget/cache.py +2 -1
  16. {webget_cli-0.10.0 → webget_cli-0.12.0}/webget/cli.py +39 -8
  17. webget_cli-0.12.0/webget/discovery.py +94 -0
  18. {webget_cli-0.10.0 → webget_cli-0.12.0}/webget/firecrawl.py +2 -1
  19. {webget_cli-0.10.0 → webget_cli-0.12.0}/webget/http.py +182 -14
  20. {webget_cli-0.10.0 → webget_cli-0.12.0}/webget/ladder.py +54 -3
  21. {webget_cli-0.10.0 → webget_cli-0.12.0}/webget/profile.py +2 -1
  22. {webget_cli-0.10.0 → webget_cli-0.12.0}/webget/search.py +2 -1
  23. {webget_cli-0.10.0 → webget_cli-0.12.0}/webget/ssrf.py +53 -5
  24. {webget_cli-0.10.0 → webget_cli-0.12.0/webget_cli.egg-info}/PKG-INFO +17 -1
  25. {webget_cli-0.10.0 → webget_cli-0.12.0}/webget_cli.egg-info/SOURCES.txt +7 -0
  26. {webget_cli-0.10.0 → webget_cli-0.12.0}/webget_cli.egg-info/requires.txt +1 -0
  27. {webget_cli-0.10.0 → webget_cli-0.12.0}/webget_cli.py +11 -1
  28. {webget_cli-0.10.0 → webget_cli-0.12.0}/webget_mcp.py +23 -0
  29. {webget_cli-0.10.0 → webget_cli-0.12.0}/LICENSE +0 -0
  30. {webget_cli-0.10.0 → webget_cli-0.12.0}/setup.cfg +0 -0
  31. {webget_cli-0.10.0 → webget_cli-0.12.0}/tests/test_adversarial_auth.py +0 -0
  32. {webget_cli-0.10.0 → webget_cli-0.12.0}/tests/test_adversarial_cache.py +0 -0
  33. {webget_cli-0.10.0 → webget_cli-0.12.0}/tests/test_adversarial_concurrency.py +0 -0
  34. {webget_cli-0.10.0 → webget_cli-0.12.0}/tests/test_adversarial_http.py +0 -0
  35. {webget_cli-0.10.0 → webget_cli-0.12.0}/tests/test_adversarial_mcp.py +0 -0
  36. {webget_cli-0.10.0 → webget_cli-0.12.0}/tests/test_adversarial_ssrf.py +0 -0
  37. {webget_cli-0.10.0 → webget_cli-0.12.0}/tests/test_auth_review.py +0 -0
  38. {webget_cli-0.10.0 → webget_cli-0.12.0}/tests/test_browser_ssrf.py +0 -0
  39. {webget_cli-0.10.0 → webget_cli-0.12.0}/tests/test_cache_review.py +0 -0
  40. {webget_cli-0.10.0 → webget_cli-0.12.0}/tests/test_concurrency_review.py +0 -0
  41. {webget_cli-0.10.0 → webget_cli-0.12.0}/tests/test_extraction_markdown.py +0 -0
  42. {webget_cli-0.10.0 → webget_cli-0.12.0}/tests/test_firecrawl_policy.py +0 -0
  43. {webget_cli-0.10.0 → webget_cli-0.12.0}/tests/test_integration_ladder.py +0 -0
  44. {webget_cli-0.10.0 → webget_cli-0.12.0}/tests/test_mcp_profile.py +0 -0
  45. {webget_cli-0.10.0 → webget_cli-0.12.0}/tests/test_mcp_smoke.py +0 -0
  46. {webget_cli-0.10.0 → webget_cli-0.12.0}/tests/test_security_review.py +0 -0
  47. {webget_cli-0.10.0 → webget_cli-0.12.0}/tests/test_size_review.py +0 -0
  48. {webget_cli-0.10.0 → webget_cli-0.12.0}/webget_cli.egg-info/dependency_links.txt +0 -0
  49. {webget_cli-0.10.0 → webget_cli-0.12.0}/webget_cli.egg-info/entry_points.txt +0 -0
  50. {webget_cli-0.10.0 → webget_cli-0.12.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.12.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
@@ -23,6 +23,7 @@ Requires-Dist: ddgs
23
23
  Requires-Dist: httpx
24
24
  Requires-Dist: trafilatura
25
25
  Requires-Dist: markdownify
26
+ Requires-Dist: pypdf
26
27
  Provides-Extra: browser
27
28
  Requires-Dist: crawl4ai>=0.9; extra == "browser"
28
29
  Provides-Extra: mcp
@@ -188,13 +189,28 @@ path can reuse them. Secrets are never printed.
188
189
  "authenticated": true,
189
190
  "state": "success"
190
191
  },
192
+ "metadata": {
193
+ "author": "Jane Doe",
194
+ "published_at": "2026-09-01",
195
+ "site_name": "Campus Portal",
196
+ "language": "id"
197
+ },
191
198
  "error": null
192
199
  }
193
200
  }
194
201
  ```
195
202
 
203
+ `metadata` (author, published date, site name, language) comes from
204
+ trafilatura extraction on the HTTP path; values are `null` when unknown
205
+ or when the winning strategy was not HTTP.
206
+
196
207
  Status values: `success | login_required | challenge | blocked | error`.
197
208
 
209
+ The HTTP path also handles non-HTML: JSON becomes a pretty code block,
210
+ CSV becomes a GFM table, RSS/Atom feeds become a link list, PDFs are
211
+ extracted per page (via `pypdf`), plain text passes through. Valid
212
+ non-HTML payloads count as success even when short.
213
+
198
214
  ## Status detection rules
199
215
 
200
216
  | Signal | State |
@@ -154,13 +154,28 @@ path can reuse them. Secrets are never printed.
154
154
  "authenticated": true,
155
155
  "state": "success"
156
156
  },
157
+ "metadata": {
158
+ "author": "Jane Doe",
159
+ "published_at": "2026-09-01",
160
+ "site_name": "Campus Portal",
161
+ "language": "id"
162
+ },
157
163
  "error": null
158
164
  }
159
165
  }
160
166
  ```
161
167
 
168
+ `metadata` (author, published date, site name, language) comes from
169
+ trafilatura extraction on the HTTP path; values are `null` when unknown
170
+ or when the winning strategy was not HTTP.
171
+
162
172
  Status values: `success | login_required | challenge | blocked | error`.
163
173
 
174
+ The HTTP path also handles non-HTML: JSON becomes a pretty code block,
175
+ CSV becomes a GFM table, RSS/Atom feeds become a link list, PDFs are
176
+ extracted per page (via `pypdf`), plain text passes through. Valid
177
+ non-HTML payloads count as success even when short.
178
+
164
179
  ## Status detection rules
165
180
 
166
181
  | Signal | State |
@@ -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.12.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"
@@ -26,6 +26,7 @@ dependencies = [
26
26
  "httpx",
27
27
  "trafilatura",
28
28
  "markdownify",
29
+ "pypdf",
29
30
  ]
30
31
 
31
32
  [project.urls]
@@ -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
 
@@ -24,7 +24,7 @@ LEAK_MARKERS = [
24
24
  "/home/",
25
25
  "Traceback",
26
26
  'File "',
27
- "line ",
27
+ '", line ', # traceback frames only (plain "line " false-positives on real content, e.g. "online ", "deadline ")
28
28
  ]
29
29
 
30
30
  # The literal variable NAME may legitimately appear in error messages
@@ -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,129 @@
1
+ """Ide #1: metadata from trafilatura exposed in fetch results."""
2
+
3
+ import webget_cli as webget
4
+
5
+
6
+ def _rich_html():
7
+ return (
8
+ "<html><head><title>T</title>"
9
+ '<meta name="author" content="Jane Doe">'
10
+ '<meta property="article:published_time" content="2026-09-01T10:00:00Z">'
11
+ "</head><body><article><h1>T</h1><p>"
12
+ + "Lorem ipsum dolor sit amet consectetur adipiscing elit sed do. " * 10
13
+ + "</p></article></body></html>"
14
+ )
15
+
16
+
17
+ class TestExtractWithMetadata:
18
+ def test_returns_text_and_metadata(self):
19
+ text, meta = webget._extract_with_metadata(_rich_html())
20
+ assert len(text) > 100
21
+ assert meta["author"] == "Jane Doe"
22
+ assert "2026-09-01" in (meta.get("published_at") or "")
23
+
24
+ def test_fallback_returns_empty_metadata(self, monkeypatch):
25
+ import trafilatura
26
+
27
+ monkeypatch.setattr(trafilatura, "extract", lambda *a, **k: None)
28
+ body = "<p>" + "Fallback body content here yes indeed. " * 5 + "</p>"
29
+ text, meta = webget._extract_with_metadata("<h1>H</h1>" + body)
30
+ assert "# H" in text
31
+ assert meta == {"author": None, "published_at": None, "site_name": None, "language": None}
32
+
33
+ def test_extract_markdown_still_returns_str(self, monkeypatch):
34
+ # Regression guard: old contract returns plain str, never tuple.
35
+ import trafilatura
36
+
37
+ monkeypatch.setattr(trafilatura, "extract", lambda *a, **k: None)
38
+ body = "<p>" + "Fallback body content here yes indeed. " * 5 + "</p>"
39
+ out = webget._extract_markdown("<h1>H</h1>" + body)
40
+ assert isinstance(out, str)
41
+ assert "# H" in out
42
+
43
+
44
+ class TestFetchHttpMetadata:
45
+ def test_fetch_http_result_carries_metadata(self, fresh_cache):
46
+ import asyncio
47
+
48
+ res = asyncio.run(webget.fetch_http(fresh_cache.url("/long"), 6000, timeout=10))
49
+ assert set(res["metadata"]) == {"author", "published_at", "site_name", "language"}
50
+
51
+ def test_scrape_many_success_carries_metadata(self, fresh_cache):
52
+ import asyncio
53
+
54
+ out = asyncio.run(webget.scrape_many([fresh_cache.url("/long")], strategy="http"))
55
+ url = fresh_cache.url("/long")
56
+ assert out[url]["status"] == "success"
57
+ assert set(out[url]["metadata"]) == {
58
+ "author",
59
+ "published_at",
60
+ "site_name",
61
+ "language",
62
+ }
63
+
64
+ def test_cached_hit_carries_metadata(self, fresh_cache):
65
+ import asyncio
66
+
67
+ url = fresh_cache.url("/long")
68
+ asyncio.run(webget.scrape_many([url], strategy="http"))
69
+ out = asyncio.run(webget.scrape_many([url], strategy="http"))
70
+ assert out[url]["cached"] is True
71
+ assert set(out[url]["metadata"]) == {
72
+ "author",
73
+ "published_at",
74
+ "site_name",
75
+ "language",
76
+ }
77
+
78
+
79
+ class TestOutputExposure:
80
+ def test_mcp_fetch_passthrough_has_metadata(self, fresh_cache):
81
+ import asyncio
82
+
83
+ import webget_mcp
84
+
85
+ res = asyncio.run(
86
+ webget_mcp.fetch(fresh_cache.url("/long"), strategy="http", no_cache=True)
87
+ )
88
+ assert set(res["metadata"]) == {"author", "published_at", "site_name", "language"}
89
+
90
+ def test_mcp_search_fetch_rebuild_has_metadata(self, fresh_cache, monkeypatch):
91
+ import asyncio
92
+
93
+ import webget_mcp
94
+
95
+ monkeypatch.setattr(
96
+ webget_mcp.wg,
97
+ "search",
98
+ lambda *a, **k: [{"title": "T", "url": fresh_cache.url("/long"), "snippet": "s"}],
99
+ )
100
+ out = asyncio.run(webget_mcp.search_fetch("q", n=1, no_cache=True))
101
+ assert set(out[0]["metadata"]) == {
102
+ "author",
103
+ "published_at",
104
+ "site_name",
105
+ "language",
106
+ }
107
+
108
+ def test_cli_su_json_rebuild_has_metadata(self, fresh_cache, monkeypatch, capsys):
109
+ import webget_cli as wgcli
110
+ from webget import cli as cli_mod
111
+
112
+ monkeypatch.setattr(
113
+ cli_mod,
114
+ "search",
115
+ lambda *a, **k: [{"title": "T", "url": fresh_cache.url("/long"), "snippet": "s"}],
116
+ )
117
+ monkeypatch.setattr("sys.argv", ["webget", "su", "q", "1", "--json", "--no-cache"])
118
+ wgcli.main()
119
+ import json
120
+
121
+ printed = capsys.readouterr().out
122
+ data = json.loads(printed)
123
+ url = fresh_cache.url("/long")
124
+ assert set(data[url]["metadata"]) == {
125
+ "author",
126
+ "published_at",
127
+ "site_name",
128
+ "language",
129
+ }
@@ -0,0 +1,110 @@
1
+ """Ide #2: non-HTML content routing (JSON/text/CSV/feed/PDF to markdown)."""
2
+
3
+ import asyncio
4
+
5
+ import webget_cli as webget
6
+
7
+
8
+ class TestJsonRouting:
9
+ def test_fetch_json_returns_pretty_markdown(self, fresh_cache):
10
+ url = fresh_cache.url("/json")
11
+ res = asyncio.run(webget.fetch_http(url, 6000, timeout=10))
12
+ assert "hello" in res["markdown"]
13
+ assert "world" in res["markdown"]
14
+ assert res["metadata"]["site_name"] == "127.0.0.1"
15
+
16
+ def test_scrape_many_json_is_success(self, fresh_cache):
17
+ url = fresh_cache.url("/json")
18
+ out = asyncio.run(webget.scrape_many([url], strategy="http", no_cache=True))
19
+ assert out[url]["status"] == "success"
20
+ assert out[url]["method"] == "http"
21
+ assert "hello" in out[url]["markdown"]
22
+
23
+
24
+ class TestCsvRouting:
25
+ def test_csv_becomes_gfm_table(self):
26
+ body = b"name,age\nbudi,25\nsiti,30\n"
27
+ _title, md, meta = webget._convert_non_html("text/csv", body, "https://example.com/d.csv")
28
+ assert "| name | age |" in md
29
+ assert "| budi | 25 |" in md
30
+ assert meta["site_name"] == "example.com"
31
+
32
+ def test_csv_escapes_pipes(self):
33
+ body = b"a,b\nx|y,z\n"
34
+ _, md, _ = webget._convert_non_html("text/csv", body, "https://example.com/d.csv")
35
+ assert "x\\|y" in md
36
+
37
+
38
+ class TestFeedRouting:
39
+ RSS = (
40
+ b'<?xml version="1.0"?><rss version="2.0"><channel><title>Blog</title>'
41
+ b"<item><title>Post A</title><link>https://ex.com/a</link>"
42
+ b"<description>First post here</description></item>"
43
+ b"<item><title>Post B</title><link>https://ex.com/b</link></item>"
44
+ b"</channel></rss>"
45
+ )
46
+ ATOM = (
47
+ b'<?xml version="1.0"?><feed xmlns="http://www.w3.org/2005/Atom">'
48
+ b"<title>Blog</title>"
49
+ b'<entry><title>Entry One</title><link href="https://ex.com/1"/>'
50
+ b"<summary>Summary one</summary></entry>"
51
+ b"</feed>"
52
+ )
53
+
54
+ def test_rss_becomes_link_list(self):
55
+ _title, md, _meta = webget._convert_non_html(
56
+ "application/rss+xml", self.RSS, "https://ex.com/feed"
57
+ )
58
+ assert "[Post A](https://ex.com/a)" in md
59
+ assert "[Post B](https://ex.com/b)" in md
60
+ assert "First post here" in md
61
+
62
+ def test_atom_becomes_link_list(self):
63
+ _, md, _ = webget._convert_non_html(
64
+ "application/atom+xml", self.ATOM, "https://ex.com/feed"
65
+ )
66
+ assert "[Entry One](https://ex.com/1)" in md
67
+
68
+ def test_xml_without_items_falls_back_to_text(self):
69
+ _, md, _ = webget._convert_non_html(
70
+ "application/xml", b"<note><to>u</to></note>", "https://ex.com/n.xml"
71
+ )
72
+ assert "to" in md
73
+
74
+
75
+ class TestPdfRouting:
76
+ def test_pdf_routing_uses_pypdf(self, monkeypatch):
77
+ import sys
78
+ import types
79
+
80
+ fake_page = types.SimpleNamespace(extract_text=lambda: "Hello PDF page one")
81
+ fake_reader = lambda *a, **k: types.SimpleNamespace(pages=[fake_page])
82
+ fake_mod = types.SimpleNamespace(PdfReader=fake_reader)
83
+ monkeypatch.setitem(sys.modules, "pypdf", fake_mod)
84
+ _title, md, meta = webget._convert_non_html(
85
+ "application/pdf", b"%PDF-fake", "https://ex.com/d.pdf"
86
+ )
87
+ assert "Hello PDF page one" in md
88
+ assert meta["site_name"] == "ex.com"
89
+
90
+ def test_pdf_missing_dep_errors_clearly(self, monkeypatch):
91
+ import sys
92
+
93
+ monkeypatch.setitem(sys.modules, "pypdf", None)
94
+ import pytest
95
+
96
+ with pytest.raises(RuntimeError, match="pypdf"):
97
+ webget._convert_non_html("application/pdf", b"%PDF-fake", "https://ex.com/d.pdf")
98
+
99
+ def test_pdf_empty_text_errors(self, monkeypatch):
100
+ import sys
101
+ import types
102
+
103
+ fake_page = types.SimpleNamespace(extract_text=lambda: " ")
104
+ fake_reader = lambda *a, **k: types.SimpleNamespace(pages=[fake_page])
105
+ fake_mod = types.SimpleNamespace(PdfReader=fake_reader)
106
+ monkeypatch.setitem(sys.modules, "pypdf", fake_mod)
107
+ import pytest
108
+
109
+ with pytest.raises(RuntimeError, match="no extractable text"):
110
+ webget._convert_non_html("application/pdf", b"%PDF-fake", "https://ex.com/d.pdf")
@@ -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):