web-snapshot-cli 0.1.0__tar.gz → 1.0.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-0.1.0 → web_snapshot_cli-1.0.0}/.github/workflows/publish.yml +3 -2
- {web_snapshot_cli-0.1.0 → web_snapshot_cli-1.0.0}/PKG-INFO +16 -38
- {web_snapshot_cli-0.1.0 → web_snapshot_cli-1.0.0}/README.md +14 -36
- {web_snapshot_cli-0.1.0 → web_snapshot_cli-1.0.0}/pyproject.toml +6 -2
- {web_snapshot_cli-0.1.0 → web_snapshot_cli-1.0.0}/src/snapshot/__init__.py +1 -1
- {web_snapshot_cli-0.1.0 → web_snapshot_cli-1.0.0}/src/snapshot/cli.py +47 -5
- {web_snapshot_cli-0.1.0 → web_snapshot_cli-1.0.0}/src/snapshot/downloader.py +69 -1
- web_snapshot_cli-1.0.0/src/snapshot/wordlist.py +171 -0
- web_snapshot_cli-1.0.0/src/snapshot/wordlists/__init__.py +0 -0
- web_snapshot_cli-1.0.0/src/snapshot/wordlists/common.txt +132 -0
- web_snapshot_cli-1.0.0/src/snapshot/wordlists/large.txt +18637 -0
- web_snapshot_cli-1.0.0/tests/test_wordlist.py +32 -0
- web_snapshot_cli-0.1.0/install.ps1 +0 -67
- web_snapshot_cli-0.1.0/install.sh +0 -109
- {web_snapshot_cli-0.1.0 → web_snapshot_cli-1.0.0}/.github/workflows/ci.yml +0 -0
- {web_snapshot_cli-0.1.0 → web_snapshot_cli-1.0.0}/.gitignore +0 -0
- {web_snapshot_cli-0.1.0 → web_snapshot_cli-1.0.0}/LICENSE +0 -0
- {web_snapshot_cli-0.1.0 → web_snapshot_cli-1.0.0}/src/snapshot/__main__.py +0 -0
- {web_snapshot_cli-0.1.0 → web_snapshot_cli-1.0.0}/src/snapshot/crawl_policy.py +0 -0
- {web_snapshot_cli-0.1.0 → web_snapshot_cli-1.0.0}/src/snapshot/manifest.py +0 -0
- {web_snapshot_cli-0.1.0 → web_snapshot_cli-1.0.0}/src/snapshot/restore.py +0 -0
- {web_snapshot_cli-0.1.0 → web_snapshot_cli-1.0.0}/src/snapshot/utils.py +0 -0
- {web_snapshot_cli-0.1.0 → web_snapshot_cli-1.0.0}/tests/test_crawl_policy.py +0 -0
- {web_snapshot_cli-0.1.0 → web_snapshot_cli-1.0.0}/tests/test_crawler.py +0 -0
- {web_snapshot_cli-0.1.0 → web_snapshot_cli-1.0.0}/tests/test_features.py +0 -0
- {web_snapshot_cli-0.1.0 → web_snapshot_cli-1.0.0}/tests/test_utils.py +0 -0
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
# PyPI
|
|
2
|
-
# Trusted publisher: owner codingsushi79, repo Snapshot, workflow publish.yml, env pypi
|
|
1
|
+
# PyPI: web-snapshot-cli
|
|
3
2
|
name: Publish to PyPI
|
|
4
3
|
|
|
5
4
|
on:
|
|
@@ -62,3 +61,5 @@ jobs:
|
|
|
62
61
|
path: dist/
|
|
63
62
|
|
|
64
63
|
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
64
|
+
with:
|
|
65
|
+
skip-existing: true
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: web-snapshot-cli
|
|
3
|
-
Version:
|
|
3
|
+
Version: 1.0.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
|
|
@@ -8,7 +8,7 @@ Author: snapshot contributors
|
|
|
8
8
|
License-Expression: MIT
|
|
9
9
|
License-File: LICENSE
|
|
10
10
|
Keywords: cli,crawler,offline,snapshot,web-archive
|
|
11
|
-
Classifier: Development Status ::
|
|
11
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
12
12
|
Classifier: Environment :: Console
|
|
13
13
|
Classifier: Intended Audience :: Developers
|
|
14
14
|
Classifier: License :: OSI Approved :: MIT License
|
|
@@ -33,25 +33,7 @@ Take a fast offline copy of any public website, then serve it locally.
|
|
|
33
33
|
|
|
34
34
|
## Install
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
```bash
|
|
39
|
-
curl -fsSL https://raw.githubusercontent.com/codingsushi79/Snapshot/main/install.sh | bash
|
|
40
|
-
```
|
|
41
|
-
|
|
42
|
-
**One-liner (Windows PowerShell):**
|
|
43
|
-
|
|
44
|
-
```powershell
|
|
45
|
-
irm https://raw.githubusercontent.com/codingsushi79/Snapshot/main/install.ps1 | iex
|
|
46
|
-
```
|
|
47
|
-
|
|
48
|
-
**Universal fallback** (requires Python 3.10+):
|
|
49
|
-
|
|
50
|
-
```bash
|
|
51
|
-
python3 -m pip install --user git+https://github.com/codingsushi79/Snapshot.git
|
|
52
|
-
```
|
|
53
|
-
|
|
54
|
-
**From PyPI** (once published):
|
|
36
|
+
Requires Python 3.10+.
|
|
55
37
|
|
|
56
38
|
```bash
|
|
57
39
|
pip install web-snapshot-cli
|
|
@@ -63,23 +45,6 @@ Then run:
|
|
|
63
45
|
snapshot https://example.com ./mirror
|
|
64
46
|
```
|
|
65
47
|
|
|
66
|
-
<details>
|
|
67
|
-
<summary>Other install methods</summary>
|
|
68
|
-
|
|
69
|
-
```bash
|
|
70
|
-
# With pipx (recommended for CLI tools)
|
|
71
|
-
pipx install web-snapshot-cli
|
|
72
|
-
# or from git:
|
|
73
|
-
pipx install git+https://github.com/codingsushi79/Snapshot.git
|
|
74
|
-
|
|
75
|
-
# From a local clone
|
|
76
|
-
git clone https://github.com/codingsushi79/Snapshot.git
|
|
77
|
-
cd Snapshot
|
|
78
|
-
pip install -e .
|
|
79
|
-
```
|
|
80
|
-
|
|
81
|
-
</details>
|
|
82
|
-
|
|
83
48
|
## Usage
|
|
84
49
|
|
|
85
50
|
### Snapshot a single page
|
|
@@ -120,6 +85,16 @@ snapshot --cookie session=abc123 --header "Authorization: Bearer TOKEN" \
|
|
|
120
85
|
snapshot --crawl --sitemap https://example.com ./mirror --max-pages 500
|
|
121
86
|
```
|
|
122
87
|
|
|
88
|
+
### Gobuster-style path discovery
|
|
89
|
+
|
|
90
|
+
Brute-force common paths (including `robots.txt`, `sitemap.xml`, admin panels, etc.) using bundled wordlists (`common` + ~18k `large` entries). Add your own SecLists/gobuster wordlists with `-w`:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
snapshot --crawl --gobuster https://example.com ./mirror
|
|
94
|
+
snapshot --crawl -w common -w large -w /path/to/SecLists/Discovery/Web-Content/common.txt URL ./out
|
|
95
|
+
snapshot --crawl --gobuster --wordlist-ext html,php,asp URL ./out
|
|
96
|
+
```
|
|
97
|
+
|
|
123
98
|
### Resume an interrupted snapshot
|
|
124
99
|
|
|
125
100
|
```bash
|
|
@@ -172,6 +147,9 @@ snapshot -restore ./mirror --port 3000 --no-open
|
|
|
172
147
|
| `--robots` / `--no-robots` | Respect robots.txt (default: on) |
|
|
173
148
|
| `--crawl-delay` | Seconds to wait after each request (default: 0) |
|
|
174
149
|
| `--sitemap` | Seed crawl from sitemap.xml |
|
|
150
|
+
| `--gobuster`, `-g` | Discover paths with bundled wordlists |
|
|
151
|
+
| `--wordlist`, `-w` | Custom or builtin wordlist (`common`, `large`) |
|
|
152
|
+
| `--wordlist-ext` | Extensions to append per word (`html,php`) |
|
|
175
153
|
| `--resume` | Skip pages/assets already saved |
|
|
176
154
|
| `--verbose`, `-v` | Detailed log output |
|
|
177
155
|
| `--dry-run` | Fetch without writing files |
|
|
@@ -4,25 +4,7 @@ Take a fast offline copy of any public website, then serve it locally.
|
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
```bash
|
|
10
|
-
curl -fsSL https://raw.githubusercontent.com/codingsushi79/Snapshot/main/install.sh | bash
|
|
11
|
-
```
|
|
12
|
-
|
|
13
|
-
**One-liner (Windows PowerShell):**
|
|
14
|
-
|
|
15
|
-
```powershell
|
|
16
|
-
irm https://raw.githubusercontent.com/codingsushi79/Snapshot/main/install.ps1 | iex
|
|
17
|
-
```
|
|
18
|
-
|
|
19
|
-
**Universal fallback** (requires Python 3.10+):
|
|
20
|
-
|
|
21
|
-
```bash
|
|
22
|
-
python3 -m pip install --user git+https://github.com/codingsushi79/Snapshot.git
|
|
23
|
-
```
|
|
24
|
-
|
|
25
|
-
**From PyPI** (once published):
|
|
7
|
+
Requires Python 3.10+.
|
|
26
8
|
|
|
27
9
|
```bash
|
|
28
10
|
pip install web-snapshot-cli
|
|
@@ -34,23 +16,6 @@ Then run:
|
|
|
34
16
|
snapshot https://example.com ./mirror
|
|
35
17
|
```
|
|
36
18
|
|
|
37
|
-
<details>
|
|
38
|
-
<summary>Other install methods</summary>
|
|
39
|
-
|
|
40
|
-
```bash
|
|
41
|
-
# With pipx (recommended for CLI tools)
|
|
42
|
-
pipx install web-snapshot-cli
|
|
43
|
-
# or from git:
|
|
44
|
-
pipx install git+https://github.com/codingsushi79/Snapshot.git
|
|
45
|
-
|
|
46
|
-
# From a local clone
|
|
47
|
-
git clone https://github.com/codingsushi79/Snapshot.git
|
|
48
|
-
cd Snapshot
|
|
49
|
-
pip install -e .
|
|
50
|
-
```
|
|
51
|
-
|
|
52
|
-
</details>
|
|
53
|
-
|
|
54
19
|
## Usage
|
|
55
20
|
|
|
56
21
|
### Snapshot a single page
|
|
@@ -91,6 +56,16 @@ snapshot --cookie session=abc123 --header "Authorization: Bearer TOKEN" \
|
|
|
91
56
|
snapshot --crawl --sitemap https://example.com ./mirror --max-pages 500
|
|
92
57
|
```
|
|
93
58
|
|
|
59
|
+
### Gobuster-style path discovery
|
|
60
|
+
|
|
61
|
+
Brute-force common paths (including `robots.txt`, `sitemap.xml`, admin panels, etc.) using bundled wordlists (`common` + ~18k `large` entries). Add your own SecLists/gobuster wordlists with `-w`:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
snapshot --crawl --gobuster https://example.com ./mirror
|
|
65
|
+
snapshot --crawl -w common -w large -w /path/to/SecLists/Discovery/Web-Content/common.txt URL ./out
|
|
66
|
+
snapshot --crawl --gobuster --wordlist-ext html,php,asp URL ./out
|
|
67
|
+
```
|
|
68
|
+
|
|
94
69
|
### Resume an interrupted snapshot
|
|
95
70
|
|
|
96
71
|
```bash
|
|
@@ -143,6 +118,9 @@ snapshot -restore ./mirror --port 3000 --no-open
|
|
|
143
118
|
| `--robots` / `--no-robots` | Respect robots.txt (default: on) |
|
|
144
119
|
| `--crawl-delay` | Seconds to wait after each request (default: 0) |
|
|
145
120
|
| `--sitemap` | Seed crawl from sitemap.xml |
|
|
121
|
+
| `--gobuster`, `-g` | Discover paths with bundled wordlists |
|
|
122
|
+
| `--wordlist`, `-w` | Custom or builtin wordlist (`common`, `large`) |
|
|
123
|
+
| `--wordlist-ext` | Extensions to append per word (`html,php`) |
|
|
146
124
|
| `--resume` | Skip pages/assets already saved |
|
|
147
125
|
| `--verbose`, `-v` | Detailed log output |
|
|
148
126
|
| `--dry-run` | Fetch without writing files |
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "web-snapshot-cli"
|
|
7
|
-
version = "
|
|
7
|
+
version = "1.0.0"
|
|
8
8
|
description = "Take offline snapshots of any website and restore them locally"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -12,7 +12,7 @@ requires-python = ">=3.10"
|
|
|
12
12
|
authors = [{ name = "snapshot contributors" }]
|
|
13
13
|
keywords = ["cli", "web-archive", "snapshot", "offline", "crawler"]
|
|
14
14
|
classifiers = [
|
|
15
|
-
"Development Status ::
|
|
15
|
+
"Development Status :: 5 - Production/Stable",
|
|
16
16
|
"Environment :: Console",
|
|
17
17
|
"Intended Audience :: Developers",
|
|
18
18
|
"License :: OSI Approved :: MIT License",
|
|
@@ -41,6 +41,10 @@ Repository = "https://github.com/codingsushi79/Snapshot"
|
|
|
41
41
|
[tool.hatch.build.targets.wheel]
|
|
42
42
|
packages = ["src/snapshot"]
|
|
43
43
|
|
|
44
|
+
[tool.hatch.build.targets.wheel.force-include]
|
|
45
|
+
"src/snapshot/wordlists/common.txt" = "snapshot/wordlists/common.txt"
|
|
46
|
+
"src/snapshot/wordlists/large.txt" = "snapshot/wordlists/large.txt"
|
|
47
|
+
|
|
44
48
|
[tool.pytest.ini_options]
|
|
45
49
|
asyncio_mode = "auto"
|
|
46
50
|
testpaths = ["tests"]
|
|
@@ -68,13 +68,20 @@ def _build_options(
|
|
|
68
68
|
respect_robots: bool,
|
|
69
69
|
crawl_delay: float,
|
|
70
70
|
use_sitemap: bool,
|
|
71
|
+
gobuster: bool,
|
|
72
|
+
wordlists: tuple[str, ...],
|
|
73
|
+
wordlist_ext: str,
|
|
71
74
|
resume: bool,
|
|
72
75
|
verbose: bool,
|
|
73
76
|
dry_run: bool,
|
|
74
77
|
) -> SnapshotOptions:
|
|
75
78
|
default_ua = SnapshotOptions().user_agent
|
|
79
|
+
use_gobuster = gobuster or bool(extras.get("gobuster")) or bool(
|
|
80
|
+
wordlists or extras.get("wordlist")
|
|
81
|
+
)
|
|
82
|
+
crawl_enabled = crawl or bool(extras.get("crawl")) or use_gobuster
|
|
76
83
|
return SnapshotOptions(
|
|
77
|
-
crawl=
|
|
84
|
+
crawl=crawl_enabled,
|
|
78
85
|
max_pages=int(extras.get("max_pages", max_pages)),
|
|
79
86
|
max_depth=int(extras.get("depth", depth)),
|
|
80
87
|
lang=str(extras.get("lang", lang)),
|
|
@@ -90,6 +97,9 @@ def _build_options(
|
|
|
90
97
|
respect_robots=bool(extras.get("respect_robots", respect_robots)),
|
|
91
98
|
crawl_delay=float(extras.get("crawl_delay", crawl_delay)),
|
|
92
99
|
use_sitemap=use_sitemap or bool(extras.get("sitemap")),
|
|
100
|
+
gobuster=gobuster or bool(extras.get("gobuster")),
|
|
101
|
+
wordlists=list(wordlists) + _parse_list_value(extras.get("wordlist")),
|
|
102
|
+
wordlist_extensions=_parse_list_value(extras.get("wordlist_ext") or wordlist_ext),
|
|
93
103
|
resume=resume or bool(extras.get("resume")),
|
|
94
104
|
verbose=verbose or bool(extras.get("verbose")),
|
|
95
105
|
dry_run=dry_run or bool(extras.get("dry_run")),
|
|
@@ -180,9 +190,27 @@ def _build_options(
|
|
|
180
190
|
)
|
|
181
191
|
@click.option(
|
|
182
192
|
"--sitemap",
|
|
193
|
+
"use_sitemap",
|
|
183
194
|
is_flag=True,
|
|
184
195
|
help="Seed crawl URLs from sitemap.xml and robots.txt Sitemap directives.",
|
|
185
196
|
)
|
|
197
|
+
@click.option(
|
|
198
|
+
"--gobuster",
|
|
199
|
+
"-g",
|
|
200
|
+
is_flag=True,
|
|
201
|
+
help="Brute-force discover paths using bundled wordlists (common + large).",
|
|
202
|
+
)
|
|
203
|
+
@click.option(
|
|
204
|
+
"--wordlist",
|
|
205
|
+
"-w",
|
|
206
|
+
multiple=True,
|
|
207
|
+
help="Wordlist file or builtin name: common, large (repeatable). Enables path scanning.",
|
|
208
|
+
)
|
|
209
|
+
@click.option(
|
|
210
|
+
"--wordlist-ext",
|
|
211
|
+
default="",
|
|
212
|
+
help="Comma-separated extensions to try with each word (e.g. html,php,asp).",
|
|
213
|
+
)
|
|
186
214
|
@click.option(
|
|
187
215
|
"--resume",
|
|
188
216
|
is_flag=True,
|
|
@@ -198,7 +226,9 @@ def _build_options(
|
|
|
198
226
|
@click.option("--host", default="127.0.0.1", show_default=True, help="Host for -restore.")
|
|
199
227
|
@click.option("--no-open", is_flag=True, help="Do not open a browser when restoring.")
|
|
200
228
|
@click.version_option(__version__, prog_name="snapshot")
|
|
229
|
+
@click.pass_context
|
|
201
230
|
def main(
|
|
231
|
+
ctx: click.Context,
|
|
202
232
|
url: str | None,
|
|
203
233
|
output_dir: str | None,
|
|
204
234
|
extra_args: tuple[str, ...],
|
|
@@ -219,6 +249,9 @@ def main(
|
|
|
219
249
|
robots: bool,
|
|
220
250
|
crawl_delay: float,
|
|
221
251
|
use_sitemap: bool,
|
|
252
|
+
gobuster: bool,
|
|
253
|
+
wordlist: tuple[str, ...],
|
|
254
|
+
wordlist_ext: str,
|
|
222
255
|
resume: bool,
|
|
223
256
|
verbose: bool,
|
|
224
257
|
dry_run: bool,
|
|
@@ -232,7 +265,8 @@ def main(
|
|
|
232
265
|
Examples:
|
|
233
266
|
snapshot https://example.com ./mirror
|
|
234
267
|
snapshot --crawl https://docs.example.com ./docs --max-pages=200
|
|
235
|
-
snapshot --crawl --
|
|
268
|
+
snapshot --crawl --gobuster --sitemap https://example.com ./mirror
|
|
269
|
+
snapshot --crawl -w common -w /path/to/SecLists.txt URL ./out
|
|
236
270
|
snapshot --cookie session=abc --header "Authorization: Bearer x" URL ./out
|
|
237
271
|
snapshot --crawl --include '/blog/*' --exclude '/blog/drafts/*' URL ./out
|
|
238
272
|
snapshot --resume --crawl URL ./mirror
|
|
@@ -245,9 +279,9 @@ def main(
|
|
|
245
279
|
return
|
|
246
280
|
|
|
247
281
|
if not url or not output_dir:
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
282
|
+
if not url:
|
|
283
|
+
ctx.fail("Missing argument 'URL'.")
|
|
284
|
+
ctx.fail("Missing argument 'OUTPUT_DIR'.")
|
|
251
285
|
|
|
252
286
|
options = _build_options(
|
|
253
287
|
extras,
|
|
@@ -267,6 +301,9 @@ def main(
|
|
|
267
301
|
respect_robots=robots,
|
|
268
302
|
crawl_delay=crawl_delay,
|
|
269
303
|
use_sitemap=use_sitemap,
|
|
304
|
+
gobuster=gobuster,
|
|
305
|
+
wordlists=wordlist,
|
|
306
|
+
wordlist_ext=wordlist_ext,
|
|
270
307
|
resume=resume,
|
|
271
308
|
verbose=verbose,
|
|
272
309
|
dry_run=dry_run,
|
|
@@ -280,6 +317,9 @@ def main(
|
|
|
280
317
|
console.print(f" crawl: up to {options.max_pages} pages, depth {options.max_depth}")
|
|
281
318
|
if options.use_sitemap:
|
|
282
319
|
console.print(" sitemap: enabled")
|
|
320
|
+
if options.gobuster or options.wordlists:
|
|
321
|
+
sources = options.wordlists or ["common", "large"]
|
|
322
|
+
console.print(f" wordlist: {', '.join(sources)}")
|
|
283
323
|
if options.resume:
|
|
284
324
|
console.print(" resume: enabled")
|
|
285
325
|
console.print(f" format: {options.lang}")
|
|
@@ -311,6 +351,8 @@ def main(
|
|
|
311
351
|
console.print(
|
|
312
352
|
f" skipped: {result.pages_skipped} pages, {result.assets_skipped} assets (resume)"
|
|
313
353
|
)
|
|
354
|
+
if result.wordlist_hits:
|
|
355
|
+
console.print(f" wordlist: {result.wordlist_hits} paths discovered")
|
|
314
356
|
if result.errors:
|
|
315
357
|
console.print(f"[yellow]{len(result.errors)} errors[/yellow] (see below)")
|
|
316
358
|
for err in result.errors[:10]:
|
|
@@ -13,6 +13,7 @@ from markdownify import markdownify
|
|
|
13
13
|
from snapshot.crawl_policy import (
|
|
14
14
|
RobotsChecker,
|
|
15
15
|
discover_sitemap_urls,
|
|
16
|
+
parse_sitemap_xml,
|
|
16
17
|
url_matches_filters,
|
|
17
18
|
)
|
|
18
19
|
from snapshot.manifest import MANIFEST_NAME, SnapshotManifest
|
|
@@ -27,6 +28,7 @@ from snapshot.utils import (
|
|
|
27
28
|
same_origin,
|
|
28
29
|
url_to_local_path,
|
|
29
30
|
)
|
|
31
|
+
from snapshot.wordlist import load_words, resolve_wordlist_sources, scan_wordlists
|
|
30
32
|
|
|
31
33
|
|
|
32
34
|
@dataclass
|
|
@@ -39,7 +41,7 @@ class SnapshotOptions:
|
|
|
39
41
|
timeout: float = 15.0
|
|
40
42
|
concurrency: int = 16
|
|
41
43
|
same_origin_only: bool = True
|
|
42
|
-
user_agent: str = "web-snapshot-cli/0
|
|
44
|
+
user_agent: str = "web-snapshot-cli/1.0 (+https://github.com/codingsushi79/Snapshot)"
|
|
43
45
|
cookies: list[str] = field(default_factory=list)
|
|
44
46
|
headers: list[str] = field(default_factory=list)
|
|
45
47
|
include_patterns: list[str] = field(default_factory=list)
|
|
@@ -47,6 +49,9 @@ class SnapshotOptions:
|
|
|
47
49
|
respect_robots: bool = True
|
|
48
50
|
crawl_delay: float = 0.0
|
|
49
51
|
use_sitemap: bool = False
|
|
52
|
+
gobuster: bool = False
|
|
53
|
+
wordlists: list[str] = field(default_factory=list)
|
|
54
|
+
wordlist_extensions: list[str] = field(default_factory=list)
|
|
50
55
|
resume: bool = False
|
|
51
56
|
verbose: bool = False
|
|
52
57
|
dry_run: bool = False
|
|
@@ -58,6 +63,7 @@ class SnapshotResult:
|
|
|
58
63
|
assets_saved: int = 0
|
|
59
64
|
pages_skipped: int = 0
|
|
60
65
|
assets_skipped: int = 0
|
|
66
|
+
wordlist_hits: int = 0
|
|
61
67
|
errors: list[str] = field(default_factory=list)
|
|
62
68
|
|
|
63
69
|
|
|
@@ -75,6 +81,7 @@ class SnapshotEngine:
|
|
|
75
81
|
self._robots = RobotsChecker(options.user_agent, options.respect_robots)
|
|
76
82
|
self._pages_skipped = 0
|
|
77
83
|
self._assets_skipped = 0
|
|
84
|
+
self._wordlist_hits = 0
|
|
78
85
|
self._log_fn: Callable[[str], None] | None = None
|
|
79
86
|
|
|
80
87
|
async def run(
|
|
@@ -119,6 +126,7 @@ class SnapshotEngine:
|
|
|
119
126
|
assets_saved=len(self._seen_assets),
|
|
120
127
|
pages_skipped=self._pages_skipped,
|
|
121
128
|
assets_skipped=self._assets_skipped,
|
|
129
|
+
wordlist_hits=self._wordlist_hits,
|
|
122
130
|
errors=self._errors,
|
|
123
131
|
)
|
|
124
132
|
|
|
@@ -133,6 +141,8 @@ class SnapshotEngine:
|
|
|
133
141
|
"respect_robots": self.options.respect_robots,
|
|
134
142
|
"crawl_delay": self.options.crawl_delay,
|
|
135
143
|
"use_sitemap": self.options.use_sitemap,
|
|
144
|
+
"gobuster": self.options.gobuster,
|
|
145
|
+
"wordlists": self.options.wordlists,
|
|
136
146
|
}
|
|
137
147
|
|
|
138
148
|
def _build_headers(self) -> dict[str, str]:
|
|
@@ -198,6 +208,35 @@ class SnapshotEngine:
|
|
|
198
208
|
self._log(f"sitemap: discovered {len(sitemap_urls)} URLs")
|
|
199
209
|
seed_urls.extend(sitemap_urls)
|
|
200
210
|
|
|
211
|
+
wordlist_sources = resolve_wordlist_sources(
|
|
212
|
+
self.options.gobuster,
|
|
213
|
+
self.options.wordlists,
|
|
214
|
+
)
|
|
215
|
+
if wordlist_sources:
|
|
216
|
+
if progress:
|
|
217
|
+
progress("loading wordlists…")
|
|
218
|
+
words = load_words(wordlist_sources)
|
|
219
|
+
extensions = tuple(self.options.wordlist_extensions)
|
|
220
|
+
found = await scan_wordlists(
|
|
221
|
+
self._client,
|
|
222
|
+
self.root_url,
|
|
223
|
+
words,
|
|
224
|
+
extensions=extensions,
|
|
225
|
+
concurrency=self.options.concurrency,
|
|
226
|
+
crawl_delay=self.options.crawl_delay,
|
|
227
|
+
robots=self._robots,
|
|
228
|
+
url_allowed=self._url_allowed,
|
|
229
|
+
log=self._log,
|
|
230
|
+
progress=progress,
|
|
231
|
+
)
|
|
232
|
+
self._wordlist_hits = len(found)
|
|
233
|
+
extra = await self._expand_metadata_urls(found)
|
|
234
|
+
seed_urls.extend(found)
|
|
235
|
+
seed_urls.extend(extra)
|
|
236
|
+
self._log(
|
|
237
|
+
f"wordlist: {len(found)} paths, {len(extra)} extra URLs from robots/sitemaps"
|
|
238
|
+
)
|
|
239
|
+
|
|
201
240
|
for url in seed_urls:
|
|
202
241
|
normalized = normalize_url(url)
|
|
203
242
|
if not normalized or not self._url_allowed(normalized):
|
|
@@ -216,6 +255,35 @@ class SnapshotEngine:
|
|
|
216
255
|
worker.cancel()
|
|
217
256
|
await asyncio.gather(*workers, return_exceptions=True)
|
|
218
257
|
|
|
258
|
+
async def _expand_metadata_urls(self, discovered: list[str]) -> list[str]:
|
|
259
|
+
"""Parse robots.txt and sitemap XML found during wordlist scanning."""
|
|
260
|
+
from snapshot.crawl_policy import parse_robots_sitemaps
|
|
261
|
+
|
|
262
|
+
extra: list[str] = []
|
|
263
|
+
seen: set[str] = set(discovered)
|
|
264
|
+
for url in discovered:
|
|
265
|
+
lower = url.lower()
|
|
266
|
+
try:
|
|
267
|
+
if lower.endswith("/robots.txt") or lower.endswith("robots.txt"):
|
|
268
|
+
response = await self._client.get(url)
|
|
269
|
+
if response.status_code == 200:
|
|
270
|
+
for sitemap_url in parse_robots_sitemaps(response.text):
|
|
271
|
+
if sitemap_url not in seen:
|
|
272
|
+
seen.add(sitemap_url)
|
|
273
|
+
extra.append(sitemap_url)
|
|
274
|
+
if "sitemap" in lower and (
|
|
275
|
+
lower.endswith(".xml") or lower.endswith(".xml.gz") or lower.endswith(".txt")
|
|
276
|
+
):
|
|
277
|
+
response = await self._client.get(url)
|
|
278
|
+
if response.status_code == 200:
|
|
279
|
+
for page_url in parse_sitemap_xml(response.text):
|
|
280
|
+
if page_url not in seen:
|
|
281
|
+
seen.add(page_url)
|
|
282
|
+
extra.append(page_url)
|
|
283
|
+
except Exception as exc: # noqa: BLE001
|
|
284
|
+
self._errors.append(f"{url}: {exc}")
|
|
285
|
+
return extra
|
|
286
|
+
|
|
219
287
|
async def _crawl_worker(
|
|
220
288
|
self,
|
|
221
289
|
queue: asyncio.Queue[tuple[str, int]],
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from importlib import resources
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from urllib.parse import urljoin, urlparse
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from snapshot.crawl_policy import RobotsChecker
|
|
12
|
+
|
|
13
|
+
BUILTIN_WORDLISTS = ("common", "large")
|
|
14
|
+
HIT_STATUS = {200, 201, 204, 301, 302, 307, 308, 401, 403}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def resolve_wordlist_sources(
|
|
18
|
+
gobuster: bool,
|
|
19
|
+
wordlist_args: list[str],
|
|
20
|
+
) -> list[Path | str]:
|
|
21
|
+
"""Return paths and/or builtin names to load."""
|
|
22
|
+
if wordlist_args:
|
|
23
|
+
return list(wordlist_args)
|
|
24
|
+
if gobuster:
|
|
25
|
+
return list(BUILTIN_WORDLISTS)
|
|
26
|
+
return []
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def load_words(sources: list[Path | str]) -> list[str]:
|
|
30
|
+
words: list[str] = []
|
|
31
|
+
seen: set[str] = set()
|
|
32
|
+
for source in sources:
|
|
33
|
+
for word in _load_source(source):
|
|
34
|
+
if word not in seen:
|
|
35
|
+
seen.add(word)
|
|
36
|
+
words.append(word)
|
|
37
|
+
return words
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _load_source(source: Path | str) -> list[str]:
|
|
41
|
+
name = str(source).strip()
|
|
42
|
+
if name in BUILTIN_WORDLISTS:
|
|
43
|
+
return _load_builtin(name)
|
|
44
|
+
path = Path(name)
|
|
45
|
+
if not path.is_file():
|
|
46
|
+
msg = f"wordlist not found: {name} (use common, large, or a file path)"
|
|
47
|
+
raise FileNotFoundError(msg)
|
|
48
|
+
return _parse_wordlist_text(path.read_text(encoding="utf-8", errors="replace"))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _load_builtin(name: str) -> list[str]:
|
|
52
|
+
text = resources.files("snapshot.wordlists").joinpath(f"{name}.txt").read_text(
|
|
53
|
+
encoding="utf-8"
|
|
54
|
+
)
|
|
55
|
+
return _parse_wordlist_text(text)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _parse_wordlist_text(text: str) -> list[str]:
|
|
59
|
+
words: list[str] = []
|
|
60
|
+
for line in text.splitlines():
|
|
61
|
+
stripped = line.strip()
|
|
62
|
+
if not stripped or stripped.startswith("#"):
|
|
63
|
+
continue
|
|
64
|
+
words.append(stripped.lstrip("/"))
|
|
65
|
+
return words
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def paths_for_word(word: str, extensions: tuple[str, ...]) -> list[str]:
|
|
69
|
+
"""Build URL path candidates for a single wordlist entry."""
|
|
70
|
+
cleaned = word.strip().strip("/")
|
|
71
|
+
if not cleaned:
|
|
72
|
+
return []
|
|
73
|
+
|
|
74
|
+
basename = cleaned.split("/")[-1]
|
|
75
|
+
paths = [f"/{cleaned}"]
|
|
76
|
+
|
|
77
|
+
if "." not in basename:
|
|
78
|
+
paths.append(f"/{cleaned}/")
|
|
79
|
+
for ext in extensions:
|
|
80
|
+
suffix = ext if ext.startswith(".") else f".{ext}"
|
|
81
|
+
paths.append(f"/{cleaned}{suffix}")
|
|
82
|
+
|
|
83
|
+
return list(dict.fromkeys(paths))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
async def scan_wordlists(
|
|
87
|
+
client: httpx.AsyncClient,
|
|
88
|
+
root_url: str,
|
|
89
|
+
words: list[str],
|
|
90
|
+
*,
|
|
91
|
+
extensions: tuple[str, ...],
|
|
92
|
+
concurrency: int,
|
|
93
|
+
crawl_delay: float,
|
|
94
|
+
robots: RobotsChecker,
|
|
95
|
+
url_allowed: Callable[[str], bool],
|
|
96
|
+
log: Callable[[str], None] | None = None,
|
|
97
|
+
progress: Callable[[str], None] | None = None,
|
|
98
|
+
) -> list[str]:
|
|
99
|
+
"""Probe wordlist paths and return URLs that appear to exist."""
|
|
100
|
+
parsed_root = urlparse(root_url)
|
|
101
|
+
base = f"{parsed_root.scheme}://{parsed_root.netloc}"
|
|
102
|
+
|
|
103
|
+
candidates: list[str] = []
|
|
104
|
+
seen_candidates: set[str] = set()
|
|
105
|
+
for word in words:
|
|
106
|
+
for path in paths_for_word(word, extensions):
|
|
107
|
+
absolute = normalize_probe_url(base, path)
|
|
108
|
+
if absolute is None or absolute in seen_candidates:
|
|
109
|
+
continue
|
|
110
|
+
if not url_allowed(absolute):
|
|
111
|
+
continue
|
|
112
|
+
seen_candidates.add(absolute)
|
|
113
|
+
candidates.append(absolute)
|
|
114
|
+
|
|
115
|
+
if not candidates:
|
|
116
|
+
return []
|
|
117
|
+
|
|
118
|
+
if progress:
|
|
119
|
+
progress(f"wordlist: probing {len(candidates)} paths…")
|
|
120
|
+
if log:
|
|
121
|
+
log(f"wordlist: probing {len(candidates)} paths from {len(words)} words")
|
|
122
|
+
|
|
123
|
+
sem = asyncio.Semaphore(max(1, concurrency))
|
|
124
|
+
found: list[str] = []
|
|
125
|
+
found_lock = asyncio.Lock()
|
|
126
|
+
probed = 0
|
|
127
|
+
|
|
128
|
+
async def probe(target: str) -> None:
|
|
129
|
+
nonlocal probed
|
|
130
|
+
if robots.enabled and not await robots.can_fetch(client, target):
|
|
131
|
+
return
|
|
132
|
+
async with sem:
|
|
133
|
+
hit = await _probe_url(client, target)
|
|
134
|
+
if crawl_delay > 0:
|
|
135
|
+
await asyncio.sleep(crawl_delay)
|
|
136
|
+
probed += 1
|
|
137
|
+
if probed % 250 == 0 and progress:
|
|
138
|
+
progress(f"wordlist: {probed}/{len(candidates)} probes, {len(found)} hits")
|
|
139
|
+
if hit:
|
|
140
|
+
async with found_lock:
|
|
141
|
+
found.append(target)
|
|
142
|
+
if log:
|
|
143
|
+
log(f"wordlist hit: {target}")
|
|
144
|
+
|
|
145
|
+
await asyncio.gather(*(probe(url) for url in candidates))
|
|
146
|
+
|
|
147
|
+
if log:
|
|
148
|
+
log(f"wordlist: {len(found)} paths found")
|
|
149
|
+
return sorted(found)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def normalize_probe_url(base: str, path: str) -> str | None:
|
|
153
|
+
joined = urljoin(f"{base}/", path.lstrip("/"))
|
|
154
|
+
parsed = urlparse(joined)
|
|
155
|
+
if not parsed.scheme or not parsed.netloc:
|
|
156
|
+
return None
|
|
157
|
+
return parsed._replace(fragment="").geturl()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
async def _probe_url(client: httpx.AsyncClient, url: str) -> bool:
|
|
161
|
+
for method in ("HEAD", "GET"):
|
|
162
|
+
try:
|
|
163
|
+
response = await client.request(method, url, follow_redirects=False)
|
|
164
|
+
except httpx.RequestError:
|
|
165
|
+
return False
|
|
166
|
+
if response.status_code in HIT_STATUS:
|
|
167
|
+
return True
|
|
168
|
+
if response.status_code == 405 and method == "HEAD":
|
|
169
|
+
continue
|
|
170
|
+
return False
|
|
171
|
+
return False
|
|
File without changes
|