Files

199 lines
8.6 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()