1032 lines
46 KiB
Python
1032 lines
46 KiB
Python
#!/usr/bin/env python3
|
||
# Лицензия: см. файл LICENSE в корне проекта.
|
||
# Использование/копирование/модификация/распространение допускаются
|
||
# только при явном письменном разрешении автора.
|
||
"""
|
||
🌐 Website Downloader - Полная выгрузка сайта с интернета
|
||
|
||
Скачивает страницы и статические ресурсы, необходимые для офлайн-запуска.
|
||
|
||
Важные моменты (как устроено):
|
||
1) Обход страниц (HTML) идёт ТОЛЬКО по основному домену (см. is_page_url_allowed),
|
||
чтобы случайно не «уползти» на внешние сайты.
|
||
2) Скачивание ассетов (CSS/JS/картинки/шрифты/JSON и т.п.) может быть расширено
|
||
на внешние домены флагом `--external` (см. is_same_domain).
|
||
3) Ресурсы с cache-busting query (например `main.css?v=123`) сохраняются в файл
|
||
с суффиксом `__q_<hash>` (см. _save_path_with_query), чтобы:
|
||
- не затирать версии;
|
||
- корректно раздавать локально через `serve_site.py`.
|
||
4) Помимо HTML, парсятся CSS (`url(...)`, `@import`) и JS (эвристика строк/путей),
|
||
т.к. многие сайты подгружают картинки/шрифты/данные динамически.
|
||
"""
|
||
|
||
import os
|
||
import sys
|
||
import time
|
||
import logging
|
||
import re
|
||
import hashlib
|
||
from urllib.parse import urljoin, urlparse, unquote
|
||
from urllib.request import Request, urlopen
|
||
from pathlib import Path
|
||
from collections import deque
|
||
import json
|
||
|
||
|
||
def _configure_stdio_for_unicode() -> None:
|
||
"""Лучшее усилие: убираем падения UnicodeEncodeError в Windows-консоли.
|
||
|
||
В PowerShell/старых кодировках (cp1251 и т.п.) эмодзи/символы могут
|
||
ломать logging.StreamHandler. Здесь мы переводим stdout/stderr в UTF-8
|
||
и включаем режим errors='replace', чтобы вывод не падал.
|
||
"""
|
||
for stream in (sys.stdout, sys.stderr):
|
||
try:
|
||
stream.reconfigure(encoding="utf-8", errors="replace")
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
_configure_stdio_for_unicode()
|
||
|
||
try:
|
||
import requests
|
||
from bs4 import BeautifulSoup
|
||
DEPENDENCIES_OK = True
|
||
except ImportError:
|
||
DEPENDENCIES_OK = False
|
||
print("⚠️ Установите зависимости: pip install requests beautifulsoup4")
|
||
|
||
|
||
# Настройка логирования
|
||
# Пишем лог в файл UTF-8 + выводим в консоль.
|
||
# Если консоль не поддерживает символы, они будут заменены благодаря
|
||
# _configure_stdio_for_unicode().
|
||
logging.basicConfig(
|
||
level=logging.INFO,
|
||
format='%(asctime)s - %(levelname)s - %(message)s',
|
||
handlers=[
|
||
logging.FileHandler('downloader.log', encoding='utf-8'),
|
||
logging.StreamHandler(stream=sys.stdout)
|
||
]
|
||
)
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class WebsiteDownloader:
|
||
"""Класс для загрузки всего сайта.
|
||
|
||
Общая идея:
|
||
- Стартуем со страницы `start_url` и обходим ссылки `<a href>` до max_depth.
|
||
- На каждой странице скачиваем ресурсы из HTML (css/js/img/fonts/preload/icon...).
|
||
- После скачивания CSS/JS дополнительно выкачиваем вложенные зависимости:
|
||
- CSS: `url(...)` и `@import` (картинки, шрифты и т.п.)
|
||
- JS: строки, похожие на пути/URL (например `data/*.json`)
|
||
- Для некоторых сайтов проекты/страницы генерируются из JSON.
|
||
Поэтому отдельно обрабатываем `data/projects.json`: добавляем страницы
|
||
`/work/<project.url>` и связанные изображения.
|
||
"""
|
||
|
||
RESOURCE_LIST_KEY = {
|
||
'page': 'pages',
|
||
'style': 'styles',
|
||
'script': 'scripts',
|
||
'image': 'images',
|
||
'font': 'fonts',
|
||
'other': 'other',
|
||
}
|
||
|
||
def __init__(self, start_url, output_dir="website_download", max_depth=5, max_pages=1000, allow_external=False, exclude_domains=None):
|
||
"""
|
||
Args:
|
||
start_url: URL сайта для скачивания
|
||
output_dir: Директория для сохранения
|
||
max_depth: Максимальная глубина рекурсии (по умолчанию 5)
|
||
max_pages: Максимальное количество страниц (по умолчанию 1000)
|
||
allow_external: если True — разрешаем скачивать ассеты с других доменов
|
||
(например Google Fonts, CDN-иконки и т.п.). На обход страниц это НЕ влияет.
|
||
exclude_domains: список доменов, с которых НИЧЕГО не скачиваем
|
||
(даже если включён allow_external). Удобно исключать публичные CDN.
|
||
"""
|
||
self.start_url = start_url
|
||
self.output_dir = output_dir
|
||
self.max_depth = max_depth
|
||
self.max_pages = max_pages
|
||
self.allow_external = allow_external
|
||
self.exclude_domains = {
|
||
d.strip().lower() for d in (exclude_domains or []) if str(d).strip()
|
||
}
|
||
|
||
# Парсим базовый URL — он определяет «основной домен» для обхода страниц.
|
||
parsed = urlparse(start_url)
|
||
self.domain = parsed.netloc
|
||
self.scheme = parsed.scheme
|
||
self.base_url = f"{self.scheme}://{self.domain}"
|
||
|
||
# Отслеживание загруженных и упавших URL:
|
||
# - downloaded_urls: чтобы не качать повторно
|
||
# - failed_urls: чтобы не пытаться снова и снова на ошибках
|
||
self.downloaded_urls = set()
|
||
self.failed_urls = set()
|
||
# Очередь страниц для обхода: (url, depth)
|
||
self.pages_queue = deque([(start_url, 0)])
|
||
self.resources = {
|
||
'pages': [],
|
||
'styles': [],
|
||
'scripts': [],
|
||
'images': [],
|
||
'fonts': [],
|
||
'other': []
|
||
}
|
||
|
||
# Что было найдено, но НЕ скачано из-за политик доменов.
|
||
# Полезно для диагностики: "почему почти ничего не скачалось?".
|
||
self.skipped_resources = {
|
||
'styles': set(),
|
||
'scripts': set(),
|
||
'images': set(),
|
||
'fonts': set(),
|
||
'other': set(),
|
||
}
|
||
self.skipped_iframes = set()
|
||
|
||
# Сессия requests — быстрее и удобнее (reuse соединений + общие заголовки).
|
||
self.session = requests.Session()
|
||
self.session.headers.update({
|
||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
|
||
})
|
||
|
||
# Создаём директорию вывода.
|
||
os.makedirs(output_dir, exist_ok=True)
|
||
logger.info(f"🚀 Начинаем загрузку сайта: {start_url}")
|
||
logger.info(f"📁 Директория для сохранения: {output_dir}")
|
||
|
||
def is_same_domain(self, url):
|
||
"""Проверить, можно ли скачивать ресурс по URL (ассеты).
|
||
|
||
Это НЕ про обход страниц, а про загрузку файлов:
|
||
- при `--external` разрешаем скачивать ассеты и с других доменов;
|
||
- домены из exclude_domains режутся всегда.
|
||
"""
|
||
parsed = urlparse(url) if url.startswith('http') else None
|
||
if parsed and parsed.netloc and parsed.netloc.lower() in self.exclude_domains:
|
||
return False
|
||
|
||
if self.allow_external:
|
||
return True
|
||
if not url.startswith('http'):
|
||
return True
|
||
return parsed.netloc == self.domain
|
||
|
||
def _asset_policy(self, url):
|
||
"""Проверить, можно ли скачивать ассет, и вернуть причину отказа.
|
||
|
||
Returns:
|
||
(allowed: bool, reason: str|None)
|
||
|
||
reason:
|
||
- 'excluded-domain' домен запрещён через exclude_domains
|
||
- 'external' внешний домен и allow_external=False
|
||
"""
|
||
if not url.startswith('http'):
|
||
return True, None
|
||
|
||
parsed = urlparse(url)
|
||
netloc = (parsed.netloc or '').lower()
|
||
if netloc and netloc in self.exclude_domains:
|
||
return False, 'excluded-domain'
|
||
if self.allow_external:
|
||
return True, None
|
||
if netloc == self.domain:
|
||
return True, None
|
||
return False, 'external'
|
||
|
||
def is_page_url_allowed(self, url):
|
||
"""Страницы для обхода: всегда только основной домен.
|
||
|
||
Даже при `--external` страницы других доменов мы НЕ обходим,
|
||
иначе можно уйти на связанные сайты/портфолио/соцсети и т.п.
|
||
"""
|
||
if not url.startswith('http'):
|
||
return True
|
||
parsed = urlparse(url)
|
||
if parsed.netloc and parsed.netloc.lower() in self.exclude_domains:
|
||
return False
|
||
return parsed.netloc == self.domain
|
||
|
||
def normalize_url(self, url, base_url):
|
||
"""Нормализовать URL.
|
||
|
||
Делает:
|
||
- отбрасывает пустые/якоря/`javascript:`;
|
||
- отбрасывает `data:` и `blob:` (они не скачиваются как файлы);
|
||
- склеивает относительные пути с base_url через urljoin;
|
||
- убирает #якорь (но query сохраняем — он нужен для версионирования).
|
||
"""
|
||
if url.startswith('#') or url.startswith('javascript:') or not url.strip():
|
||
return None
|
||
|
||
lowered = url.strip().lower()
|
||
if lowered.startswith('data:') or lowered.startswith('blob:'):
|
||
return None
|
||
|
||
# Убрать якоря
|
||
url = url.split('#')[0]
|
||
|
||
# Объединить с базовым URL
|
||
full_url = urljoin(base_url, url)
|
||
|
||
return full_url if full_url.startswith('http') else None
|
||
|
||
def _save_path_with_query(self, path, query):
|
||
"""Добавить к имени файла хеш query-строки, чтобы не было коллизий.
|
||
|
||
Пример:
|
||
assets/css/main.css?v=123 -> assets/css/main__q_<hash>.css
|
||
|
||
Это важно для сайтов, которые используют cache-busting параметры.
|
||
"""
|
||
if not query:
|
||
return path
|
||
|
||
query_hash = hashlib.md5(query.encode('utf-8', errors='ignore')).hexdigest()[:10]
|
||
p = Path(path)
|
||
|
||
# If path points to a directory, keep index.html behavior intact
|
||
if str(path).endswith('/'):
|
||
return path
|
||
|
||
if p.suffix:
|
||
return str(p.with_name(f"{p.stem}__q_{query_hash}{p.suffix}"))
|
||
return f"{path}__q_{query_hash}"
|
||
|
||
def get_save_path(self, url, resource_type='page'):
|
||
"""Получить локальный путь для сохранения ресурса.
|
||
|
||
Основные правила:
|
||
- / -> index.html
|
||
- «красивые» URL страниц без расширения -> .html (например /lab -> lab.html)
|
||
- для не-страниц, если путь без расширения/странный — кладём в папки по типу
|
||
- query-строка добавляет суффикс `__q_<hash>`
|
||
- внешние ассеты (если разрешены) складываем под `external/<netloc>/...`
|
||
"""
|
||
parsed = urlparse(url)
|
||
path = parsed.path.strip('/')
|
||
|
||
# Если это главная страница (пустой путь) или директория — сохраняем как index.html
|
||
if not path or path.endswith('/'):
|
||
path = os.path.join(path, 'index.html')
|
||
|
||
# Pretty URLs: /lab -> lab.html
|
||
if resource_type == 'page' and path and not Path(path).suffix:
|
||
path = f"{path}.html"
|
||
|
||
# Для ассетов иногда встречаются URL без расширения.
|
||
# Чтобы не получать «голые» пути, кладём их в папки по типу.
|
||
if resource_type != 'page' and not any(path.endswith(ext) for ext in ['.css', '.js', '.html']):
|
||
ext = Path(path).suffix or '.bin'
|
||
if not path.endswith(ext):
|
||
resource_type_map = {
|
||
'style': 'css',
|
||
'script': 'js',
|
||
'image': 'images',
|
||
'font': 'fonts',
|
||
'other': 'resources'
|
||
}
|
||
base_dir = resource_type_map.get(resource_type, 'resources')
|
||
path = os.path.join(base_dir, os.path.basename(path) or 'index')
|
||
|
||
# Избегаем коллизий при cache-busting параметрах (?v=..., ?t=...)
|
||
path = self._save_path_with_query(path, parsed.query)
|
||
|
||
# Внешние ресурсы (другие домены) складываем отдельно — так проще переносить/анализировать.
|
||
if parsed.netloc and parsed.netloc != self.domain:
|
||
path = os.path.join('external', parsed.netloc, path)
|
||
|
||
full_path = os.path.join(self.output_dir, path)
|
||
return full_path
|
||
|
||
def _resource_type_from_url(self, url, content_type=None):
|
||
"""Определить тип ресурса (style/script/font/image/other) по URL и Content-Type."""
|
||
lowered_ct = (content_type or '').lower()
|
||
path = urlparse(url).path.lower()
|
||
ext = Path(path).suffix
|
||
|
||
if 'text/css' in lowered_ct or ext == '.css':
|
||
return 'style'
|
||
if 'javascript' in lowered_ct or ext in ('.js', '.mjs'):
|
||
return 'script'
|
||
if 'font' in lowered_ct or ext in ('.woff', '.woff2', '.ttf', '.otf', '.eot'):
|
||
return 'font'
|
||
if 'image' in lowered_ct or ext in ('.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.ico', '.avif', '.bmp', '.tif', '.tiff'):
|
||
return 'image'
|
||
return 'other'
|
||
|
||
def _extract_css_urls(self, css_text):
|
||
"""Вытащить URL из CSS.
|
||
|
||
Ищем:
|
||
- url(...)
|
||
- @import "..." / @import url(...)
|
||
"""
|
||
urls = []
|
||
for match in re.finditer(r"url\(\s*(['\"]?)([^'\"\)]+)\1\s*\)", css_text, flags=re.IGNORECASE):
|
||
candidate = match.group(2).strip()
|
||
if candidate:
|
||
urls.append(candidate)
|
||
|
||
for match in re.finditer(r"@import\s+(?:url\()?\s*(['\"])([^'\"]+)\1\s*\)?", css_text, flags=re.IGNORECASE):
|
||
candidate = match.group(2).strip()
|
||
if candidate:
|
||
urls.append(candidate)
|
||
|
||
return urls
|
||
|
||
def _extract_js_urls(self, js_text):
|
||
"""Эвристика: вытаскиваем из JS строки, похожие на URL/пути.
|
||
|
||
Это не полноценный JS-парсер. Задача — вытащить типичные вещи:
|
||
- 'data/projects.json'
|
||
- '/assets/images/..'
|
||
- 'https://...'
|
||
|
||
Мы намеренно пропускаем шаблонные строки с `${...}`, т.к. их
|
||
невозможно восстановить без исполнения кода.
|
||
"""
|
||
urls = []
|
||
for match in re.finditer(r"(['\"`])([^'\"`]{1,2048})\1", js_text):
|
||
candidate = match.group(2).strip()
|
||
if not candidate:
|
||
continue
|
||
if '${' in candidate:
|
||
continue
|
||
if candidate.startswith(('/', './', '../', 'http://', 'https://')):
|
||
urls.append(candidate)
|
||
return urls
|
||
|
||
def _download_js_dependencies(self, js_text, base_url):
|
||
"""Скачать зависимости, упомянутые в JS.
|
||
|
||
Сюда попадают:
|
||
- JSON (особенно data/*.json)
|
||
- изображения, шрифты
|
||
- css/js (если упомянуты явно)
|
||
|
||
Плюс отдельная эвристика под паттерн data-loader:
|
||
`${basePath}person.json` -> скачиваем /data/person.json и т.д.
|
||
"""
|
||
# Фокусируемся на типичных расширениях, чтобы меньше ложных срабатываний.
|
||
interesting_exts = (
|
||
'.json',
|
||
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.ico', '.avif',
|
||
'.woff', '.woff2', '.ttf', '.otf', '.eot',
|
||
'.css', '.js', '.mjs',
|
||
)
|
||
|
||
for raw in self._extract_js_urls(js_text):
|
||
if raw.startswith('/data/') or '/data/' in raw or raw.lower().endswith(interesting_exts):
|
||
dep_url = self.normalize_url(raw, base_url)
|
||
if not dep_url:
|
||
continue
|
||
if not self.is_same_domain(dep_url):
|
||
continue
|
||
dep_type = self._resource_type_from_url(dep_url)
|
||
self.download_resource(dep_url, dep_type)
|
||
|
||
# Handle common pattern: basePath = 'data/' and template literals `${basePath}person.json`
|
||
if 'data/' in js_text or 'basepath' in js_text.lower():
|
||
for match in re.finditer(r"\b([a-zA-Z0-9_-]+\.json)\b", js_text):
|
||
filename = match.group(1)
|
||
# Join against site root, not script directory
|
||
for raw in (f"data/{filename}", f"/data/{filename}"):
|
||
dep_url = self.normalize_url(raw, self.base_url + '/')
|
||
if dep_url and self.is_same_domain(dep_url):
|
||
self.download_resource(dep_url, 'other')
|
||
|
||
def _iter_json_strings(self, value):
|
||
"""Рекурсивно обойти JSON-структуру и вернуть все строковые значения.
|
||
|
||
Это универсальная эвристика: многие сайты хранят пути к ассетам/страницам
|
||
в JSON (иконки, логотипы, картинки, ссылки на .html/.json и т.п.).
|
||
"""
|
||
if isinstance(value, str):
|
||
yield value
|
||
return
|
||
if isinstance(value, list):
|
||
for item in value:
|
||
yield from self._iter_json_strings(item)
|
||
return
|
||
if isinstance(value, dict):
|
||
for item in value.values():
|
||
yield from self._iter_json_strings(item)
|
||
return
|
||
|
||
def _download_json_dependencies(self, data, base_url, current_depth=0):
|
||
"""Универсально докачать зависимости, найденные внутри JSON.
|
||
|
||
Правила:
|
||
- Если строка похожа на URL/путь и указывает на ассет (img/font/css/js/json),
|
||
скачиваем как ресурс.
|
||
- Если строка указывает на HTML-страницу на основном домене,
|
||
добавляем в очередь обхода.
|
||
"""
|
||
if data is None:
|
||
return
|
||
|
||
interesting_exts = (
|
||
'.html',
|
||
'.json',
|
||
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.ico', '.avif',
|
||
'.woff', '.woff2', '.ttf', '.otf', '.eot',
|
||
'.css', '.js', '.mjs',
|
||
'.mp4', '.webm',
|
||
)
|
||
|
||
for raw in self._iter_json_strings(data):
|
||
candidate = raw.strip()
|
||
if not candidate:
|
||
continue
|
||
if candidate.startswith(('data:', 'blob:', 'mailto:', 'tel:')):
|
||
continue
|
||
|
||
looks_like_path = candidate.startswith(('/', './', '../', 'http://', 'https://'))
|
||
looks_like_asset = candidate.lower().endswith(interesting_exts)
|
||
if not (looks_like_path or looks_like_asset):
|
||
continue
|
||
|
||
full = self.normalize_url(candidate, base_url)
|
||
if not full:
|
||
continue
|
||
|
||
# HTML-страницы обходим только на основном домене.
|
||
if full.lower().endswith('.html') and self.is_page_url_allowed(full):
|
||
self._enqueue_page(full, current_depth + 1)
|
||
continue
|
||
|
||
if not self.is_same_domain(full):
|
||
continue
|
||
|
||
dep_type = self._resource_type_from_url(full)
|
||
self.download_resource(full, dep_type, current_depth=current_depth)
|
||
|
||
def _enqueue_page(self, page_url, depth):
|
||
"""Положить страницу в очередь обхода (с ограничителями)."""
|
||
if not page_url:
|
||
return
|
||
if page_url in self.downloaded_urls or page_url in self.failed_urls:
|
||
return
|
||
# Avoid unbounded growth
|
||
if len(self.pages_queue) > self.max_pages * 2:
|
||
return
|
||
self.pages_queue.append((page_url, depth))
|
||
|
||
def _handle_projects_json(self, projects_data, current_depth):
|
||
"""Спец-обработка data/projects.json для сайтов, где страницы проектов генерируются из данных.
|
||
|
||
Что делаем:
|
||
- добавляем страницы проектов в виде `/work/<project.url>`;
|
||
- заранее скачиваем изображения из `/assets/images/work/`:
|
||
- превью по project.id: `{id}-{light|dark}.png`
|
||
- изображения для карусели по featuredImages[].filename: `{filename}-{light|dark}.png`
|
||
"""
|
||
try:
|
||
projects = projects_data.get('projects', []) if isinstance(projects_data, dict) else []
|
||
except Exception:
|
||
projects = []
|
||
|
||
for project in projects:
|
||
if not isinstance(project, dict):
|
||
continue
|
||
|
||
# Case study JSON подгружается на страницах /work/* динамически:
|
||
# dataLoader.loadCaseStudy(caseStudyId) -> /data/case-studies/<id>.json
|
||
# Поэтому скачиваем заранее по project.id.
|
||
case_study_id = project.get('id')
|
||
if isinstance(case_study_id, str) and case_study_id.strip():
|
||
cs = self.normalize_url(
|
||
f"/data/case-studies/{case_study_id.strip()}.json",
|
||
self.base_url + '/',
|
||
)
|
||
if cs and self.is_same_domain(cs):
|
||
self.download_resource(cs, 'other', current_depth=current_depth)
|
||
|
||
project_url = project.get('url')
|
||
if isinstance(project_url, str) and project_url.strip():
|
||
work_page = self.normalize_url(f"/work/{project_url.lstrip('/')}" , self.base_url + '/')
|
||
if work_page and self.is_page_url_allowed(work_page):
|
||
self._enqueue_page(work_page, current_depth + 1)
|
||
|
||
project_id = project.get('id')
|
||
if isinstance(project_id, str) and project_id.strip():
|
||
for theme in ('light', 'dark'):
|
||
img = self.normalize_url(f"/assets/images/work/{project_id}-{theme}.png", self.base_url + '/')
|
||
if img and self.is_same_domain(img):
|
||
self.download_resource(img, 'image')
|
||
|
||
featured = project.get('featuredImages', [])
|
||
if isinstance(featured, list):
|
||
for item in featured:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
filename = item.get('filename')
|
||
if not isinstance(filename, str) or not filename.strip():
|
||
continue
|
||
for theme in ('light', 'dark'):
|
||
img = self.normalize_url(f"/assets/images/work/{filename}-{theme}.png", self.base_url + '/')
|
||
if img and self.is_same_domain(img):
|
||
self.download_resource(img, 'image')
|
||
|
||
def _handle_brands_json(self, brands_data, current_depth=0):
|
||
"""Спец-обработка data/brands.json.
|
||
|
||
Логотипы брендов на главной странице создаются динамически из JSON
|
||
(brand.lightLogo/brand.darkLogo), поэтому их нет в HTML.
|
||
Скачиваем все перечисленные пути, чтобы не было 404 на /assets/logos/*.svg.
|
||
"""
|
||
if not isinstance(brands_data, dict):
|
||
return
|
||
|
||
brands = brands_data.get('brands', [])
|
||
if not isinstance(brands, list):
|
||
return
|
||
|
||
for brand in brands:
|
||
if not isinstance(brand, dict):
|
||
continue
|
||
|
||
for key in ('lightLogo', 'darkLogo'):
|
||
raw = brand.get(key)
|
||
if not isinstance(raw, str) or not raw.strip():
|
||
continue
|
||
|
||
logo_url = self.normalize_url(raw.strip(), self.base_url + '/')
|
||
if not logo_url:
|
||
continue
|
||
if not self.is_same_domain(logo_url):
|
||
continue
|
||
|
||
self.download_resource(logo_url, 'image', current_depth=current_depth)
|
||
|
||
def _download_css_dependencies(self, css_text, css_url):
|
||
"""Скачать зависимости, найденные в CSS (картинки/шрифты/@import)."""
|
||
for raw in self._extract_css_urls(css_text):
|
||
raw_str = str(raw or '').strip()
|
||
if not raw_str:
|
||
continue
|
||
|
||
# В CSS часто встречается url(#id) / url(%23id) (ссылки на элементы внутри SVG).
|
||
# Это не файлы и их не надо скачивать.
|
||
if unquote(raw_str).startswith('#'):
|
||
continue
|
||
|
||
dep_url = self.normalize_url(raw_str, css_url)
|
||
if not dep_url:
|
||
continue
|
||
dep_type = self._resource_type_from_url(dep_url)
|
||
self.download_resource(dep_url, dep_type)
|
||
|
||
def download_resource(self, url, resource_type='other', current_depth=0):
|
||
"""Скачать один ресурс.
|
||
|
||
Здесь качаются именно файлы (ассеты): css/js/img/font/json/other.
|
||
Важно:
|
||
- Фильтрация доменов: is_same_domain
|
||
- Сохранение на диск: get_save_path
|
||
- Пост-обработка:
|
||
- CSS -> вытаскиваем url()/@import
|
||
- JS -> вытаскиваем data/*.json и т.п.
|
||
- projects.json -> добавляем страницы /work/... и work-картинки
|
||
"""
|
||
# Защита от повторов
|
||
if url in self.downloaded_urls or url in self.failed_urls:
|
||
return None
|
||
|
||
# Приводим относительный URL к абсолютному
|
||
if not url.startswith('http'):
|
||
url = urljoin(self.base_url, url)
|
||
|
||
resources_key = self.RESOURCE_LIST_KEY.get(resource_type, 'other')
|
||
allowed, reason = self._asset_policy(url)
|
||
if not allowed:
|
||
self.skipped_resources.setdefault(resources_key, set()).add(url)
|
||
return None
|
||
|
||
try:
|
||
# Скачиваем
|
||
response = self.session.get(url, timeout=10, allow_redirects=True)
|
||
|
||
if response.status_code != 200:
|
||
logger.warning(f"⚠️ HTTP {response.status_code}: {url}")
|
||
self.failed_urls.add(url)
|
||
return None
|
||
|
||
# Определить путь для сохранения
|
||
save_path = self.get_save_path(url, resource_type)
|
||
|
||
# Создать директории
|
||
os.makedirs(os.path.dirname(save_path), exist_ok=True)
|
||
|
||
# Сохранить файл: html пишем текстом, остальное — байтами
|
||
content_type = response.headers.get('content-type', '')
|
||
if 'text/html' in content_type:
|
||
with open(save_path, 'w', encoding='utf-8') as f:
|
||
f.write(response.text)
|
||
else:
|
||
with open(save_path, 'wb') as f:
|
||
f.write(response.content)
|
||
|
||
self.downloaded_urls.add(url)
|
||
# Если тип не задан — определим по расширению/Content-Type
|
||
if resource_type == 'other':
|
||
resource_type = self._resource_type_from_url(url, content_type=content_type)
|
||
|
||
resources_key = self.RESOURCE_LIST_KEY.get(resource_type, 'other')
|
||
self.resources[resources_key].append(url)
|
||
|
||
logger.info(f"✅ {resource_type.upper():8} | {url[:60]}")
|
||
# Вежливая задержка, чтобы не «ддосить» сайт
|
||
time.sleep(0.1)
|
||
|
||
# CSS: url(...)/@import
|
||
if resource_type == 'style' and isinstance(response.text, str):
|
||
self._download_css_dependencies(response.text, url)
|
||
|
||
# JS: data/*.json и другие ассеты, которые подгружаются скриптами
|
||
if resource_type == 'script' and isinstance(response.text, str):
|
||
self._download_js_dependencies(response.text, url)
|
||
|
||
# JSON: иногда в данных лежат ссылки на страницы/картинки
|
||
parsed_path = urlparse(url).path.lower()
|
||
if parsed_path.endswith('.json') or 'application/json' in content_type.lower():
|
||
try:
|
||
data = json.loads(response.text)
|
||
except Exception:
|
||
data = None
|
||
|
||
# Универсальный режим: вытащить ссылки/пути из любого JSON и докачать.
|
||
self._download_json_dependencies(data, self.base_url + '/', current_depth=current_depth)
|
||
|
||
# projects.json -> страницы проектов + work-картинки
|
||
if parsed_path.endswith('/data/projects.json') and isinstance(data, (dict, list)):
|
||
self._handle_projects_json(data if isinstance(data, dict) else {}, current_depth)
|
||
|
||
if parsed_path.endswith('/data/brands.json') and isinstance(data, (dict, list)):
|
||
self._handle_brands_json(data if isinstance(data, dict) else {}, current_depth=current_depth)
|
||
|
||
return save_path
|
||
|
||
except Exception as e:
|
||
logger.error(f"❌ Ошибка при загрузке {url}: {e}")
|
||
self.failed_urls.add(url)
|
||
return None
|
||
|
||
def extract_resources(self, html, page_url):
|
||
"""Извлечь все ресурсы из HTML.
|
||
|
||
Скачиваем:
|
||
- <link rel=stylesheet>
|
||
- иконки/manifest/touch icons
|
||
- preload (style/script/image/font)
|
||
- <script src>
|
||
- inline <script> (для data/*.json)
|
||
- изображения из <img>/<source> + srcset + data-src
|
||
- og:image/twitter:image
|
||
- video poster + media sources
|
||
- inline style="background-image: url(...)" и т.п.
|
||
|
||
Возвращаем список ссылок (<a href>) для дальнейшего обхода.
|
||
"""
|
||
try:
|
||
soup = BeautifulSoup(html, 'html.parser')
|
||
|
||
# CSS файлы
|
||
for link in soup.find_all('link', {'rel': 'stylesheet'}):
|
||
href = link.get('href')
|
||
if href:
|
||
css_url = self.normalize_url(href, page_url)
|
||
if css_url:
|
||
self.download_resource(css_url, 'style')
|
||
|
||
# Icons / manifest / touch icons
|
||
for link in soup.find_all('link', {'href': True}):
|
||
rel = (link.get('rel') or [])
|
||
rel_joined = ' '.join(rel).lower() if isinstance(rel, list) else str(rel).lower()
|
||
href = link.get('href')
|
||
if not href:
|
||
continue
|
||
|
||
if any(token in rel_joined for token in ('icon', 'apple-touch-icon', 'mask-icon')) or rel_joined == 'manifest':
|
||
icon_url = self.normalize_url(href, page_url)
|
||
if icon_url:
|
||
self.download_resource(icon_url, 'image')
|
||
|
||
# Preload resources
|
||
for link in soup.find_all('link', {'rel': 'preload'}):
|
||
href = link.get('href')
|
||
as_attr = (link.get('as') or '').lower()
|
||
if not href or not as_attr:
|
||
continue
|
||
preload_url = self.normalize_url(href, page_url)
|
||
if not preload_url:
|
||
continue
|
||
preload_type = {'style': 'style', 'script': 'script', 'image': 'image', 'font': 'font'}.get(as_attr, 'other')
|
||
self.download_resource(preload_url, preload_type)
|
||
|
||
# JavaScript файлы
|
||
for script in soup.find_all('script', {'src': True}):
|
||
src = script.get('src')
|
||
if src:
|
||
js_url = self.normalize_url(src, page_url)
|
||
if js_url:
|
||
self.download_resource(js_url, 'script')
|
||
|
||
# Inline scripts can contain runtime data endpoints
|
||
for script in soup.find_all('script', {'src': False}):
|
||
text = script.string
|
||
if text and isinstance(text, str):
|
||
self._download_js_dependencies(text, page_url)
|
||
|
||
# Изображения
|
||
for tag in soup.find_all(['img', 'source']):
|
||
candidates = []
|
||
if tag.get('src'):
|
||
candidates.append(tag.get('src'))
|
||
if tag.get('srcset'):
|
||
# srcset can contain multiple candidates separated by commas
|
||
for part in str(tag.get('srcset')).split(','):
|
||
url_part = part.strip().split()[0] if part.strip() else ''
|
||
if url_part:
|
||
candidates.append(url_part)
|
||
if tag.get('data-src'):
|
||
candidates.append(tag.get('data-src'))
|
||
if tag.get('data-srcset'):
|
||
for part in str(tag.get('data-srcset')).split(','):
|
||
url_part = part.strip().split()[0] if part.strip() else ''
|
||
if url_part:
|
||
candidates.append(url_part)
|
||
|
||
for raw in candidates:
|
||
img_url = self.normalize_url(raw, page_url)
|
||
if img_url:
|
||
self.download_resource(img_url, 'image')
|
||
|
||
# OpenGraph / social preview images
|
||
for meta in soup.find_all('meta', {'property': True, 'content': True}):
|
||
prop = str(meta.get('property')).lower()
|
||
if prop in ('og:image', 'twitter:image'):
|
||
img_url = self.normalize_url(meta.get('content'), page_url)
|
||
if img_url:
|
||
self.download_resource(img_url, 'image')
|
||
|
||
# Video posters and media sources
|
||
for video in soup.find_all('video'):
|
||
poster = video.get('poster')
|
||
if poster:
|
||
poster_url = self.normalize_url(poster, page_url)
|
||
if poster_url:
|
||
self.download_resource(poster_url, 'image')
|
||
for media in soup.find_all(['video', 'audio', 'source'], {'src': True}):
|
||
media_url = self.normalize_url(media.get('src'), page_url)
|
||
if media_url:
|
||
self.download_resource(media_url, 'other')
|
||
|
||
# Шрифты
|
||
for font in soup.find_all('link', {'rel': 'preload', 'as': 'font'}):
|
||
href = font.get('href')
|
||
if href:
|
||
font_url = self.normalize_url(href, page_url)
|
||
if font_url:
|
||
self.download_resource(font_url, 'font')
|
||
|
||
# Inline styles (background-image etc.)
|
||
for styled in soup.find_all(style=True):
|
||
style_attr = styled.get('style')
|
||
if not style_attr:
|
||
continue
|
||
for raw in self._extract_css_urls(style_attr):
|
||
dep_url = self.normalize_url(raw, page_url)
|
||
if dep_url:
|
||
dep_type = self._resource_type_from_url(dep_url)
|
||
self.download_resource(dep_url, dep_type)
|
||
|
||
# Найти все ссылки для дальнейшей загрузки (обход страниц)
|
||
links = []
|
||
|
||
# Iframe: часто "демо"-страницы грузят реальный сайт внутри iframe на другом домене.
|
||
for frame in soup.find_all('iframe', {'src': True}):
|
||
src = frame.get('src')
|
||
if not src:
|
||
continue
|
||
frame_url = self.normalize_url(src, page_url)
|
||
if not frame_url:
|
||
continue
|
||
if self.is_page_url_allowed(frame_url):
|
||
links.append(frame_url)
|
||
else:
|
||
self.skipped_iframes.add(frame_url)
|
||
|
||
for a in soup.find_all('a', {'href': True}):
|
||
href = a.get('href')
|
||
if href:
|
||
link_url = self.normalize_url(href, page_url)
|
||
if link_url and self.is_page_url_allowed(link_url):
|
||
links.append(link_url)
|
||
|
||
return links
|
||
|
||
except Exception as e:
|
||
logger.error(f"❌ Ошибка парсинга {page_url}: {e}")
|
||
return []
|
||
|
||
def download_page(self, url, depth):
|
||
"""Загрузить страницу (HTML) и запустить извлечение ресурсов."""
|
||
if url in self.downloaded_urls:
|
||
return []
|
||
|
||
# Ограничители обхода
|
||
if depth > self.max_depth or len(self.downloaded_urls) >= self.max_pages:
|
||
return []
|
||
|
||
try:
|
||
logger.info(f"📄 Загружаю страницу (depth {depth}): {url}")
|
||
|
||
response = self.session.get(url, timeout=10, allow_redirects=True)
|
||
|
||
if response.status_code != 200:
|
||
logger.warning(f"⚠️ HTTP {response.status_code}: {url}")
|
||
self.failed_urls.add(url)
|
||
return []
|
||
|
||
# Сохранить HTML
|
||
save_path = self.get_save_path(url, 'page')
|
||
os.makedirs(os.path.dirname(save_path), exist_ok=True)
|
||
|
||
with open(save_path, 'w', encoding='utf-8') as f:
|
||
f.write(response.text)
|
||
|
||
self.downloaded_urls.add(url)
|
||
self.resources['pages'].append(url)
|
||
logger.info(f"✅ PAGE | {url[:60]}")
|
||
|
||
# Извлечь все ресурсы из HTML
|
||
links = self.extract_resources(response.text, url)
|
||
|
||
time.sleep(0.2)
|
||
|
||
return links
|
||
|
||
except Exception as e:
|
||
logger.error(f"❌ Ошибка при загрузке страницы {url}: {e}")
|
||
self.failed_urls.add(url)
|
||
return []
|
||
|
||
def run(self):
|
||
"""Начать загрузку сайта.
|
||
|
||
Основной цикл:
|
||
- берём страницу из очереди;
|
||
- скачиваем её и ресурсы;
|
||
- добавляем найденные ссылки в очередь.
|
||
"""
|
||
print("\n" + "="*70)
|
||
print("🌐 WEBSITE DOWNLOADER - Начало работы")
|
||
print("="*70 + "\n")
|
||
|
||
while self.pages_queue and len(self.downloaded_urls) < self.max_pages:
|
||
url, depth = self.pages_queue.popleft()
|
||
|
||
if url not in self.downloaded_urls:
|
||
links = self.download_page(url, depth)
|
||
|
||
# Добавить найденные ссылки в очередь
|
||
for link in links:
|
||
if link not in self.downloaded_urls and depth < self.max_depth:
|
||
self.pages_queue.append((link, depth + 1))
|
||
|
||
self.generate_report()
|
||
self.print_summary()
|
||
|
||
def generate_report(self):
|
||
"""Сгенерировать отчёт о загрузке"""
|
||
skipped = {
|
||
k: sorted(v)[:500] # ограничим размер отчёта
|
||
for k, v in (self.skipped_resources or {}).items()
|
||
if v
|
||
}
|
||
report = {
|
||
'start_url': self.start_url,
|
||
'domain': self.domain,
|
||
'output_dir': self.output_dir,
|
||
'timestamp': time.strftime('%Y-%m-%d %H:%M:%S'),
|
||
'total_downloaded': len(self.downloaded_urls),
|
||
'total_failed': len(self.failed_urls),
|
||
'resources': self.resources,
|
||
'skipped_external': skipped,
|
||
'external_iframes': sorted(self.skipped_iframes)[:200]
|
||
}
|
||
|
||
report_path = os.path.join(self.output_dir, 'download_report.json')
|
||
with open(report_path, 'w', encoding='utf-8') as f:
|
||
json.dump(report, f, indent=2, ensure_ascii=False)
|
||
|
||
logger.info(f"📊 Отчёт сохранён: {report_path}")
|
||
|
||
def print_summary(self):
|
||
"""Вывести сводку"""
|
||
print("\n" + "="*70)
|
||
print("✅ ЗАГРУЗКА ЗАВЕРШЕНА")
|
||
print("="*70)
|
||
print(f"📁 Директория: {self.output_dir}")
|
||
print(f"🌐 Домен: {self.domain}")
|
||
print(f"✅ Загружено: {len(self.downloaded_urls)} ресурсов")
|
||
print(f"❌ Ошибок: {len(self.failed_urls)}")
|
||
print(f"\n📊 По типам:")
|
||
print(f" 📄 Страницы: {len(self.resources['pages'])}")
|
||
print(f" 🎨 CSS файлы: {len(self.resources['styles'])}")
|
||
print(f" 📜 JavaScript: {len(self.resources['scripts'])}")
|
||
print(f" 🖼️ Изображения: {len(self.resources['images'])}")
|
||
print(f" 🔤 Шрифты: {len(self.resources['fonts'])}")
|
||
print(f" 📦 Остальное: {len(self.resources['other'])}")
|
||
print("="*70 + "\n")
|
||
|
||
|
||
def main():
|
||
"""Точка входа CLI.
|
||
|
||
Формат запуска:
|
||
python website_downloader.py <URL> [output_dir] [max_depth] [max_pages] [--external] [--exclude-domain <domain>]
|
||
|
||
Важно:
|
||
- `--external` относится только к ассетам (css/js/img/font/json и т.п.).
|
||
Страницы других доменов НЕ обходятся.
|
||
- `--exclude-domain` можно указывать несколько раз.
|
||
"""
|
||
if not DEPENDENCIES_OK:
|
||
sys.exit(1)
|
||
|
||
# Обработка аргументов командной строки
|
||
if len(sys.argv) < 2:
|
||
print("""
|
||
🌐 Website Downloader - Использование:
|
||
|
||
python website_downloader.py <URL> [output_dir] [max_depth] [max_pages] [--external] [--exclude-domain <domain>]
|
||
|
||
Примеры:
|
||
python website_downloader.py https://example.com
|
||
python website_downloader.py https://example.com my_website 5 500
|
||
python website_downloader.py https://github.dfv24.com website_backup 3 200
|
||
|
||
Параметры:
|
||
URL - URL сайта для загрузки (обязательный)
|
||
output_dir - Директория для сохранения (default: website_download)
|
||
max_depth - Максимальная глубина рекурсии (default: 5)
|
||
max_pages - Максимум страниц для загрузки (default: 1000)
|
||
--external - Разрешить скачивание ресурсов с других доменов (иконки/CDN/шрифты)
|
||
--exclude-domain <d> - Не скачивать ресурсы с указанного домена (можно указать несколько раз)
|
||
""")
|
||
return
|
||
|
||
url = sys.argv[1]
|
||
|
||
argv = sys.argv[1:]
|
||
allow_external = '--external' in argv
|
||
argv = [a for a in argv if a != '--external']
|
||
|
||
exclude_domains = ['cdn.dribbble.com']
|
||
while '--exclude-domain' in argv:
|
||
idx = argv.index('--exclude-domain')
|
||
if idx + 1 >= len(argv):
|
||
raise SystemExit('Ожидается домен после --exclude-domain')
|
||
exclude_domains.append(argv[idx + 1])
|
||
del argv[idx:idx + 2]
|
||
|
||
url = argv[0]
|
||
output_dir = argv[1] if len(argv) > 1 else "website_download"
|
||
max_depth = int(argv[2]) if len(argv) > 2 else 5
|
||
max_pages = int(argv[3]) if len(argv) > 3 else 1000
|
||
|
||
# Проверить URL
|
||
if not url.startswith('http'):
|
||
url = f'https://{url}'
|
||
|
||
try:
|
||
downloader = WebsiteDownloader(
|
||
url,
|
||
output_dir,
|
||
max_depth,
|
||
max_pages,
|
||
allow_external=allow_external,
|
||
exclude_domains=exclude_domains,
|
||
)
|
||
downloader.run()
|
||
except KeyboardInterrupt:
|
||
print("\n\n⚠️ Загрузка прервана пользователем")
|
||
logger.info("Загрузка прервана пользователем")
|
||
except Exception as e:
|
||
logger.error(f"Критическая ошибка: {e}")
|
||
print(f"\n❌ Ошибка: {e}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|