web-snapshot-cli 1.1.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.1.0 → web_snapshot_cli-1.3.0}/PKG-INFO +1 -1
  2. {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.3.0}/docs/index.html +3 -3
  3. {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.3.0}/pyproject.toml +1 -1
  4. {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.3.0}/src/snapshot/__init__.py +1 -1
  5. {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.3.0}/src/snapshot/downloader.py +17 -12
  6. {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.3.0}/src/snapshot/utils.py +75 -27
  7. {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.3.0}/src/snapshot/wordlist.py +9 -14
  8. {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.3.0}/tests/test_crawler.py +27 -0
  9. {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.3.0}/tests/test_utils.py +43 -3
  10. web_snapshot_cli-1.3.0/tests/test_wordlist.py +92 -0
  11. web_snapshot_cli-1.1.0/tests/test_wordlist.py +0 -69
  12. {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.3.0}/.github/workflows/ci.yml +0 -0
  13. {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.3.0}/.github/workflows/pages.yml +0 -0
  14. {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.3.0}/.github/workflows/publish.yml +0 -0
  15. {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.3.0}/.gitignore +0 -0
  16. {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.3.0}/LICENSE +0 -0
  17. {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.3.0}/README.md +0 -0
  18. {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.3.0}/docs/CNAME +0 -0
  19. {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.3.0}/docs/app.js +0 -0
  20. {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.3.0}/docs/style.css +0 -0
  21. {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.3.0}/src/snapshot/__main__.py +0 -0
  22. {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.3.0}/src/snapshot/cli.py +0 -0
  23. {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.3.0}/src/snapshot/crawl_policy.py +0 -0
  24. {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.3.0}/src/snapshot/manifest.py +0 -0
  25. {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.3.0}/src/snapshot/restore.py +0 -0
  26. {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.3.0}/src/snapshot/wordlists/__init__.py +0 -0
  27. {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.3.0}/src/snapshot/wordlists/common.txt +0 -0
  28. {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.3.0}/src/snapshot/wordlists/large.txt +0 -0
  29. {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.3.0}/tests/test_crawl_policy.py +0 -0
  30. {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.3.0}/tests/test_features.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: web-snapshot-cli
3
- Version: 1.1.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
@@ -50,7 +50,7 @@
50
50
  </label>
51
51
  <label class="check-item">
52
52
  <input type="checkbox" id="gobuster" />
53
- <div>--gobuster<span>Bundled wordlists; soft 404 pages are filtered out</span></div>
53
+ <div>--gobuster<span>Wordlist discovery; skips any page mentioning 404</span></div>
54
54
  </label>
55
55
  <label class="check-item">
56
56
  <input type="checkbox" id="sitemap" />
@@ -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. Pages that return 200 but show 404/not-found content are skipped.</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>
@@ -143,7 +143,7 @@
143
143
  </label>
144
144
  <label class="check-item">
145
145
  <input type="checkbox" id="noAssets" />
146
- <div>--no-assets<span>Skip CSS/JS/images</span></div>
146
+ <div>--no-assets<span>Skip CSS/JS/images/animations</span></div>
147
147
  </label>
148
148
  <label class="check-item">
149
149
  <input type="checkbox" id="verbose" />
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "web-snapshot-cli"
7
- version = "1.1.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.1.0"
3
+ __version__ = "1.3.0"
@@ -20,6 +20,7 @@ from snapshot.manifest import MANIFEST_NAME, SnapshotManifest
20
20
  from snapshot.utils import (
21
21
  SRCSET_ATTRS,
22
22
  URL_ATTRS,
23
+ extract_css_urls,
23
24
  is_not_found_page,
24
25
  normalize_url,
25
26
  page_local_path,
@@ -363,6 +364,13 @@ class SnapshotEngine:
363
364
 
364
365
  try:
365
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 []
366
374
  except Exception as exc: # noqa: BLE001
367
375
  self._errors.append(f"{url}: {exc}")
368
376
  return []
@@ -419,12 +427,15 @@ class SnapshotEngine:
419
427
  absolute = normalize_url(piece, page_url)
420
428
  if absolute and not self._is_page_url(absolute):
421
429
  urls.add(absolute)
422
- for style in soup.find_all("style"):
423
- if style.string:
424
- for match in re.findall(r"url\(([^)]+)\)", style.string):
430
+ style = tag.get("style")
431
+ if style:
432
+ for match in re.findall(r"url\(([^)]+)\)", style):
425
433
  absolute = normalize_url(match.strip("'\""), page_url)
426
434
  if absolute and not self._is_page_url(absolute):
427
435
  urls.add(absolute)
436
+ for style in soup.find_all("style"):
437
+ if style.string:
438
+ urls.update(extract_css_urls(style.string, page_url))
428
439
  return urls
429
440
 
430
441
  def _collect_page_links(self, soup: BeautifulSoup, page_url: str) -> list[str]:
@@ -466,7 +477,7 @@ class SnapshotEngine:
466
477
  lambda asset_url: self._css_mapper(asset_url, local_path),
467
478
  )
468
479
  local_path.write_text(css, encoding="utf-8")
469
- nested = self._extract_css_urls(css, url)
480
+ nested = extract_css_urls(css, url)
470
481
  await asyncio.gather(*(self._download_asset(u) for u in nested))
471
482
  else:
472
483
  local_path.write_bytes(response.content)
@@ -480,14 +491,6 @@ class SnapshotEngine:
480
491
  self._url_to_path[asset_url] = target
481
492
  return relative_href(css_path, target)
482
493
 
483
- def _extract_css_urls(self, css: str, base_url: str) -> set[str]:
484
- urls: set[str] = set()
485
- for match in re.findall(r"url\(([^)]+)\)", css):
486
- absolute = normalize_url(match.strip("'\""), base_url)
487
- if absolute:
488
- urls.add(absolute)
489
- return urls
490
-
491
494
  def _rewrite_html(self, soup: BeautifulSoup, page_url: str, page_path: Path) -> None:
492
495
  for tag in soup.find_all(True):
493
496
  for attr in URL_ATTRS:
@@ -533,6 +536,8 @@ class SnapshotEngine:
533
536
  def _render_page(self, soup: BeautifulSoup) -> str:
534
537
  if self.options.lang == "md":
535
538
  return self._html_to_markdown(soup)
539
+ if not soup.find("html"):
540
+ return f"<!DOCTYPE html>\n{str(soup)}"
536
541
  return str(soup)
537
542
 
538
543
  def _html_to_markdown(self, soup: BeautifulSoup) -> str:
@@ -12,8 +12,28 @@ if TYPE_CHECKING:
12
12
  import httpx
13
13
 
14
14
  # Attributes that may contain fetchable URLs.
15
- URL_ATTRS = ("href", "src", "poster", "data-src", "data-background")
15
+ URL_ATTRS = (
16
+ "href",
17
+ "src",
18
+ "poster",
19
+ "data-src",
20
+ "data-background",
21
+ "data-lazy-src",
22
+ "data-original",
23
+ "data-lazy",
24
+ "data-bg",
25
+ "data-background-image",
26
+ "data-image",
27
+ "data-href",
28
+ "data-url",
29
+ "data-video",
30
+ "data-poster",
31
+ "data-anim-src",
32
+ "data-animation",
33
+ )
16
34
  SRCSET_ATTRS = ("srcset", "data-srcset")
35
+ TEXT_ATTRS = ("aria-label", "alt", "title", "placeholder")
36
+ _SKIP_TAGS = frozenset({"script", "style", "noscript"})
17
37
 
18
38
  # Tags treated as HTML pages when crawled.
19
39
  PAGE_EXTENSIONS = {".html", ".htm", ".xhtml", ""}
@@ -43,14 +63,44 @@ ASSET_EXTENSIONS = {
43
63
 
44
64
  SKIP_SCHEMES = {"mailto", "tel", "javascript", "data", "blob", "about", "file"}
45
65
 
46
- _NOT_FOUND_TEXT = re.compile(
47
- r"\b404\b|not\s+found|page\s+not\s+found|page\s+cannot\s+be\s+found",
48
- re.IGNORECASE,
49
- )
66
+ _404_MARKER = re.compile(r"404", re.IGNORECASE)
67
+
68
+
69
+ def extract_page_text(html: str) -> str:
70
+ """Collect visible and semantic text from an HTML document."""
71
+ from bs4 import BeautifulSoup
72
+
73
+ soup = BeautifulSoup(html, "html.parser")
74
+ parts: list[str] = []
75
+
76
+ if soup.title:
77
+ parts.append(soup.title.get_text(" ", strip=True))
78
+
79
+ for meta in soup.find_all("meta"):
80
+ content = meta.get("content")
81
+ if content:
82
+ parts.append(str(content))
83
+
84
+ for tag in soup.find_all(True):
85
+ for attr in TEXT_ATTRS:
86
+ value = tag.get(attr)
87
+ if value:
88
+ parts.append(str(value))
89
+
90
+ for skip_tag in list(soup.find_all(_SKIP_TAGS)):
91
+ skip_tag.decompose()
92
+
93
+ parts.append(soup.get_text(" ", strip=True))
94
+ return " ".join(part for part in parts if part)
95
+
96
+
97
+ def page_mentions_404(text: str) -> bool:
98
+ """Return True when page text contains a 404 marker."""
99
+ return bool(_404_MARKER.search(text))
50
100
 
51
101
 
52
102
  def is_not_found_page(response: httpx.Response) -> bool:
53
- """Detect hard and soft 404 pages (HTTP 404 or HTML error content with 200 status)."""
103
+ """Detect hard and soft 404 pages (HTTP 404 or any page text mentioning 404)."""
54
104
  if response.status_code == 404:
55
105
  return True
56
106
  if response.status_code not in {200, 201, 204}:
@@ -62,32 +112,16 @@ def is_not_found_page(response: httpx.Response) -> bool:
62
112
  return False
63
113
 
64
114
  try:
65
- text = response.text
115
+ html = response.text
66
116
  except Exception: # noqa: BLE001
67
117
  return False
68
118
 
69
- from bs4 import BeautifulSoup
70
-
71
- soup = BeautifulSoup(text, "html.parser")
72
-
73
- title = soup.title.get_text(" ", strip=True) if soup.title else ""
74
- if title and _NOT_FOUND_TEXT.search(title):
119
+ if page_mentions_404(extract_page_text(html)):
75
120
  return True
76
121
 
77
- for tag_name in ("h1", "h2"):
78
- heading = soup.find(tag_name)
79
- if heading:
80
- heading_text = heading.get_text(" ", strip=True)
81
- if heading_text and _NOT_FOUND_TEXT.search(heading_text):
82
- return True
83
-
84
- body = soup.body
85
- if body:
86
- body_text = body.get_text(" ", strip=True)
87
- if body_text and len(body_text) < 500 and _NOT_FOUND_TEXT.search(body_text):
88
- return True
89
-
90
- return False
122
+ stripped = re.sub(r"(?is)<script[^>]*>.*?</script>", " ", html)
123
+ stripped = re.sub(r"(?is)<style[^>]*>.*?</style>", " ", stripped)
124
+ return page_mentions_404(stripped)
91
125
 
92
126
 
93
127
  def normalize_url(url: str, base: str | None = None) -> str | None:
@@ -187,6 +221,20 @@ def parse_srcset(value: str) -> list[str]:
187
221
  return urls
188
222
 
189
223
 
224
+ def extract_css_urls(css: str, base_url: str) -> set[str]:
225
+ """Extract linked asset URLs from CSS, including @import rules."""
226
+ urls: set[str] = set()
227
+ for match in re.findall(r"url\(([^)]+)\)", css):
228
+ absolute = normalize_url(match.strip("'\""), base_url)
229
+ if absolute:
230
+ urls.add(absolute)
231
+ for match in re.findall(r"@import\s+(?:url\()?['\"]?([^'\")\s;]+)", css):
232
+ absolute = normalize_url(match.strip("'\""), base_url)
233
+ if absolute:
234
+ urls.add(absolute)
235
+ return urls
236
+
237
+
190
238
  def rewrite_css_urls(css: str, base_url: str, mapper) -> str:
191
239
  """Rewrite url(...) references in CSS using mapper(url) -> local path or None."""
192
240
 
@@ -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 == []
@@ -1,7 +1,15 @@
1
1
  from pathlib import Path
2
2
  from unittest.mock import MagicMock
3
3
 
4
- from snapshot.utils import is_not_found_page, normalize_url, page_local_path, url_to_local_path
4
+ from snapshot.utils import (
5
+ extract_css_urls,
6
+ extract_page_text,
7
+ is_not_found_page,
8
+ normalize_url,
9
+ page_local_path,
10
+ page_mentions_404,
11
+ url_to_local_path,
12
+ )
5
13
 
6
14
 
7
15
  def _html_response(html: str, status_code: int = 200) -> MagicMock:
@@ -23,8 +31,14 @@ def test_is_not_found_page_soft_404_title():
23
31
  assert is_not_found_page(_html_response(html)) is True
24
32
 
25
33
 
26
- def test_is_not_found_page_soft_404_heading():
27
- html = "<html><body><h1>Page Not Found</h1><p>Sorry</p></body></html>"
34
+ def test_is_not_found_page_soft_404_anywhere_on_page():
35
+ filler = " ".join(["lorem ipsum"] * 200)
36
+ html = f"<html><body><main>{filler}</main><footer>Error 404</footer></body></html>"
37
+ assert is_not_found_page(_html_response(html)) is True
38
+
39
+
40
+ def test_is_not_found_page_soft_404_in_meta():
41
+ html = '<html><head><meta name="description" content="404 page"></head><body>Hi</body></html>'
28
42
  assert is_not_found_page(_html_response(html)) is True
29
43
 
30
44
 
@@ -33,6 +47,32 @@ def test_is_not_found_page_valid_page():
33
47
  assert is_not_found_page(_html_response(html)) is False
34
48
 
35
49
 
50
+ def test_extract_page_text_includes_meta_and_attributes():
51
+ html = (
52
+ "<html><head><title>Home</title>"
53
+ '<meta name="description" content="Welcome home"></head>'
54
+ '<body><img alt="Hero banner" src="/hero.png"><p>Hello</p></body></html>'
55
+ )
56
+ text = extract_page_text(html)
57
+ assert "Home" in text
58
+ assert "Welcome home" in text
59
+ assert "Hero banner" in text
60
+ assert "Hello" in text
61
+
62
+
63
+ def test_page_mentions_404_case_insensitive():
64
+ assert page_mentions_404("error 404") is True
65
+ assert page_mentions_404("ERROR 404") is True
66
+ assert page_mentions_404("all good") is False
67
+
68
+
69
+ def test_extract_css_urls_import_and_url():
70
+ css = '@import url("fonts/inter.css"); body { background: url("/bg.png"); }'
71
+ urls = extract_css_urls(css, "https://example.com/style.css")
72
+ assert "https://example.com/fonts/inter.css" in urls
73
+ assert "https://example.com/bg.png" in urls
74
+
75
+
36
76
  def test_normalize_url_drops_fragment():
37
77
  assert normalize_url("https://example.com/path#section") == "https://example.com/path"
38
78
 
@@ -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