web-snapshot-cli 1.2.0__tar.gz → 1.3.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 (30) hide show
  1. {web_snapshot_cli-1.2.0 → web_snapshot_cli-1.3.0}/PKG-INFO +1 -1
  2. {web_snapshot_cli-1.2.0 → web_snapshot_cli-1.3.0}/docs/index.html +1 -1
  3. {web_snapshot_cli-1.2.0 → web_snapshot_cli-1.3.0}/pyproject.toml +1 -1
  4. {web_snapshot_cli-1.2.0 → web_snapshot_cli-1.3.0}/src/snapshot/__init__.py +1 -1
  5. {web_snapshot_cli-1.2.0 → web_snapshot_cli-1.3.0}/src/snapshot/downloader.py +7 -0
  6. {web_snapshot_cli-1.2.0 → web_snapshot_cli-1.3.0}/src/snapshot/wordlist.py +9 -14
  7. {web_snapshot_cli-1.2.0 → web_snapshot_cli-1.3.0}/tests/test_crawler.py +27 -0
  8. web_snapshot_cli-1.3.0/tests/test_wordlist.py +92 -0
  9. web_snapshot_cli-1.2.0/tests/test_wordlist.py +0 -69
  10. {web_snapshot_cli-1.2.0 → web_snapshot_cli-1.3.0}/.github/workflows/ci.yml +0 -0
  11. {web_snapshot_cli-1.2.0 → web_snapshot_cli-1.3.0}/.github/workflows/pages.yml +0 -0
  12. {web_snapshot_cli-1.2.0 → web_snapshot_cli-1.3.0}/.github/workflows/publish.yml +0 -0
  13. {web_snapshot_cli-1.2.0 → web_snapshot_cli-1.3.0}/.gitignore +0 -0
  14. {web_snapshot_cli-1.2.0 → web_snapshot_cli-1.3.0}/LICENSE +0 -0
  15. {web_snapshot_cli-1.2.0 → web_snapshot_cli-1.3.0}/README.md +0 -0
  16. {web_snapshot_cli-1.2.0 → web_snapshot_cli-1.3.0}/docs/CNAME +0 -0
  17. {web_snapshot_cli-1.2.0 → web_snapshot_cli-1.3.0}/docs/app.js +0 -0
  18. {web_snapshot_cli-1.2.0 → web_snapshot_cli-1.3.0}/docs/style.css +0 -0
  19. {web_snapshot_cli-1.2.0 → web_snapshot_cli-1.3.0}/src/snapshot/__main__.py +0 -0
  20. {web_snapshot_cli-1.2.0 → web_snapshot_cli-1.3.0}/src/snapshot/cli.py +0 -0
  21. {web_snapshot_cli-1.2.0 → web_snapshot_cli-1.3.0}/src/snapshot/crawl_policy.py +0 -0
  22. {web_snapshot_cli-1.2.0 → web_snapshot_cli-1.3.0}/src/snapshot/manifest.py +0 -0
  23. {web_snapshot_cli-1.2.0 → web_snapshot_cli-1.3.0}/src/snapshot/restore.py +0 -0
  24. {web_snapshot_cli-1.2.0 → web_snapshot_cli-1.3.0}/src/snapshot/utils.py +0 -0
  25. {web_snapshot_cli-1.2.0 → web_snapshot_cli-1.3.0}/src/snapshot/wordlists/__init__.py +0 -0
  26. {web_snapshot_cli-1.2.0 → web_snapshot_cli-1.3.0}/src/snapshot/wordlists/common.txt +0 -0
  27. {web_snapshot_cli-1.2.0 → web_snapshot_cli-1.3.0}/src/snapshot/wordlists/large.txt +0 -0
  28. {web_snapshot_cli-1.2.0 → web_snapshot_cli-1.3.0}/tests/test_crawl_policy.py +0 -0
  29. {web_snapshot_cli-1.2.0 → web_snapshot_cli-1.3.0}/tests/test_features.py +0 -0
  30. {web_snapshot_cli-1.2.0 → web_snapshot_cli-1.3.0}/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: web-snapshot-cli
3
- Version: 1.2.0
3
+ Version: 1.3.0
4
4
  Summary: Take offline snapshots of any website and restore them locally
5
5
  Project-URL: Homepage, https://github.com/codingsushi79/Snapshot
6
6
  Project-URL: Repository, https://github.com/codingsushi79/Snapshot
@@ -65,7 +65,7 @@
65
65
  <div class="field snapshot-only">
66
66
  <label for="wordlists">Extra wordlists (one per line)</label>
67
67
  <textarea id="wordlists" placeholder="common&#10;large&#10;/path/to/SecLists/Discovery/Web-Content/common.txt"></textarea>
68
- <div class="hint">Builtin names: <code>common</code>, <code>large</code>. --gobuster uses both by default. Any page whose full text contains <code>404</code> is skipped, even on HTTP 200.</div>
68
+ <div class="hint">Builtin names: <code>common</code>, <code>large</code>. Probes follow redirects and skip any page whose text contains <code>404</code> or returns HTTP 404.</div>
69
69
  </div>
70
70
  <div class="field snapshot-only">
71
71
  <label for="wordlistExt">Wordlist extensions</label>
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "web-snapshot-cli"
7
- version = "1.2.0"
7
+ version = "1.3.0"
8
8
  description = "Take offline snapshots of any website and restore them locally"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,3 +1,3 @@
1
1
  """snapshot — offline website snapshots."""
2
2
 
3
- __version__ = "1.2.0"
3
+ __version__ = "1.3.0"
@@ -364,6 +364,13 @@ class SnapshotEngine:
364
364
 
365
365
  try:
366
366
  response = await self._fetch(url)
367
+ except httpx.HTTPStatusError as exc:
368
+ if exc.response.status_code in {404, 410}:
369
+ self._pages_skipped += 1
370
+ self._log(f"skip (404): {url}")
371
+ return []
372
+ self._errors.append(f"{url}: {exc}")
373
+ return []
367
374
  except Exception as exc: # noqa: BLE001
368
375
  self._errors.append(f"{url}: {exc}")
369
376
  return []
@@ -12,7 +12,7 @@ from snapshot.crawl_policy import RobotsChecker
12
12
  from snapshot.utils import is_not_found_page
13
13
 
14
14
  BUILTIN_WORDLISTS = ("common", "large")
15
- HIT_STATUS = {200, 201, 204, 301, 302, 307, 308, 401, 403}
15
+ FOUND_STATUS = {200, 201, 204, 401, 403}
16
16
 
17
17
 
18
18
  def resolve_wordlist_sources(
@@ -157,16 +157,11 @@ def normalize_probe_url(base: str, path: str) -> str | None:
157
157
 
158
158
 
159
159
  async def _probe_url(client: httpx.AsyncClient, url: str) -> bool:
160
- for method in ("HEAD", "GET"):
161
- try:
162
- response = await client.request(method, url, follow_redirects=False)
163
- except httpx.RequestError:
164
- return False
165
- if response.status_code not in HIT_STATUS:
166
- if response.status_code == 405 and method == "HEAD":
167
- continue
168
- return False
169
- if method == "HEAD":
170
- continue
171
- return not is_not_found_page(response)
172
- return False
160
+ """Probe a URL the same way page snapshots are fetched."""
161
+ try:
162
+ response = await client.get(url, follow_redirects=True)
163
+ except httpx.RequestError:
164
+ return False
165
+ if response.status_code not in FOUND_STATUS:
166
+ return False
167
+ return not is_not_found_page(response)
@@ -1,6 +1,7 @@
1
1
  from pathlib import Path
2
2
  from unittest.mock import AsyncMock, MagicMock, patch
3
3
 
4
+ import httpx
4
5
  import pytest
5
6
  from bs4 import BeautifulSoup
6
7
 
@@ -93,3 +94,29 @@ async def test_snapshot_skips_soft_404_page():
93
94
  assert result.pages_saved == 0
94
95
  assert result.pages_skipped == 1
95
96
  assert "https://example.com/" not in engine._seen_pages
97
+ assert result.errors == []
98
+
99
+
100
+ @pytest.mark.asyncio
101
+ async def test_snapshot_skips_http_404_without_error():
102
+ async def fake_fetch(url: str):
103
+ response = MagicMock()
104
+ response.raise_for_status.side_effect = httpx.HTTPStatusError(
105
+ "not found",
106
+ request=MagicMock(),
107
+ response=MagicMock(status_code=404),
108
+ )
109
+ raise response.raise_for_status.side_effect
110
+
111
+ engine = SnapshotEngine(
112
+ "https://example.com/",
113
+ Path("/tmp/http-404-test"),
114
+ SnapshotOptions(crawl=False, download_assets=False),
115
+ )
116
+
117
+ with patch.object(engine, "_fetch", new=AsyncMock(side_effect=fake_fetch)):
118
+ result = await engine.run()
119
+
120
+ assert result.pages_saved == 0
121
+ assert result.pages_skipped == 1
122
+ assert result.errors == []
@@ -0,0 +1,92 @@
1
+ from unittest.mock import AsyncMock, MagicMock
2
+
3
+ import httpx
4
+ import pytest
5
+
6
+ from snapshot.wordlist import _probe_url, paths_for_word, resolve_wordlist_sources
7
+
8
+
9
+ def test_paths_for_word_file_entry():
10
+ paths = paths_for_word("robots.txt", ())
11
+ assert paths == ["/robots.txt"]
12
+
13
+
14
+ def test_paths_for_word_directory_entry():
15
+ paths = paths_for_word("admin", (".html",))
16
+ assert "/admin" in paths
17
+ assert "/admin/" in paths
18
+ assert "/admin.html" in paths
19
+
20
+
21
+ def test_resolve_wordlist_sources_gobuster_defaults():
22
+ assert resolve_wordlist_sources(True, []) == ["common", "large"]
23
+
24
+
25
+ def test_resolve_wordlist_sources_custom():
26
+ assert resolve_wordlist_sources(False, ["common", "/tmp/list.txt"]) == [
27
+ "common",
28
+ "/tmp/list.txt",
29
+ ]
30
+
31
+
32
+ def test_load_builtin_common():
33
+ from snapshot.wordlist import load_words
34
+
35
+ words = load_words(["common"])
36
+ assert "robots.txt" in words
37
+ assert "sitemap.xml" in words
38
+
39
+
40
+ @pytest.mark.asyncio
41
+ async def test_probe_url_rejects_soft_404():
42
+ client = MagicMock(spec=httpx.AsyncClient)
43
+ response = MagicMock()
44
+ response.status_code = 200
45
+ response.headers = {"content-type": "text/html"}
46
+ response.url = "https://example.com/missing"
47
+ response.text = (
48
+ "<html><head><title>404 Not Found</title></head><body><h1>404</h1></body></html>"
49
+ )
50
+ client.get = AsyncMock(return_value=response)
51
+
52
+ assert await _probe_url(client, "https://example.com/missing") is False
53
+ client.get.assert_awaited_once_with("https://example.com/missing", follow_redirects=True)
54
+
55
+
56
+ @pytest.mark.asyncio
57
+ async def test_probe_url_accepts_valid_page():
58
+ client = MagicMock(spec=httpx.AsyncClient)
59
+ response = MagicMock()
60
+ response.status_code = 200
61
+ response.headers = {"content-type": "text/html"}
62
+ response.url = "https://example.com/about"
63
+ response.text = "<html><head><title>About</title></head><body><h1>About</h1></body></html>"
64
+ client.get = AsyncMock(return_value=response)
65
+
66
+ assert await _probe_url(client, "https://example.com/about") is True
67
+
68
+
69
+ @pytest.mark.asyncio
70
+ async def test_probe_url_rejects_hard_404_after_redirect():
71
+ client = MagicMock(spec=httpx.AsyncClient)
72
+ response = MagicMock()
73
+ response.status_code = 404
74
+ response.headers = {"content-type": "text/html"}
75
+ response.url = "https://example.com/missing"
76
+ response.text = "<html><body>missing</body></html>"
77
+ client.get = AsyncMock(return_value=response)
78
+
79
+ assert await _probe_url(client, "https://example.com/missing/") is False
80
+
81
+
82
+ @pytest.mark.asyncio
83
+ async def test_probe_url_does_not_count_redirect_without_final_page():
84
+ client = MagicMock(spec=httpx.AsyncClient)
85
+ response = MagicMock()
86
+ response.status_code = 308
87
+ response.headers = {"content-type": "text/html"}
88
+ response.url = "https://example.com/old/"
89
+ response.text = ""
90
+ client.get = AsyncMock(return_value=response)
91
+
92
+ assert await _probe_url(client, "https://example.com/old/") is False
@@ -1,69 +0,0 @@
1
- from unittest.mock import AsyncMock, MagicMock
2
-
3
- import httpx
4
- import pytest
5
-
6
- from snapshot.wordlist import _probe_url, paths_for_word, resolve_wordlist_sources
7
-
8
-
9
- def test_paths_for_word_file_entry():
10
- paths = paths_for_word("robots.txt", ())
11
- assert paths == ["/robots.txt"]
12
-
13
-
14
- def test_paths_for_word_directory_entry():
15
- paths = paths_for_word("admin", (".html",))
16
- assert "/admin" in paths
17
- assert "/admin/" in paths
18
- assert "/admin.html" in paths
19
-
20
-
21
- def test_resolve_wordlist_sources_gobuster_defaults():
22
- assert resolve_wordlist_sources(True, []) == ["common", "large"]
23
-
24
-
25
- def test_resolve_wordlist_sources_custom():
26
- assert resolve_wordlist_sources(False, ["common", "/tmp/list.txt"]) == [
27
- "common",
28
- "/tmp/list.txt",
29
- ]
30
-
31
-
32
- def test_load_builtin_common():
33
- from snapshot.wordlist import load_words
34
-
35
- words = load_words(["common"])
36
- assert "robots.txt" in words
37
- assert "sitemap.xml" in words
38
-
39
-
40
- @pytest.mark.asyncio
41
- async def test_probe_url_rejects_soft_404():
42
- client = MagicMock(spec=httpx.AsyncClient)
43
- head_response = MagicMock()
44
- head_response.status_code = 200
45
- get_response = MagicMock()
46
- get_response.status_code = 200
47
- get_response.headers = {"content-type": "text/html"}
48
- get_response.url = "https://example.com/missing"
49
- get_response.text = (
50
- "<html><head><title>404 Not Found</title></head><body><h1>404</h1></body></html>"
51
- )
52
- client.request = AsyncMock(side_effect=[head_response, get_response])
53
-
54
- assert await _probe_url(client, "https://example.com/missing") is False
55
-
56
-
57
- @pytest.mark.asyncio
58
- async def test_probe_url_accepts_valid_page():
59
- client = MagicMock(spec=httpx.AsyncClient)
60
- head_response = MagicMock()
61
- head_response.status_code = 200
62
- get_response = MagicMock()
63
- get_response.status_code = 200
64
- get_response.headers = {"content-type": "text/html"}
65
- get_response.url = "https://example.com/about"
66
- get_response.text = "<html><head><title>About</title></head><body><h1>About</h1></body></html>"
67
- client.request = AsyncMock(side_effect=[head_response, get_response])
68
-
69
- assert await _probe_url(client, "https://example.com/about") is True