web-snapshot-cli 0.2.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.
Files changed (24) hide show
  1. {web_snapshot_cli-0.2.0 → web_snapshot_cli-1.0.0}/PKG-INFO +15 -2
  2. {web_snapshot_cli-0.2.0 → web_snapshot_cli-1.0.0}/README.md +13 -0
  3. {web_snapshot_cli-0.2.0 → web_snapshot_cli-1.0.0}/pyproject.toml +6 -2
  4. {web_snapshot_cli-0.2.0 → web_snapshot_cli-1.0.0}/src/snapshot/__init__.py +1 -1
  5. {web_snapshot_cli-0.2.0 → web_snapshot_cli-1.0.0}/src/snapshot/cli.py +41 -2
  6. {web_snapshot_cli-0.2.0 → web_snapshot_cli-1.0.0}/src/snapshot/downloader.py +69 -1
  7. web_snapshot_cli-1.0.0/src/snapshot/wordlist.py +171 -0
  8. web_snapshot_cli-1.0.0/src/snapshot/wordlists/__init__.py +0 -0
  9. web_snapshot_cli-1.0.0/src/snapshot/wordlists/common.txt +132 -0
  10. web_snapshot_cli-1.0.0/src/snapshot/wordlists/large.txt +18637 -0
  11. web_snapshot_cli-1.0.0/tests/test_wordlist.py +32 -0
  12. {web_snapshot_cli-0.2.0 → web_snapshot_cli-1.0.0}/.github/workflows/ci.yml +0 -0
  13. {web_snapshot_cli-0.2.0 → web_snapshot_cli-1.0.0}/.github/workflows/publish.yml +0 -0
  14. {web_snapshot_cli-0.2.0 → web_snapshot_cli-1.0.0}/.gitignore +0 -0
  15. {web_snapshot_cli-0.2.0 → web_snapshot_cli-1.0.0}/LICENSE +0 -0
  16. {web_snapshot_cli-0.2.0 → web_snapshot_cli-1.0.0}/src/snapshot/__main__.py +0 -0
  17. {web_snapshot_cli-0.2.0 → web_snapshot_cli-1.0.0}/src/snapshot/crawl_policy.py +0 -0
  18. {web_snapshot_cli-0.2.0 → web_snapshot_cli-1.0.0}/src/snapshot/manifest.py +0 -0
  19. {web_snapshot_cli-0.2.0 → web_snapshot_cli-1.0.0}/src/snapshot/restore.py +0 -0
  20. {web_snapshot_cli-0.2.0 → web_snapshot_cli-1.0.0}/src/snapshot/utils.py +0 -0
  21. {web_snapshot_cli-0.2.0 → web_snapshot_cli-1.0.0}/tests/test_crawl_policy.py +0 -0
  22. {web_snapshot_cli-0.2.0 → web_snapshot_cli-1.0.0}/tests/test_crawler.py +0 -0
  23. {web_snapshot_cli-0.2.0 → web_snapshot_cli-1.0.0}/tests/test_features.py +0 -0
  24. {web_snapshot_cli-0.2.0 → web_snapshot_cli-1.0.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: 0.2.0
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 :: 4 - Beta
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
@@ -85,6 +85,16 @@ snapshot --cookie session=abc123 --header "Authorization: Bearer TOKEN" \
85
85
  snapshot --crawl --sitemap https://example.com ./mirror --max-pages 500
86
86
  ```
87
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
+
88
98
  ### Resume an interrupted snapshot
89
99
 
90
100
  ```bash
@@ -137,6 +147,9 @@ snapshot -restore ./mirror --port 3000 --no-open
137
147
  | `--robots` / `--no-robots` | Respect robots.txt (default: on) |
138
148
  | `--crawl-delay` | Seconds to wait after each request (default: 0) |
139
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`) |
140
153
  | `--resume` | Skip pages/assets already saved |
141
154
  | `--verbose`, `-v` | Detailed log output |
142
155
  | `--dry-run` | Fetch without writing files |
@@ -56,6 +56,16 @@ snapshot --cookie session=abc123 --header "Authorization: Bearer TOKEN" \
56
56
  snapshot --crawl --sitemap https://example.com ./mirror --max-pages 500
57
57
  ```
58
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
+
59
69
  ### Resume an interrupted snapshot
60
70
 
61
71
  ```bash
@@ -108,6 +118,9 @@ snapshot -restore ./mirror --port 3000 --no-open
108
118
  | `--robots` / `--no-robots` | Respect robots.txt (default: on) |
109
119
  | `--crawl-delay` | Seconds to wait after each request (default: 0) |
110
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`) |
111
124
  | `--resume` | Skip pages/assets already saved |
112
125
  | `--verbose`, `-v` | Detailed log output |
113
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 = "0.2.0"
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 :: 4 - Beta",
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"]
@@ -1,3 +1,3 @@
1
1
  """snapshot — offline website snapshots."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "1.0.0"
@@ -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=crawl or bool(extras.get("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")),
@@ -184,6 +194,23 @@ def _build_options(
184
194
  is_flag=True,
185
195
  help="Seed crawl URLs from sitemap.xml and robots.txt Sitemap directives.",
186
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
+ )
187
214
  @click.option(
188
215
  "--resume",
189
216
  is_flag=True,
@@ -222,6 +249,9 @@ def main(
222
249
  robots: bool,
223
250
  crawl_delay: float,
224
251
  use_sitemap: bool,
252
+ gobuster: bool,
253
+ wordlist: tuple[str, ...],
254
+ wordlist_ext: str,
225
255
  resume: bool,
226
256
  verbose: bool,
227
257
  dry_run: bool,
@@ -235,7 +265,8 @@ def main(
235
265
  Examples:
236
266
  snapshot https://example.com ./mirror
237
267
  snapshot --crawl https://docs.example.com ./docs --max-pages=200
238
- snapshot --crawl --sitemap --crawl-delay 1 https://example.com ./mirror
268
+ snapshot --crawl --gobuster --sitemap https://example.com ./mirror
269
+ snapshot --crawl -w common -w /path/to/SecLists.txt URL ./out
239
270
  snapshot --cookie session=abc --header "Authorization: Bearer x" URL ./out
240
271
  snapshot --crawl --include '/blog/*' --exclude '/blog/drafts/*' URL ./out
241
272
  snapshot --resume --crawl URL ./mirror
@@ -270,6 +301,9 @@ def main(
270
301
  respect_robots=robots,
271
302
  crawl_delay=crawl_delay,
272
303
  use_sitemap=use_sitemap,
304
+ gobuster=gobuster,
305
+ wordlists=wordlist,
306
+ wordlist_ext=wordlist_ext,
273
307
  resume=resume,
274
308
  verbose=verbose,
275
309
  dry_run=dry_run,
@@ -283,6 +317,9 @@ def main(
283
317
  console.print(f" crawl: up to {options.max_pages} pages, depth {options.max_depth}")
284
318
  if options.use_sitemap:
285
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)}")
286
323
  if options.resume:
287
324
  console.print(" resume: enabled")
288
325
  console.print(f" format: {options.lang}")
@@ -314,6 +351,8 @@ def main(
314
351
  console.print(
315
352
  f" skipped: {result.pages_skipped} pages, {result.assets_skipped} assets (resume)"
316
353
  )
354
+ if result.wordlist_hits:
355
+ console.print(f" wordlist: {result.wordlist_hits} paths discovered")
317
356
  if result.errors:
318
357
  console.print(f"[yellow]{len(result.errors)} errors[/yellow] (see below)")
319
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.2 (+https://github.com/codingsushi79/Snapshot)"
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
@@ -0,0 +1,132 @@
1
+ # Essential discovery paths (robots, sitemaps, well-known, admin)
2
+ robots.txt
3
+ sitemap.xml
4
+ sitemap_index.xml
5
+ sitemap-index.xml
6
+ sitemap1.xml
7
+ sitemap.txt
8
+ sitemap.gz
9
+ sitemap.xml.gz
10
+ sitemap_index.xml.gz
11
+ atom.xml
12
+ feed
13
+ rss
14
+ rss.xml
15
+ feed.xml
16
+ index.xml
17
+ crossdomain.xml
18
+ clientaccesspolicy.xml
19
+ favicon.ico
20
+ apple-touch-icon.png
21
+ .well-known/security.txt
22
+ .well-known/change-password
23
+ .well-known/assetlinks.json
24
+ .well-known/apple-app-site-association
25
+ .well-known/ai-plugin.json
26
+ .well-known/openid-configuration
27
+ .well-known/jwks.json
28
+ humans.txt
29
+ ads.txt
30
+ app-ads.txt
31
+ security.txt
32
+ browserconfig.xml
33
+ manifest.json
34
+ site.webmanifest
35
+ opensearch.xml
36
+ health
37
+ healthz
38
+ healthcheck
39
+ status
40
+ ping
41
+ version
42
+ api
43
+ api/v1
44
+ api/v2
45
+ graphql
46
+ swagger
47
+ swagger.json
48
+ swagger-ui
49
+ swagger-ui.html
50
+ openapi.json
51
+ docs
52
+ documentation
53
+ redoc
54
+ admin
55
+ administrator
56
+ login
57
+ logout
58
+ signin
59
+ signup
60
+ register
61
+ auth
62
+ oauth
63
+ dashboard
64
+ portal
65
+ console
66
+ panel
67
+ cpanel
68
+ webmail
69
+ phpmyadmin
70
+ wp-admin
71
+ wp-login.php
72
+ wp-json
73
+ xmlrpc.php
74
+ .git
75
+ .git/config
76
+ .git/HEAD
77
+ .env
78
+ .env.local
79
+ .env.production
80
+ backup
81
+ backups
82
+ old
83
+ test
84
+ dev
85
+ staging
86
+ tmp
87
+ temp
88
+ upload
89
+ uploads
90
+ files
91
+ assets
92
+ static
93
+ public
94
+ media
95
+ images
96
+ img
97
+ css
98
+ js
99
+ javascript
100
+ dist
101
+ build
102
+ cache
103
+ config
104
+ settings
105
+ search
106
+ archive
107
+ archives
108
+ blog
109
+ news
110
+ press
111
+ about
112
+ about-us
113
+ contact
114
+ contact-us
115
+ help
116
+ support
117
+ faq
118
+ privacy
119
+ terms
120
+ legal
121
+ sitemap
122
+ sitemaps
123
+ sitemap-index
124
+ sitemap_index
125
+ sitemapindex
126
+ sitemap.php
127
+ sitemap.html
128
+ sitemap.asp
129
+ sitemap.aspx
130
+ robots
131
+ robots.php
132
+ robots.html