199 lines
8.6 KiB
Python
199 lines
8.6 KiB
Python
#!/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_<hash>.css`
|
||
(так сохраняет downloader: `website_downloader.py`)
|
||
|
||
Использование
|
||
=============
|
||
python serve_site.py <URL-or-folder>
|
||
|
||
Примеры:
|
||
python serve_site.py https://www.jerimybrown.com/
|
||
python serve_site.py jerimybrown.com
|
||
python serve_site.py d:\\PROJECTS\\Fofanov\\DFWebsite_Downloader\\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_<hash>.css
|
||
# Downloader сохраняет такие файлы через суффикс __q_<hash>.
|
||
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_<hash>.
|
||
# Полезно, когда:
|
||
# - исходный URL был /demo/550728.html?....
|
||
# - на диск попало demo/550728__q_<hash>.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()
|