#!/usr/bin/env python3 # Лицензия: см. файл LICENSE в корне проекта. # Использование/копирование/модификация/распространение допускаются # только при явном письменном разрешении автора. """Локальный статический сервер для скачанного сайта. Зачем он нужен ============== Скачанные сайты часто используют: - «красивые» URL без расширения `.html` (например `/lab` вместо `/lab.html`) - cache-busting query параметры у ресурсов (например `main.css?v=123`) Если раздавать папку обычным `http.server` или просто открыть файл в браузере, можно получить 404, потому что сервер не умеет автоматически подставлять `.html` и не умеет сопоставлять `?v=...` с реально сохранёнными файлами. Что делает этот сервер ====================== 1) Раздаёт выбранную папку сайта на `http://127.0.0.1:8080/`. 2) Поддерживает pretty URL fallback: - `/lab` -> `/lab.html` - `/foo/` -> `/foo/index.html` 3) Поддерживает ресурсы с query-параметрами: - `/assets/app.css?v=123` -> `/assets/app__q_.css` (так сохраняет downloader: `website_downloader.py`) Использование ============= python serve_site.py Примеры: python serve_site.py https://www.jerimybrown.com/ python serve_site.py jerimybrown.com python serve_site.py d:\\PROJECTS\\Fofanov\\SiteArchive\\jerimybrown.com Остановка: Ctrl+C """ from __future__ import annotations import argparse import hashlib import os import posixpath from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler from pathlib import Path from urllib.parse import urlparse, unquote def _candidate_site_dirs(base_dir: Path, url_or_folder: str) -> list[Path]: """Вернуть список кандидатов директорий скачанного сайта. Поддерживаем несколько вариантов ввода: - абсолютный/относительный путь до папки (если существует) - URL (по нему берём netloc и пробуем найти папку с таким именем) - просто имя папки относительно base_dir Подбираем варианты с/без `www.`, т.к. downloader обычно создаёт папку по netloc. """ p = Path(url_or_folder) if p.exists() and p.is_dir(): return [p.resolve()] # Считаем ввод URL-ом, если есть scheme и netloc. parsed = urlparse(url_or_folder) if parsed.scheme and parsed.netloc: netloc = parsed.netloc if ":" in netloc: netloc = netloc.split(":", 1)[0] candidates = [ base_dir / netloc, ] if netloc.startswith("www."): candidates.append(base_dir / netloc.removeprefix("www.")) else: candidates.append(base_dir / f"www.{netloc}") # Частый случай: пользователь назвал папку без www candidates.append(base_dir / netloc.replace("www.", "", 1)) return candidates # Иначе считаем, что это имя папки относительно base_dir. return [base_dir / url_or_folder] def _resolve_site_dir(base_dir: Path, url_or_folder: str) -> Path: """Выбрать первую существующую директорию сайта из списка кандидатов.""" for candidate in _candidate_site_dirs(base_dir, url_or_folder): if candidate.exists() and candidate.is_dir(): return candidate.resolve() attempted = "\n".join(str(p) for p in _candidate_site_dirs(base_dir, url_or_folder)) raise SystemExit(f"Не найдена папка сайта. Пробовал:\n{attempted}") class PrettyURLHandler(SimpleHTTPRequestHandler): """HTTP handler с поддержкой "pretty URL" и cache-busting query. Ключевой метод — translate_path(): он превращает URL запроса в путь к файлу на диске. """ def __init__(self, *args, directory: str | None = None, **kwargs): super().__init__(*args, directory=directory, **kwargs) def translate_path(self, path: str) -> str: # Базовая реализация SimpleHTTPRequestHandler уже мапит URL -> файл. # Мы используем её как основу, но дальше добавляем свои правила. base = Path(super().translate_path(path)) # Убираем query/fragment и декодируем %XX. parsed = urlparse(path) request_path = unquote(parsed.path) # Нормализуем путь и делаем его относительным. # Это защищает от странных конструкций вида /a/../b. request_path = posixpath.normpath(request_path) while request_path.startswith("/"): request_path = request_path[1:] root = Path(self.directory or os.getcwd()) target = (root / request_path).resolve() # Cache-busting query support: /file.css?v=123 -> /file__q_.css # Downloader сохраняет такие файлы через суффикс __q_. if parsed.query: qhash = hashlib.md5(parsed.query.encode('utf-8', errors='ignore')).hexdigest()[:10] if target.suffix: candidate = target.with_name(f"{target.stem}__q_{qhash}{target.suffix}") if candidate.exists(): return str(candidate) # Если файл/папка существуют как есть — отдаём напрямую. if target.exists(): return str(target) # Fallback для файлов, сохранённых downloader-ом с __q_. # Полезно, когда: # - исходный URL был /demo/550728.html?.... # - на диск попало demo/550728__q_.html # - в браузере вы открываете /demo/550728.html (без query) if target.suffix: pattern = f"{target.stem}__q_*{target.suffix}" matches = sorted(target.parent.glob(pattern)) if matches: return str(matches[0]) # Directory fallback: /foo/ -> /foo/index.html if parsed.path.endswith("/"): candidate = (root / request_path / "index.html").resolve() if candidate.exists(): return str(candidate) # Pretty URL fallback: /lab -> /lab.html # Условие "нет точки в имени" — чтобы не ломать /assets/app.css if request_path and "." not in Path(request_path).name: candidate = (root / f"{request_path}.html").resolve() if candidate.exists(): return str(candidate) # И сюда тоже добавим fallback на __q_* (редкий, но возможный случай). pattern = f"{candidate.stem}__q_*{candidate.suffix}" matches = sorted(candidate.parent.glob(pattern)) if matches: return str(matches[0]) # Если ничего не подошло — пусть SimpleHTTPRequestHandler решает, # вернётся 404 если файла нет. return str(target) def main() -> None: """CLI-точка входа.""" parser = argparse.ArgumentParser(description="Serve downloaded site on http://localhost:8080/") parser.add_argument("url_or_folder", help="URL (to infer folder) or local folder path") args = parser.parse_args() base_dir = Path(__file__).resolve().parent site_dir = _resolve_site_dir(base_dir, args.url_or_folder) host = "127.0.0.1" port = 8080 handler = lambda *h_args, **h_kwargs: PrettyURLHandler(*h_args, directory=str(site_dir), **h_kwargs) httpd = ThreadingHTTPServer((host, port), handler) print(f"Serving: {site_dir}") print(f"URL: http://{host}:{port}/") print("Stop: Ctrl+C") try: httpd.serve_forever() except KeyboardInterrupt: pass if __name__ == "__main__": main()