Добавление загрузчика веб-сайтов для сбора данных с сайтов в автономном режиме с извлечением ресурсов.
This commit is contained in:
+28
@@ -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
|
||||
@@ -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. Контакты
|
||||
Чтобы получить Разрешение, свяжитесь с Автором и укажите предполагаемое использование.
|
||||
(Заполните контактные данные здесь при необходимости.)
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
services:
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
ports:
|
||||
- "8080:80"
|
||||
volumes:
|
||||
- ./site:/usr/share/nginx/html:ro
|
||||
@@ -0,0 +1,2 @@
|
||||
requests>=2.31.0
|
||||
beautifulsoup4>=4.12.0
|
||||
+198
@@ -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
Reference in New Issue
Block a user