Files
sitearchive/website_downloader.py

1032 lines
46 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 в корне проекта.
# Использование/копирование/модификация/распространение допускаются
# только при явном письменном разрешении автора.
"""
🌐 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()