web-snapshot-cli 0.1.0__py3-none-any.whl
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.
- snapshot/__init__.py +3 -0
- snapshot/__main__.py +4 -0
- snapshot/cli.py +325 -0
- snapshot/crawl_policy.py +156 -0
- snapshot/downloader.py +483 -0
- snapshot/manifest.py +50 -0
- snapshot/restore.py +134 -0
- snapshot/utils.py +153 -0
- web_snapshot_cli-0.1.0.dist-info/METADATA +204 -0
- web_snapshot_cli-0.1.0.dist-info/RECORD +13 -0
- web_snapshot_cli-0.1.0.dist-info/WHEEL +4 -0
- web_snapshot_cli-0.1.0.dist-info/entry_points.txt +2 -0
- web_snapshot_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
snapshot/__init__.py
ADDED
snapshot/__main__.py
ADDED
snapshot/cli.py
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
9
|
+
|
|
10
|
+
from snapshot import __version__
|
|
11
|
+
from snapshot.downloader import SnapshotEngine, SnapshotOptions
|
|
12
|
+
from snapshot.restore import serve_snapshot
|
|
13
|
+
|
|
14
|
+
console = Console()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _parse_extra_args(extra: tuple[str, ...]) -> dict:
|
|
18
|
+
"""Parse key=value extra args like crawl=true max-pages=100."""
|
|
19
|
+
parsed: dict = {}
|
|
20
|
+
for item in extra:
|
|
21
|
+
if "=" not in item:
|
|
22
|
+
parsed[item.replace("-", "_")] = True
|
|
23
|
+
continue
|
|
24
|
+
key, value = item.split("=", 1)
|
|
25
|
+
key = key.lstrip("-").replace("-", "_")
|
|
26
|
+
lowered = value.lower()
|
|
27
|
+
if lowered in {"true", "yes", "1"}:
|
|
28
|
+
parsed[key] = True
|
|
29
|
+
elif lowered in {"false", "no", "0"}:
|
|
30
|
+
parsed[key] = False
|
|
31
|
+
else:
|
|
32
|
+
try:
|
|
33
|
+
parsed[key] = int(value)
|
|
34
|
+
except ValueError:
|
|
35
|
+
try:
|
|
36
|
+
parsed[key] = float(value)
|
|
37
|
+
except ValueError:
|
|
38
|
+
parsed[key] = value
|
|
39
|
+
return parsed
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _parse_list_value(value: object) -> list[str]:
|
|
43
|
+
if value is None:
|
|
44
|
+
return []
|
|
45
|
+
if isinstance(value, list):
|
|
46
|
+
return value
|
|
47
|
+
if isinstance(value, str):
|
|
48
|
+
return [part.strip() for part in value.split(",") if part.strip()]
|
|
49
|
+
return []
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _build_options(
|
|
53
|
+
extras: dict,
|
|
54
|
+
*,
|
|
55
|
+
crawl: bool,
|
|
56
|
+
lang: str,
|
|
57
|
+
max_pages: int,
|
|
58
|
+
depth: int,
|
|
59
|
+
no_assets: bool,
|
|
60
|
+
timeout: float,
|
|
61
|
+
concurrency: int,
|
|
62
|
+
same_origin: bool,
|
|
63
|
+
user_agent: str | None,
|
|
64
|
+
cookies: tuple[str, ...],
|
|
65
|
+
headers: tuple[str, ...],
|
|
66
|
+
include: tuple[str, ...],
|
|
67
|
+
exclude: tuple[str, ...],
|
|
68
|
+
respect_robots: bool,
|
|
69
|
+
crawl_delay: float,
|
|
70
|
+
use_sitemap: bool,
|
|
71
|
+
resume: bool,
|
|
72
|
+
verbose: bool,
|
|
73
|
+
dry_run: bool,
|
|
74
|
+
) -> SnapshotOptions:
|
|
75
|
+
default_ua = SnapshotOptions().user_agent
|
|
76
|
+
return SnapshotOptions(
|
|
77
|
+
crawl=crawl or bool(extras.get("crawl")),
|
|
78
|
+
max_pages=int(extras.get("max_pages", max_pages)),
|
|
79
|
+
max_depth=int(extras.get("depth", depth)),
|
|
80
|
+
lang=str(extras.get("lang", lang)),
|
|
81
|
+
download_assets=not no_assets and not extras.get("no_assets"),
|
|
82
|
+
timeout=float(extras.get("timeout", timeout)),
|
|
83
|
+
concurrency=int(extras.get("concurrency", concurrency)),
|
|
84
|
+
same_origin_only=bool(extras.get("same_origin", same_origin)),
|
|
85
|
+
user_agent=str(extras.get("user_agent", user_agent or default_ua)),
|
|
86
|
+
cookies=list(cookies) + _parse_list_value(extras.get("cookie")),
|
|
87
|
+
headers=list(headers) + _parse_list_value(extras.get("header")),
|
|
88
|
+
include_patterns=list(include) + _parse_list_value(extras.get("include")),
|
|
89
|
+
exclude_patterns=list(exclude) + _parse_list_value(extras.get("exclude")),
|
|
90
|
+
respect_robots=bool(extras.get("respect_robots", respect_robots)),
|
|
91
|
+
crawl_delay=float(extras.get("crawl_delay", crawl_delay)),
|
|
92
|
+
use_sitemap=use_sitemap or bool(extras.get("sitemap")),
|
|
93
|
+
resume=resume or bool(extras.get("resume")),
|
|
94
|
+
verbose=verbose or bool(extras.get("verbose")),
|
|
95
|
+
dry_run=dry_run or bool(extras.get("dry_run")),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@click.command(
|
|
100
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
101
|
+
add_help_option=True,
|
|
102
|
+
)
|
|
103
|
+
@click.argument("url", required=False)
|
|
104
|
+
@click.argument("output_dir", required=False, type=click.Path())
|
|
105
|
+
@click.argument("extra_args", nargs=-1)
|
|
106
|
+
@click.option(
|
|
107
|
+
"-restore",
|
|
108
|
+
"restore_dir",
|
|
109
|
+
type=click.Path(exists=True, file_okay=False),
|
|
110
|
+
help="Serve a saved snapshot from DIR.",
|
|
111
|
+
)
|
|
112
|
+
@click.option(
|
|
113
|
+
"--crawl",
|
|
114
|
+
"-c",
|
|
115
|
+
is_flag=True,
|
|
116
|
+
help="Crawl same-origin pages and download them all.",
|
|
117
|
+
)
|
|
118
|
+
@click.option(
|
|
119
|
+
"--lang",
|
|
120
|
+
"-l",
|
|
121
|
+
type=click.Choice(["html", "md"]),
|
|
122
|
+
default="html",
|
|
123
|
+
show_default=True,
|
|
124
|
+
help="Output format for saved pages.",
|
|
125
|
+
)
|
|
126
|
+
@click.option(
|
|
127
|
+
"--max-pages",
|
|
128
|
+
type=int,
|
|
129
|
+
default=50,
|
|
130
|
+
show_default=True,
|
|
131
|
+
help="Maximum pages when crawling.",
|
|
132
|
+
)
|
|
133
|
+
@click.option("--depth", type=int, default=3, show_default=True, help="Maximum crawl depth.")
|
|
134
|
+
@click.option("--no-assets", is_flag=True, help="Skip downloading CSS, JS, images, and fonts.")
|
|
135
|
+
@click.option(
|
|
136
|
+
"--timeout", type=float, default=15.0, show_default=True, help="HTTP timeout in seconds."
|
|
137
|
+
)
|
|
138
|
+
@click.option(
|
|
139
|
+
"--concurrency", type=int, default=16, show_default=True, help="Parallel download workers."
|
|
140
|
+
)
|
|
141
|
+
@click.option(
|
|
142
|
+
"--same-origin/--no-same-origin",
|
|
143
|
+
default=True,
|
|
144
|
+
show_default=True,
|
|
145
|
+
help="Only follow links on the same origin when crawling.",
|
|
146
|
+
)
|
|
147
|
+
@click.option("--user-agent", default=None, help="Custom User-Agent header.")
|
|
148
|
+
@click.option(
|
|
149
|
+
"--cookie",
|
|
150
|
+
multiple=True,
|
|
151
|
+
help="Cookie as name=value (repeatable). Example: --cookie session=abc123",
|
|
152
|
+
)
|
|
153
|
+
@click.option(
|
|
154
|
+
"--header",
|
|
155
|
+
multiple=True,
|
|
156
|
+
help='Extra HTTP header (repeatable). Example: --header "Authorization: Bearer token"',
|
|
157
|
+
)
|
|
158
|
+
@click.option(
|
|
159
|
+
"--include",
|
|
160
|
+
multiple=True,
|
|
161
|
+
help="Only crawl/fetch URLs matching this glob (repeatable). Example: --include '/docs/*'",
|
|
162
|
+
)
|
|
163
|
+
@click.option(
|
|
164
|
+
"--exclude",
|
|
165
|
+
multiple=True,
|
|
166
|
+
help="Skip URLs matching this glob (repeatable). Example: --exclude '/admin/*'",
|
|
167
|
+
)
|
|
168
|
+
@click.option(
|
|
169
|
+
"--robots/--no-robots",
|
|
170
|
+
default=True,
|
|
171
|
+
show_default=True,
|
|
172
|
+
help="Respect robots.txt when crawling.",
|
|
173
|
+
)
|
|
174
|
+
@click.option(
|
|
175
|
+
"--crawl-delay",
|
|
176
|
+
type=float,
|
|
177
|
+
default=0.0,
|
|
178
|
+
show_default=True,
|
|
179
|
+
help="Seconds to wait after each HTTP request.",
|
|
180
|
+
)
|
|
181
|
+
@click.option(
|
|
182
|
+
"--sitemap",
|
|
183
|
+
is_flag=True,
|
|
184
|
+
help="Seed crawl URLs from sitemap.xml and robots.txt Sitemap directives.",
|
|
185
|
+
)
|
|
186
|
+
@click.option(
|
|
187
|
+
"--resume",
|
|
188
|
+
is_flag=True,
|
|
189
|
+
help="Resume a previous snapshot; skip pages and assets already on disk.",
|
|
190
|
+
)
|
|
191
|
+
@click.option("--verbose", "-v", is_flag=True, help="Print detailed progress messages.")
|
|
192
|
+
@click.option(
|
|
193
|
+
"--dry-run",
|
|
194
|
+
is_flag=True,
|
|
195
|
+
help="Discover and fetch without writing files or updating the manifest.",
|
|
196
|
+
)
|
|
197
|
+
@click.option("--port", type=int, default=8080, show_default=True, help="Port for -restore.")
|
|
198
|
+
@click.option("--host", default="127.0.0.1", show_default=True, help="Host for -restore.")
|
|
199
|
+
@click.option("--no-open", is_flag=True, help="Do not open a browser when restoring.")
|
|
200
|
+
@click.version_option(__version__, prog_name="snapshot")
|
|
201
|
+
def main(
|
|
202
|
+
url: str | None,
|
|
203
|
+
output_dir: str | None,
|
|
204
|
+
extra_args: tuple[str, ...],
|
|
205
|
+
restore_dir: str | None,
|
|
206
|
+
crawl: bool,
|
|
207
|
+
lang: str,
|
|
208
|
+
max_pages: int,
|
|
209
|
+
depth: int,
|
|
210
|
+
no_assets: bool,
|
|
211
|
+
timeout: float,
|
|
212
|
+
concurrency: int,
|
|
213
|
+
same_origin: bool,
|
|
214
|
+
user_agent: str | None,
|
|
215
|
+
cookie: tuple[str, ...],
|
|
216
|
+
header: tuple[str, ...],
|
|
217
|
+
include: tuple[str, ...],
|
|
218
|
+
exclude: tuple[str, ...],
|
|
219
|
+
robots: bool,
|
|
220
|
+
crawl_delay: float,
|
|
221
|
+
use_sitemap: bool,
|
|
222
|
+
resume: bool,
|
|
223
|
+
verbose: bool,
|
|
224
|
+
dry_run: bool,
|
|
225
|
+
port: int,
|
|
226
|
+
host: str,
|
|
227
|
+
no_open: bool,
|
|
228
|
+
) -> None:
|
|
229
|
+
"""Take offline snapshots of websites and restore them locally.
|
|
230
|
+
|
|
231
|
+
\b
|
|
232
|
+
Examples:
|
|
233
|
+
snapshot https://example.com ./mirror
|
|
234
|
+
snapshot --crawl https://docs.example.com ./docs --max-pages=200
|
|
235
|
+
snapshot --crawl --sitemap --crawl-delay 1 https://example.com ./mirror
|
|
236
|
+
snapshot --cookie session=abc --header "Authorization: Bearer x" URL ./out
|
|
237
|
+
snapshot --crawl --include '/blog/*' --exclude '/blog/drafts/*' URL ./out
|
|
238
|
+
snapshot --resume --crawl URL ./mirror
|
|
239
|
+
snapshot --dry-run --verbose --crawl URL ./mirror
|
|
240
|
+
snapshot -restore ./mirror
|
|
241
|
+
"""
|
|
242
|
+
extras = _parse_extra_args(extra_args)
|
|
243
|
+
if restore_dir:
|
|
244
|
+
serve_snapshot(Path(restore_dir), host=host, port=port, open_browser=not no_open)
|
|
245
|
+
return
|
|
246
|
+
|
|
247
|
+
if not url or not output_dir:
|
|
248
|
+
console.print("[red]Usage:[/red] snapshot [OPTIONS] URL OUTPUT_DIR [extra args]")
|
|
249
|
+
console.print(" snapshot -restore DIR")
|
|
250
|
+
raise SystemExit(1)
|
|
251
|
+
|
|
252
|
+
options = _build_options(
|
|
253
|
+
extras,
|
|
254
|
+
crawl=crawl,
|
|
255
|
+
lang=lang,
|
|
256
|
+
max_pages=max_pages,
|
|
257
|
+
depth=depth,
|
|
258
|
+
no_assets=no_assets,
|
|
259
|
+
timeout=timeout,
|
|
260
|
+
concurrency=concurrency,
|
|
261
|
+
same_origin=same_origin,
|
|
262
|
+
user_agent=user_agent,
|
|
263
|
+
cookies=cookie,
|
|
264
|
+
headers=header,
|
|
265
|
+
include=include,
|
|
266
|
+
exclude=exclude,
|
|
267
|
+
respect_robots=robots,
|
|
268
|
+
crawl_delay=crawl_delay,
|
|
269
|
+
use_sitemap=use_sitemap,
|
|
270
|
+
resume=resume,
|
|
271
|
+
verbose=verbose,
|
|
272
|
+
dry_run=dry_run,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
out = Path(output_dir)
|
|
276
|
+
console.print(f"[bold]snapshot[/bold] {url} → {out.resolve()}")
|
|
277
|
+
if options.dry_run:
|
|
278
|
+
console.print(" [yellow]dry-run[/yellow]: no files will be written")
|
|
279
|
+
if options.crawl:
|
|
280
|
+
console.print(f" crawl: up to {options.max_pages} pages, depth {options.max_depth}")
|
|
281
|
+
if options.use_sitemap:
|
|
282
|
+
console.print(" sitemap: enabled")
|
|
283
|
+
if options.resume:
|
|
284
|
+
console.print(" resume: enabled")
|
|
285
|
+
console.print(f" format: {options.lang}")
|
|
286
|
+
|
|
287
|
+
def on_log(message: str) -> None:
|
|
288
|
+
if options.verbose:
|
|
289
|
+
console.print(f"[dim]{message}[/dim]")
|
|
290
|
+
|
|
291
|
+
with Progress(
|
|
292
|
+
SpinnerColumn(),
|
|
293
|
+
TextColumn("[progress.description]{task.description}"),
|
|
294
|
+
console=console,
|
|
295
|
+
transient=not options.verbose,
|
|
296
|
+
) as progress:
|
|
297
|
+
task = progress.add_task("starting…", total=None)
|
|
298
|
+
|
|
299
|
+
def on_progress(message: str) -> None:
|
|
300
|
+
progress.update(task, description=message)
|
|
301
|
+
|
|
302
|
+
engine = SnapshotEngine(url, out, options)
|
|
303
|
+
result = asyncio.run(engine.run(progress=on_progress, log=on_log))
|
|
304
|
+
|
|
305
|
+
label = "Would save" if options.dry_run else "Done"
|
|
306
|
+
console.print(
|
|
307
|
+
f"[green]{label}.[/green] {result.pages_saved} pages, "
|
|
308
|
+
f"{result.assets_saved} assets → {out.resolve()}"
|
|
309
|
+
)
|
|
310
|
+
if result.pages_skipped or result.assets_skipped:
|
|
311
|
+
console.print(
|
|
312
|
+
f" skipped: {result.pages_skipped} pages, {result.assets_skipped} assets (resume)"
|
|
313
|
+
)
|
|
314
|
+
if result.errors:
|
|
315
|
+
console.print(f"[yellow]{len(result.errors)} errors[/yellow] (see below)")
|
|
316
|
+
for err in result.errors[:10]:
|
|
317
|
+
console.print(f" • {err}")
|
|
318
|
+
if len(result.errors) > 10:
|
|
319
|
+
console.print(f" … and {len(result.errors) - 10} more")
|
|
320
|
+
if not options.dry_run:
|
|
321
|
+
console.print(f"Restore with: [cyan]snapshot -restore {out}[/cyan]")
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
if __name__ == "__main__":
|
|
325
|
+
main()
|
snapshot/crawl_policy.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import fnmatch
|
|
4
|
+
import xml.etree.ElementTree as ET
|
|
5
|
+
from urllib.parse import urlparse
|
|
6
|
+
from urllib.robotparser import RobotFileParser
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
SITEMAP_NS = {"sm": "http://www.sitemaps.org/schemas/sitemap/0.9"}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def url_matches_filters(url: str, includes: list[str], excludes: list[str]) -> bool:
|
|
14
|
+
"""Return True when a URL passes include/exclude glob patterns."""
|
|
15
|
+
if excludes and any(_match_pattern(url, pattern) for pattern in excludes):
|
|
16
|
+
return False
|
|
17
|
+
if includes:
|
|
18
|
+
return any(_match_pattern(url, pattern) for pattern in includes)
|
|
19
|
+
return True
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _match_pattern(url: str, pattern: str) -> bool:
|
|
23
|
+
parsed = urlparse(url)
|
|
24
|
+
candidates = [
|
|
25
|
+
url,
|
|
26
|
+
parsed.path,
|
|
27
|
+
f"{parsed.netloc}{parsed.path}",
|
|
28
|
+
f"{parsed.scheme}://{parsed.netloc}{parsed.path}",
|
|
29
|
+
]
|
|
30
|
+
return any(fnmatch.fnmatchcase(candidate, pattern) for candidate in candidates)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def parse_robots_sitemaps(text: str) -> list[str]:
|
|
34
|
+
sitemaps: list[str] = []
|
|
35
|
+
for line in text.splitlines():
|
|
36
|
+
stripped = line.strip()
|
|
37
|
+
if stripped.lower().startswith("sitemap:"):
|
|
38
|
+
sitemaps.append(stripped.split(":", 1)[1].strip())
|
|
39
|
+
return sitemaps
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def parse_sitemap_xml(xml_text: str) -> list[str]:
|
|
43
|
+
"""Extract <loc> URLs from a sitemap or sitemap index document."""
|
|
44
|
+
try:
|
|
45
|
+
root = ET.fromstring(xml_text)
|
|
46
|
+
except ET.ParseError:
|
|
47
|
+
return []
|
|
48
|
+
|
|
49
|
+
tag = root.tag.rsplit("}", 1)[-1].lower()
|
|
50
|
+
if tag == "sitemapindex":
|
|
51
|
+
return _collect_locs(root)
|
|
52
|
+
|
|
53
|
+
if tag == "urlset":
|
|
54
|
+
return _collect_locs(root)
|
|
55
|
+
|
|
56
|
+
return _collect_locs(root)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _collect_locs(root: ET.Element) -> list[str]:
|
|
60
|
+
urls: list[str] = []
|
|
61
|
+
for loc in root.findall(".//sm:loc", SITEMAP_NS):
|
|
62
|
+
if loc.text:
|
|
63
|
+
urls.append(loc.text.strip())
|
|
64
|
+
if not urls:
|
|
65
|
+
for loc in root.findall(".//loc"):
|
|
66
|
+
if loc.text:
|
|
67
|
+
urls.append(loc.text.strip())
|
|
68
|
+
return urls
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def origin_url(url: str) -> str:
|
|
72
|
+
parsed = urlparse(url)
|
|
73
|
+
return f"{parsed.scheme}://{parsed.netloc}"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class RobotsChecker:
|
|
77
|
+
def __init__(self, user_agent: str, enabled: bool = True):
|
|
78
|
+
self.user_agent = user_agent
|
|
79
|
+
self.enabled = enabled
|
|
80
|
+
self._parsers: dict[str, RobotFileParser | None] = {}
|
|
81
|
+
self.sitemap_urls: list[str] = []
|
|
82
|
+
|
|
83
|
+
async def can_fetch(self, client: httpx.AsyncClient, url: str) -> bool:
|
|
84
|
+
if not self.enabled:
|
|
85
|
+
return True
|
|
86
|
+
parser = await self._get_parser(client, origin_url(url))
|
|
87
|
+
if parser is None:
|
|
88
|
+
return True
|
|
89
|
+
return parser.can_fetch(self.user_agent, url)
|
|
90
|
+
|
|
91
|
+
async def _get_parser(self, client: httpx.AsyncClient, origin: str) -> RobotFileParser | None:
|
|
92
|
+
if origin in self._parsers:
|
|
93
|
+
return self._parsers[origin]
|
|
94
|
+
|
|
95
|
+
robots_url = f"{origin}/robots.txt"
|
|
96
|
+
try:
|
|
97
|
+
response = await client.get(robots_url)
|
|
98
|
+
if response.status_code != 200:
|
|
99
|
+
self._parsers[origin] = None
|
|
100
|
+
return None
|
|
101
|
+
parser = RobotFileParser()
|
|
102
|
+
parser.parse(response.text.splitlines())
|
|
103
|
+
self.sitemap_urls.extend(parse_robots_sitemaps(response.text))
|
|
104
|
+
self._parsers[origin] = parser
|
|
105
|
+
return parser
|
|
106
|
+
except Exception: # noqa: BLE001
|
|
107
|
+
self._parsers[origin] = None
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
async def discover_sitemap_urls(
|
|
112
|
+
client: httpx.AsyncClient,
|
|
113
|
+
root_url: str,
|
|
114
|
+
robots_checker: RobotsChecker,
|
|
115
|
+
) -> list[str]:
|
|
116
|
+
"""Discover page URLs from sitemap.xml and robots.txt Sitemap directives."""
|
|
117
|
+
origin = origin_url(root_url)
|
|
118
|
+
await robots_checker._get_parser(client, origin)
|
|
119
|
+
|
|
120
|
+
candidates = [f"{origin}/sitemap.xml", *robots_checker.sitemap_urls]
|
|
121
|
+
seen_sitemaps: set[str] = set()
|
|
122
|
+
page_urls: list[str] = []
|
|
123
|
+
|
|
124
|
+
for sitemap_url in candidates:
|
|
125
|
+
await _collect_sitemap_urls(client, sitemap_url, seen_sitemaps, page_urls)
|
|
126
|
+
|
|
127
|
+
return list(dict.fromkeys(page_urls))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
async def _collect_sitemap_urls(
|
|
131
|
+
client: httpx.AsyncClient,
|
|
132
|
+
sitemap_url: str,
|
|
133
|
+
seen_sitemaps: set[str],
|
|
134
|
+
page_urls: list[str],
|
|
135
|
+
) -> None:
|
|
136
|
+
if sitemap_url in seen_sitemaps:
|
|
137
|
+
return
|
|
138
|
+
seen_sitemaps.add(sitemap_url)
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
response = await client.get(sitemap_url)
|
|
142
|
+
if response.status_code != 200:
|
|
143
|
+
return
|
|
144
|
+
locs = parse_sitemap_xml(response.text)
|
|
145
|
+
except Exception: # noqa: BLE001
|
|
146
|
+
return
|
|
147
|
+
|
|
148
|
+
if not locs:
|
|
149
|
+
return
|
|
150
|
+
|
|
151
|
+
if any(loc.endswith(".xml") for loc in locs[:3]):
|
|
152
|
+
for loc in locs:
|
|
153
|
+
await _collect_sitemap_urls(client, loc, seen_sitemaps, page_urls)
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
page_urls.extend(locs)
|