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.
- {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.3.0}/PKG-INFO +1 -1
- {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.3.0}/docs/index.html +3 -3
- {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.3.0}/pyproject.toml +1 -1
- {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.3.0}/src/snapshot/__init__.py +1 -1
- {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.3.0}/src/snapshot/downloader.py +17 -12
- {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.3.0}/src/snapshot/utils.py +75 -27
- {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.3.0}/src/snapshot/wordlist.py +9 -14
- {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.3.0}/tests/test_crawler.py +27 -0
- {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.3.0}/tests/test_utils.py +43 -3
- web_snapshot_cli-1.3.0/tests/test_wordlist.py +92 -0
- web_snapshot_cli-1.1.0/tests/test_wordlist.py +0 -69
- {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.3.0}/.github/workflows/ci.yml +0 -0
- {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.3.0}/.github/workflows/pages.yml +0 -0
- {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.3.0}/.github/workflows/publish.yml +0 -0
- {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.3.0}/.gitignore +0 -0
- {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.3.0}/LICENSE +0 -0
- {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.3.0}/README.md +0 -0
- {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.3.0}/docs/CNAME +0 -0
- {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.3.0}/docs/app.js +0 -0
- {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.3.0}/docs/style.css +0 -0
- {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.3.0}/src/snapshot/__main__.py +0 -0
- {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.3.0}/src/snapshot/cli.py +0 -0
- {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.3.0}/src/snapshot/crawl_policy.py +0 -0
- {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.3.0}/src/snapshot/manifest.py +0 -0
- {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.3.0}/src/snapshot/restore.py +0 -0
- {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.3.0}/src/snapshot/wordlists/__init__.py +0 -0
- {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.3.0}/src/snapshot/wordlists/common.txt +0 -0
- {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.3.0}/src/snapshot/wordlists/large.txt +0 -0
- {web_snapshot_cli-1.1.0 → web_snapshot_cli-1.3.0}/tests/test_crawl_policy.py +0 -0
- {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.
|
|
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>
|
|
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 large /path/to/SecLists/Discovery/Web-Content/common.txt"></textarea>
|
|
68
|
-
<div class="hint">Builtin names: <code>common</code>, <code>large</code>.
|
|
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" />
|
|
@@ -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
|
-
|
|
423
|
-
if style
|
|
424
|
-
for match in re.findall(r"url\(([^)]+)\)", style
|
|
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 =
|
|
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 = (
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
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
|
-
|
|
115
|
+
html = response.text
|
|
66
116
|
except Exception: # noqa: BLE001
|
|
67
117
|
return False
|
|
68
118
|
|
|
69
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
|
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
|
|
27
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|