Добавление загрузчика веб-сайтов для сбора данных с сайтов в автономном режиме с извлечением ресурсов.

This commit is contained in:
Dmitriy Fofanov
2026-02-22 01:39:54 +03:00
parent 845d2e8bd5
commit 81cc5b74ad
7 changed files with 1528 additions and 0 deletions
+28
View File
@@ -0,0 +1,28 @@
# Python cache and bytecode
__pycache__/
*.py[cod]
*$py.class
# Virtual environments
.venv/
venv/
env/
ENV/
# Build and packaging artifacts
build/
dist/
*.egg-info/
# Tool caches
.pytest_cache/
.mypy_cache/
.ruff_cache/
# IDE/editor
.vscode/
.idea/
# OS files
.DS_Store
Thumbs.db
+38
View File
@@ -0,0 +1,38 @@
PERMISSION REQUIRED LICENSE (PRL) v1.0
Copyright (c) 2025, Dmitry Fofanov ("Автор")
All rights reserved.
КРАТКОЕ РЕЗЮМЕ (не является частью лицензии):
Этот код можно использовать только если Автор дал вам явное письменное разрешение.
1. Определения
"Код" — любые исходные тексты, скрипты, файлы и материалы, размещённые в этом репозитории.
"Разрешение" — явное письменное согласие Автора (включая e-mail/мессенджер),
содержащее как минимум: кто получает права, на какой Код, какие действия разрешены,
и при необходимости — срок/территорию/условия.
2. Отсутствие лицензии по умолчанию
Если вы не получили Разрешение, то вам НЕ предоставляются никакие права на использование,
копирование, модификацию, распространение, публикацию, сублицензирование или продажу Кода.
Любые такие действия без Разрешения запрещены.
3. Лицензия при наличии Разрешения
Если вы получили Разрешение, то вы можете осуществлять только те действия с Кодом и только
в тех пределах, которые прямо указаны в Разрешении. Любые права, не указанные явно,
считаются не предоставленными.
4. Передача третьим лицам
Запрещено передавать Код третьим лицам, публиковать его или распространять (включая форки,
пакеты и бинарные сборки), если это прямо не разрешено Автором в Разрешении.
5. Отказ от гарантий
КОД ПРЕДОСТАВЛЯЕТСЯ "КАК ЕСТЬ" БЕЗ КАКИХ-ЛИБО ГАРАНТИЙ. АВТОР НЕ НЕСЁТ ОТВЕТСТВЕННОСТИ
ЗА ЛЮБЫЕ УБЫТКИ ИЛИ УЩЕРБ, ВОЗНИКАЮЩИЕ ИЗ-ЗА ИСПОЛЬЗОВАНИЯ КОДА.
6. Применимое право
Настоящие условия действуют в максимально возможной степени, допускаемой применимым правом.
7. Контакты
Чтобы получить Разрешение, свяжитесь с Автором и укажите предполагаемое использование.
(Заполните контактные данные здесь при необходимости.)
+224
View File
@@ -1,2 +1,226 @@
# DFWebsite_Downloader
Набор утилит для офлайн-зеркалирования сайтов: скачивание HTML-страниц, связанных ассетов и локальный запуск полученной копии.
Проект ориентирован на Python и подходит для:
- статических сайтов;
- смешанных сайтов, где часть контента объявлена в HTML/CSS/JS/JSON;
- диагностического сохранения структуры ресурсов для последующего анализа.
## Важное о лицензии
В репозитории используется лицензия **PERMISSION REQUIRED LICENSE (PRL) v1.0**.
Это означает, что без явного письменного разрешения автора вам **не предоставляются права** на использование, копирование, модификацию, публикацию и распространение кода.
Подробности — в файле `LICENSE`.
## Возможности
### `website_downloader.py`
- Обход HTML-страниц выполняется только в пределах основного домена стартового URL.
- Загрузка ассетов поддерживает:
- CSS, JS, изображения, шрифты, JSON и прочие ресурсы;
- опциональное скачивание ассетов с внешних доменов (`--external`);
- запрет отдельных доменов (`--exclude-domain`, можно несколько раз).
- Извлечение зависимостей:
- из CSS: `url(...)`, `@import`;
- из JS: эвристическое извлечение путей/URL из строк;
- из JSON: рекурсивный поиск строковых URL/путей с докачкой ссылок.
- Поддержка cache-busting query (`?v=...`, `?t=...`):
- локальное имя файла получает суффикс `__q_<hash>`;
- это предотвращает коллизии и помогает корректно раздавать такие файлы локально.
- Дополнительная обработка популярных паттернов:
- `srcset`, `data-src`, `data-srcset`;
- `meta`-изображения (`og:image`, `twitter:image`);
- `video poster`, `audio/video/source`;
- `iframe` (внутридоменные добавляются в обход, внешние фиксируются в отчёте).
- Генерация отчёта `download_report.json` с итоговой статистикой.
### `serve_site.py`
Локальный сервер для каталога скачанного сайта на `http://127.0.0.1:8080/` с поддержкой:
- pretty URL fallback:
- `/lab` → `/lab.html`
- `/dir/` → `/dir/index.html`
- файлов с cache-busting query:
- `/assets/app.css?v=123` → `/assets/app__q_<hash>.css`
- fallback на уже сохранённые варианты `__q_*` даже при запросе без query.
## Как это работает
1. Скрипт начинает со стартового URL и формирует очередь страниц.
2. Для каждой страницы:
- сохраняет HTML;
- извлекает ресурсы и ссылки;
- скачивает ассеты и вложенные зависимости.
3. Для CSS/JS/JSON выполняет дополнительный разбор на скрытые зависимости.
4. Ссылки на страницы добавляются в очередь с учётом `max_depth`.
5. По завершении формируется `download_report.json` и печатается сводка.
## Требования
- Python 3.10+ (рекомендуется).
- Зависимости из `requirements.txt`:
- `requests`
- `beautifulsoup4`
## Установка (Windows / PowerShell)
```powershell
python -m venv .venv
.\.venv\Scripts\Activate.ps1
pip install -r requirements.txt
```
## Установка (Linux/macOS)
```bash
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
```
## Использование downloader
### Синтаксис
```powershell
python .\website_downloader.py <URL> [output_dir] [max_depth] [max_pages] [--external] [--exclude-domain <domain>]
```
### Параметры
- `URL` — стартовый адрес сайта (обязательно).
- `output_dir` — каталог сохранения (по умолчанию: `website_download`).
- `max_depth` — максимальная глубина обхода HTML-ссылок (по умолчанию: `5`).
- `max_pages` — общий ограничитель на количество загруженных URL (по умолчанию: `1000`).
- `--external` — разрешает скачивать ассеты с внешних доменов.
- `--exclude-domain <domain>` — запрещает скачивание с указанного домена (можно повторять).
> По умолчанию уже добавлен исключённый домен: `cdn.dribbble.com`.
### Примеры
```powershell
# Минимальный запуск
python .\website_downloader.py https://example.com
# Папка + ограничение глубины/объёма
python .\website_downloader.py https://example.com my_site 3 500
# Разрешить внешние ассеты
python .\website_downloader.py https://example.com my_site 3 500 --external
# Разрешить внешние ассеты, но отключить конкретные домены
python .\website_downloader.py https://example.com my_site 3 500 --external --exclude-domain cdn.dribbble.com --exclude-domain fonts.example.com
```
## Использование локального сервера
### Синтаксис
```powershell
python .\serve_site.py <URL-or-folder>
```
### Что можно передать
- URL сайта (папка будет определена по `netloc`), например `https://www.example.com/`;
- имя папки рядом со скриптом, например `example.com`;
- абсолютный путь к каталогу сайта.
### Примеры
```powershell
python .\serve_site.py https://www.example.com/
python .\serve_site.py example.com
python .\serve_site.py D:\PROJECTS\@NEW\DFWebsite_Downloader\my_site
```
После запуска откройте:
- `http://127.0.0.1:8080/`
Остановка сервера: `Ctrl + C`.
## Docker (опционально)
В проекте есть `docker-compose.yml` с `nginx`, который раздаёт каталог `./site` на порту `8080`.
```powershell
docker compose up -d
```
Важно: downloader по умолчанию сохраняет в `website_download`, поэтому для Docker-раздачи:
- либо запускайте downloader с `output_dir=site`,
- либо скорректируйте volume в `docker-compose.yml`.
## Структура результата
После скачивания в каталоге `output_dir` обычно появляются:
- HTML-страницы (`index.html`, `about.html`, `work/...`);
- ассеты (`css`, `js`, `images`, `fonts`, `external/...`);
- `download_report.json` — итоговый отчёт.
В корне проекта создаётся лог:
- `downloader.log`
## Формат `download_report.json`
Ключевые поля отчёта:
- `start_url`, `domain`, `output_dir`, `timestamp`;
- `total_downloaded`, `total_failed`;
- `resources` (разбивка на `pages`, `styles`, `scripts`, `images`, `fonts`, `other`);
- `skipped_external` (что найдено, но пропущено политикой доменов);
- `external_iframes` (внешние iframe, не включённые в обход).
## Ограничения
Полная «бит-в-бит» копия сайта не всегда возможна только HTTP-скрапером. Проблемные случаи:
- SPA-маршрутизация и контент, рендерящийся только в браузере после выполнения JS;
- страницы и API за авторизацией;
- динамические URL, вычисляемые в runtime и не присутствующие в исходниках;
- антибот-защита, rate limit, гео/региональные ограничения.
Для таких случаев нужен отдельный браузерный режим (например, Playwright) с перехватом сетевых запросов.
## Типовые проблемы и решения
### 1) 404 на файлы с `?v=...`
Решено через схему `__q_<hash>` в downloader + соответствующий fallback в `serve_site.py`.
### 2) Не хватает ресурсов, которых нет в HTML
В проекте уже есть разбор CSS/JS/JSON, но при сложной runtime-логике часть зависимостей может не обнаружиться без браузерного выполнения JS.
### 3) Внешние ресурсы не скачиваются
Проверьте:
- добавлен ли флаг `--external`;
- не попадает ли домен под `--exclude-domain`.
## Файлы проекта
- `website_downloader.py` — основной downloader.
- `serve_site.py` — локальный HTTP-сервер для скачанного сайта.
- `requirements.txt` — Python-зависимости.
- `docker-compose.yml` — опциональная раздача через nginx.
- `LICENSE` — лицензионные условия.
## Быстрый старт
```powershell
python -m venv .venv
.\.venv\Scripts\Activate.ps1
pip install -r requirements.txt
python .\website_downloader.py https://example.com site 4 800 --external
python .\serve_site.py site
```
+7
View File
@@ -0,0 +1,7 @@
services:
nginx:
image: nginx:alpine
ports:
- "8080:80"
volumes:
- ./site:/usr/share/nginx/html:ro
+2
View File
@@ -0,0 +1,2 @@
requests>=2.31.0
beautifulsoup4>=4.12.0
+198
View File
@@ -0,0 +1,198 @@
#!/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()
File diff suppressed because it is too large Load Diff