Функция: добавлен скрипт для исправления внешних ссылок и загрузки ресурсов
- Создан файл `fix-links.js` для обработки загрузки внешних ресурсов и исправления ссылок в HTML-файлах. - Добавлена конфигурация для разрешенных и пропускаемых доменов, настроек загрузки и механизмов кэширования. - Реализованы функции для обработки файлов, обработки URL-адресов и одновременных загрузок. - Введено отслеживание статистики для обработанных файлов, исправленных ссылок и сбоев загрузки. Задача: инициализация package.json и package-lock.json - Добавлен файл `package.json` с метаданными проекта и зависимостями. - Создан файл `package-lock.json` для блокировки версий зависимостей для обеспечения согласованной установки.
This commit is contained in:
+33
@@ -0,0 +1,33 @@
|
||||
node_modules/
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# Runtime data
|
||||
pids/
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Dependency directories
|
||||
.pnpm-store/
|
||||
|
||||
# Build / coverage
|
||||
dist/
|
||||
coverage/
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# IDE / OS files
|
||||
.vscode/
|
||||
.idea/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,14 @@
|
||||
Авторское право (c) 2026, Автор(ы) проекта
|
||||
|
||||
Разрешается использовать, копировать, изменять и/или распространять это программное обеспечение
|
||||
в любых целях (с оплатой или без), при условии, что указанное выше уведомление об авторских правах
|
||||
и данное разрешение будут включены во все копии.
|
||||
|
||||
ПРОГРАММНОЕ ОБЕСПЕЧЕНИЕ ПРЕДОСТАВЛЯЕТСЯ «КАК ЕСТЬ», И АВТОР ОТКАЗЫВАЕТСЯ ОТ ЛЮБЫХ ГАРАНТИЙ
|
||||
В ОТНОШЕНИИ ДАННОГО ПРОГРАММНОГО ОБЕСПЕЧЕНИЯ, ВКЛЮЧАЯ ВСЕ ПОДРАЗУМЕВАЕМЫЕ ГАРАНТИИ
|
||||
КОММЕРЧЕСКОЙ ПРИГОДНОСТИ И ПРИГОДНОСТИ ДЛЯ ОПРЕДЕЛЁННОЙ ЦЕЛИ.
|
||||
НИ В КОЕМ СЛУЧАЕ АВТОР НЕ НЕСЁТ ОТВЕТСТВЕННОСТИ ЗА ЛЮБЫЕ ОСОБЫЕ, ПРЯМЫЕ, КОСВЕННЫЕ
|
||||
ИЛИ ПОСЛЕДУЮЩИЕ УБЫТКИ, ЛИБО ЛЮБЫЕ УБЫТКИ ВООБЩЕ, ВОЗНИКШИЕ В РЕЗУЛЬТАТЕ ПОТЕРИ
|
||||
ВОЗМОЖНОСТИ ИСПОЛЬЗОВАНИЯ, ДАННЫХ ИЛИ ПРИБЫЛИ, НЕЗАВИСИМО ОТ ТОГО, БЫЛО ЛИ ЭТО
|
||||
СЛЕДСТВИЕМ ДОГОВОРА, НЕБРЕЖНОСТИ ИЛИ ИНОГО ДЕЛИКТА, ВОЗНИКШЕГО ИЗ ИСПОЛЬЗОВАНИЯ
|
||||
ИЛИ НЕВОЗМОЖНОСТИ ИСПОЛЬЗОВАНИЯ ДАННОГО ПРОГРАММНОГО ОБЕСПЕЧЕНИЯ.
|
||||
@@ -1,2 +1,215 @@
|
||||
# mirror-forge
|
||||
|
||||
Node.js-проект для зеркалирования сайта в локальную папку `mirror/` с помощью Playwright (headless Chromium).
|
||||
|
||||
Проект включает два сценария:
|
||||
|
||||
1. `crawl.js` — обходит страницы сайта, сохраняет HTML и ресурсы (CSS/JS/изображения/шрифты и т.д.).
|
||||
2. `fix-links.js` — пост-обработка уже скачанного зеркала: ищет внешние URL в HTML, догружает недостающие файлы и заменяет ссылки на локальные относительные пути.
|
||||
|
||||
---
|
||||
|
||||
## Возможности
|
||||
|
||||
- Обход внутренних страниц с очередью URL и ограничением по количеству страниц.
|
||||
- Параллельная загрузка (`CONCURRENCY`) для ускорения зеркалирования.
|
||||
- Перехват `response` в браузере и сохранение ассетов по `Content-Type`.
|
||||
- Автоповторы при ошибках (`MAX_RETRIES`).
|
||||
- Извлечение новых ссылок из финального DOM страницы, а не только из сырого HTML.
|
||||
- Корректное завершение по `Ctrl+C` (graceful shutdown).
|
||||
- Нормализация путей для Windows (замена недопустимых символов).
|
||||
- Поддержка query-строк через хеширование имени файла, чтобы избежать перезаписи.
|
||||
- Опциональная замена абсолютных ссылок в HTML на относительные (`FIX_LINKS`).
|
||||
- Принудительное сохранение HTML в UTF-8 (`<meta charset="utf-8">`).
|
||||
|
||||
---
|
||||
|
||||
## Стек и требования
|
||||
|
||||
- Node.js 18+
|
||||
- npm
|
||||
- Playwright (`playwright`)
|
||||
- ОС: Windows / Linux / macOS
|
||||
|
||||
> В `package.json` проект работает в режиме ES Modules (`"type": "module"`).
|
||||
|
||||
---
|
||||
|
||||
## Установка
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npx playwright install
|
||||
```
|
||||
|
||||
Если зависимостей ещё нет, альтернативно:
|
||||
|
||||
```bash
|
||||
npm i playwright
|
||||
npx playwright install
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
### 1) Скачивание сайта
|
||||
|
||||
```bash
|
||||
node crawl.js
|
||||
```
|
||||
|
||||
### 2) Дополнительная фиксация ссылок (опционально)
|
||||
|
||||
```bash
|
||||
node fix-links.js
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Как устроен `crawl.js`
|
||||
|
||||
### Основной поток
|
||||
|
||||
1. В очередь добавляется `BASE`.
|
||||
2. URL обрабатываются батчами по `CONCURRENCY`.
|
||||
3. Для каждой страницы:
|
||||
- выполняется `page.goto(..., waitUntil: "domcontentloaded")`;
|
||||
- даётся дополнительная пауза `WAIT_AFTER_LOAD`;
|
||||
- выполняется попытка дождаться `networkidle`;
|
||||
- через `page.on("response")` сохраняются ассеты;
|
||||
- HTML сохраняется в `mirror/`;
|
||||
- извлекаются новые ссылки и добавляются в очередь.
|
||||
4. При ошибках применяются автоповторы до `MAX_RETRIES`.
|
||||
|
||||
### Что скачивается
|
||||
|
||||
- HTML-документы.
|
||||
- Скрипты, стили, изображения, шрифты.
|
||||
- XHR/fetch-ответы (с ограничениями, см. ниже).
|
||||
|
||||
### Что фильтруется/ограничивается
|
||||
|
||||
- Обход ограничен разрешёнными URL-префиксами (`ALLOWED_URL_PREFIXES`) и origin, вычисленными из них.
|
||||
- JSON-ответы без расширения `.json` пропускаются в загрузчике ассетов, чтобы не перезаписывать HTML.
|
||||
- URL с неподдерживаемыми схемами (`data:`, `javascript:`, `mailto:`, `tel:`, `blob:`) игнорируются.
|
||||
- Ассеты (по расширению) не попадают в очередь страниц.
|
||||
|
||||
---
|
||||
|
||||
## Конфигурация `crawl.js`
|
||||
|
||||
Ключевые параметры находятся вверху файла:
|
||||
|
||||
- `BASE` — стартовый URL обхода.
|
||||
- `OUT_DIR` — папка вывода (по умолчанию `mirror`).
|
||||
- `MAX_PAGES` — лимит страниц для обхода.
|
||||
- `CONCURRENCY` — количество одновременно открытых вкладок.
|
||||
- `WAIT_AFTER_LOAD` — задержка после `domcontentloaded`.
|
||||
- `MAX_RETRIES` — число повторных попыток для страницы.
|
||||
- `REQUEST_TIMEOUT` — таймаут навигации `page.goto`.
|
||||
- `USER_AGENT` — User-Agent браузера.
|
||||
- `FIX_LINKS` — включить замену ссылок в HTML на относительные в момент сохранения.
|
||||
- `ALLOWED_URL_PREFIXES` — список URL-префиксов, которые разрешено обходить/скачивать.
|
||||
|
||||
---
|
||||
|
||||
## Как формируются локальные пути
|
||||
|
||||
- URL-path сохраняется как путь внутри `mirror/`.
|
||||
- Путь, заканчивающийся на `/`, сохраняется как `.../index.html` (через `index` + расширение).
|
||||
- Query-строка хешируется (`md5`, 8 символов) и добавляется к имени файла.
|
||||
- Если у URL нет расширения, оно определяется по `Content-Type`; если определить нельзя — используется `.html`.
|
||||
- Символы, недопустимые в Windows-путях, заменяются на `_`.
|
||||
|
||||
---
|
||||
|
||||
## Как устроен `fix-links.js`
|
||||
|
||||
`fix-links.js` предназначен для пост-обработки уже скачанного зеркала.
|
||||
|
||||
Что делает скрипт:
|
||||
|
||||
1. Рекурсивно находит HTML/HTM-файлы в `mirror/`.
|
||||
2. Извлекает URL из `src/href/srcset/poster/data-src/url(...)`.
|
||||
3. Для внешних и отсутствующих относительных ссылок:
|
||||
- проверяет домены по правилам `ALLOWED_DOMAINS` и `SKIP_DOMAINS`;
|
||||
- скачивает ресурсы в локальную структуру с ограничением по конкурентности;
|
||||
- заменяет абсолютные ссылки на относительные пути.
|
||||
4. Ведёт статистику: обработано файлов, исправлено ссылок, скачано/пропущено/ошибки.
|
||||
|
||||
Ключевые параметры в `fix-links.js`:
|
||||
|
||||
- `MIRROR_DIR` — папка зеркала.
|
||||
- `DOWNLOAD_TIMEOUT` — таймаут загрузки одного ресурса.
|
||||
- `DOWNLOAD_CONCURRENCY` — число одновременных загрузок при пост-обработке.
|
||||
- `MAX_RETRIES` — число повторов загрузки.
|
||||
- `ALLOWED_DOMAINS` — явно разрешённые домены для скачивания.
|
||||
- `SKIP_DOMAINS` — домены, которые нужно пропускать.
|
||||
|
||||
---
|
||||
|
||||
## Логи и статус
|
||||
|
||||
### `crawl.js`
|
||||
|
||||
- `[start]` — старт обхода.
|
||||
- `[config]` — активные параметры.
|
||||
- `[crawled]` — страница сохранена.
|
||||
- `[retry x/y]` — повторная попытка.
|
||||
- `[gave up]` — страница не скачана после всех повторов.
|
||||
- `[done]` — итоговая статистика.
|
||||
|
||||
### `fix-links.js`
|
||||
|
||||
- `📄 Обрабатываю` — файл в обработке.
|
||||
- `📥 Скачиваю` / `✅ Скачано` / `❌ Не удалось скачать`.
|
||||
- При неудачной загрузке исходная ссылка сохраняется, чтобы не превращать рабочий внешний URL в битый локальный путь.
|
||||
- Итоговый блок `📊 СТАТИСТИКА`.
|
||||
|
||||
---
|
||||
|
||||
## Ограничения
|
||||
|
||||
- Это не «пиксель-в-пиксель офлайн-клон» сложных SPA/PWA.
|
||||
- Контент, подгружаемый только после действий пользователя (скролл/клик/hover), может не попасть в зеркало.
|
||||
- Внешние сервисы аналитики/соцсети/CDN могут оставаться внешними ссылками (зависит от доменных правил).
|
||||
- API-данные с динамическими эндпоинтами не гарантируется получить полностью.
|
||||
|
||||
---
|
||||
|
||||
## Рекомендации по эксплуатации
|
||||
|
||||
- Начинайте с меньшего `MAX_PAGES` и `CONCURRENCY`, затем увеличивайте.
|
||||
- При частых таймаутах увеличьте `REQUEST_TIMEOUT` и/или `WAIT_AFTER_LOAD`.
|
||||
- Если сервер ограничивает запросы, уменьшите `CONCURRENCY`.
|
||||
- Перед массовым запуском проверьте политики сайта (`robots.txt`, Terms of Service) и правовые ограничения.
|
||||
|
||||
---
|
||||
|
||||
## Типичный workflow
|
||||
|
||||
1. Настроить `BASE` и `ALLOWED_URL_PREFIXES` в `crawl.js`.
|
||||
2. Запустить `node crawl.js`.
|
||||
3. Проверить структуру `mirror/`.
|
||||
4. При необходимости запустить `node fix-links.js`.
|
||||
5. Открывать локальные HTML из `mirror/` для анализа/архивации/офлайн-просмотра.
|
||||
|
||||
---
|
||||
|
||||
## Структура проекта
|
||||
|
||||
```text
|
||||
.
|
||||
├─ crawl.js
|
||||
├─ fix-links.js
|
||||
├─ package.json
|
||||
├─ README.md
|
||||
└─ mirror/ # создаётся после запуска
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Лицензия
|
||||
|
||||
ISC (см. `package.json` и `LICENSE`).
|
||||
|
||||
@@ -0,0 +1,641 @@
|
||||
// crawl.js
|
||||
// Скрипт зеркалирования сайта с помощью Playwright.
|
||||
import fs from "fs/promises";
|
||||
import { existsSync, mkdirSync } from "fs";
|
||||
import path from "path";
|
||||
import crypto from "crypto";
|
||||
import { chromium } from "playwright";
|
||||
|
||||
// ========== КОНФИГУРАЦИЯ ==========
|
||||
const BASE = "https://coderthemes.com/vona/layouts/index.html"; // Базовый URL, откуда стартуем (корень сайта)
|
||||
const OUT_DIR = path.resolve("mirror"); // Папка для зеркала
|
||||
const MAX_PAGES = 50000; // Лимит страниц, чтобы не уйти в бесконечный обход (увеличен)
|
||||
const CONCURRENCY = 6; // Количество параллельных вкладок (увеличено для скорости)
|
||||
const WAIT_AFTER_LOAD = 2000; // Пауза после загрузки DOM (мс) для догрузки ресурсов
|
||||
const MAX_RETRIES = 2; // Количество повторных попыток при ошибках
|
||||
const REQUEST_TIMEOUT = 30000; // Таймаут навигации (мс)
|
||||
const USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122 Safari/537.36"; // User-Agent браузера
|
||||
const FIX_LINKS = true; // Заменять ссылки на относительные при сохранении
|
||||
|
||||
// Нормализация BASE URL: убираем хвостовые слэши
|
||||
const BASE_NORMALIZED = BASE.replace(/\/+$/, "");
|
||||
const BASE_ORIGIN = new URL(BASE_NORMALIZED).origin;
|
||||
|
||||
// Разрешённые URL-префиксы для обхода и локализации ссылок.
|
||||
// Внешние ресурсы (Google Fonts, CDN, GitHub и т.д.) НЕ скачиваются,
|
||||
// ссылки на них остаются прямыми (абсолютными).
|
||||
const ALLOWED_URL_PREFIXES = [
|
||||
new URL("/vona/layouts", BASE_ORIGIN).href.replace(/\/+$/, ""),
|
||||
new URL("/vona/layouts", "https://www.coderthemes.com").href.replace(/\/+$/, ""),
|
||||
];
|
||||
const ALLOWED_ORIGINS = [...new Set(ALLOWED_URL_PREFIXES.map((value) => new URL(value).origin))];
|
||||
|
||||
// ========== СОСТОЯНИЕ ==========
|
||||
const seenPages = new Set();
|
||||
const queue = [];
|
||||
const enqueued = new Set();
|
||||
const downloaded = new Set();
|
||||
const inFlightDownloads = new Map();
|
||||
const failedPages = new Map(); // url -> количество повторов
|
||||
|
||||
let browser = null; // Экземпляр браузера Playwright
|
||||
let isShuttingDown = false; // Флаг завершения (Ctrl+C и т.д.)
|
||||
|
||||
// ========== МАППИНГ CONTENT-TYPE -> РАСШИРЕНИЕ ==========
|
||||
const CONTENT_TYPE_EXT = {
|
||||
"text/html": ".html",
|
||||
"text/css": ".css",
|
||||
"text/javascript": ".js",
|
||||
"application/javascript": ".js",
|
||||
"application/json": ".json",
|
||||
"image/png": ".png",
|
||||
"image/jpeg": ".jpg",
|
||||
"image/gif": ".gif",
|
||||
"image/svg+xml": ".svg",
|
||||
"image/webp": ".webp",
|
||||
"image/avif": ".avif",
|
||||
"font/woff": ".woff",
|
||||
"font/woff2": ".woff2",
|
||||
"application/font-woff": ".woff",
|
||||
"application/font-woff2": ".woff2",
|
||||
"font/ttf": ".ttf",
|
||||
"font/otf": ".otf",
|
||||
"application/octet-stream": "", // keep original
|
||||
};
|
||||
|
||||
// ========== ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ==========
|
||||
|
||||
/**
|
||||
* Санитизация пути для Windows:
|
||||
* заменяем недопустимые символы на "_".
|
||||
*/
|
||||
function sanitizePath(p) {
|
||||
// Недопустимые символы Windows: < > : " | ? *
|
||||
return p.replace(/[<>:"|?*]/g, "_");
|
||||
}
|
||||
|
||||
/**
|
||||
* Определение расширения по Content-Type.
|
||||
*/
|
||||
function getExtFromContentType(contentType) {
|
||||
if (!contentType) return null;
|
||||
const type = contentType.split(";")[0].trim().toLowerCase();
|
||||
return CONTENT_TYPE_EXT[type] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Преобразование URL в локальный путь файла.
|
||||
* Учитываются query-параметры (через хеш),
|
||||
* чтобы избежать перезаписи файлов.
|
||||
*/
|
||||
function urlToPath(urlStr, contentType = null) {
|
||||
const u = new URL(urlStr);
|
||||
let p = sanitizePath(decodeURIComponent(u.pathname));
|
||||
|
||||
// Директории превращаем в index
|
||||
if (p.endsWith("/")) p += "index";
|
||||
|
||||
// Query-параметры хешируем и добавляем к имени
|
||||
if (u.search) {
|
||||
const hash = crypto.createHash("md5").update(u.search).digest("hex").slice(0, 8);
|
||||
const ext = path.extname(p);
|
||||
if (ext) {
|
||||
p = p.slice(0, -ext.length) + `_${hash}` + ext;
|
||||
} else {
|
||||
p += `_${hash}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Определяем расширение (по Content-Type либо .html)
|
||||
const currentExt = path.extname(p);
|
||||
if (!currentExt) {
|
||||
// Пробуем взять расширение из Content-Type
|
||||
const ctExt = getExtFromContentType(contentType);
|
||||
if (ctExt) {
|
||||
p += ctExt;
|
||||
} else {
|
||||
// По умолчанию считаем HTML
|
||||
p += ".html";
|
||||
}
|
||||
}
|
||||
|
||||
return path.join(OUT_DIR, p);
|
||||
}
|
||||
|
||||
/**
|
||||
* Создание директории (если отсутствует).
|
||||
*/
|
||||
function ensureDir(filePath) {
|
||||
const dir = path.dirname(filePath);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function isAllowedUrl(urlLike) {
|
||||
try {
|
||||
const u = typeof urlLike === "string" ? new URL(urlLike, BASE_NORMALIZED) : urlLike;
|
||||
if (!ALLOWED_ORIGINS.includes(u.origin)) return false;
|
||||
|
||||
const comparable = `${u.origin}${u.pathname}`.replace(/\/+$/, "");
|
||||
return ALLOWED_URL_PREFIXES.some((prefix) => comparable === prefix || comparable.startsWith(prefix + "/"));
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Нормализация URL для корректной дедупликации.
|
||||
* - Отбрасываем hash
|
||||
* - Убираем хвостовой слэш
|
||||
* - Фильтруем внешние домены
|
||||
*/
|
||||
function normalizeUrl(urlStr) {
|
||||
try {
|
||||
const u = new URL(urlStr, BASE_NORMALIZED);
|
||||
|
||||
if (!isAllowedUrl(u)) return null;
|
||||
|
||||
u.hash = "";
|
||||
|
||||
// Убираем хвостовой слэш, кроме корня
|
||||
if (u.pathname !== "/" && u.pathname.endsWith("/")) {
|
||||
u.pathname = u.pathname.slice(0, -1);
|
||||
}
|
||||
|
||||
return u.toString();
|
||||
} catch (e) {
|
||||
// Не логируем мусорные/битые URL, чтобы не засорять вывод
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяем, является ли URL страницей (а не ассетом).
|
||||
* Ассеты (CSS/JS/изображения/видео/шрифты) не добавляем в очередь страниц.
|
||||
*/
|
||||
function isPageUrl(url) {
|
||||
const u = new URL(url);
|
||||
const ext = path.extname(u.pathname).toLowerCase();
|
||||
const assetExts = [
|
||||
// Scripts & styles
|
||||
".js", ".mjs", ".css", ".map",
|
||||
// Images
|
||||
".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".avif", ".ico", ".bmp",
|
||||
// Fonts
|
||||
".woff", ".woff2", ".ttf", ".otf", ".eot",
|
||||
// Video & Audio
|
||||
".mp4", ".webm", ".ogg", ".mp3", ".wav", ".m4a", ".m4v", ".avi", ".mov",
|
||||
// Documents & Data
|
||||
".json", ".xml", ".pdf", ".zip", ".gz", ".tar",
|
||||
];
|
||||
return !assetExts.includes(ext);
|
||||
}
|
||||
|
||||
// ========== ФУНКЦИИ ЗАМЕНЫ ССЫЛОК НА ОТНОСИТЕЛЬНЫЕ ==========
|
||||
|
||||
let linksFixed = 0; // Счётчик исправленных ссылок
|
||||
|
||||
/**
|
||||
* Вычисление относительного пути от HTML файла к ресурсу
|
||||
*/
|
||||
function getRelativePath(htmlFilePath, targetFilePath) {
|
||||
const fromDir = path.dirname(htmlFilePath);
|
||||
let relativePath = path.relative(fromDir, targetFilePath);
|
||||
// Заменяем обратные слеши на прямые для HTML
|
||||
relativePath = relativePath.replace(/\\/g, "/");
|
||||
// Если путь не начинается с . или /, добавляем ./
|
||||
if (!relativePath.startsWith(".") && !relativePath.startsWith("/")) {
|
||||
relativePath = "./" + relativePath;
|
||||
}
|
||||
return relativePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Замена абсолютных ссылок на относительные в HTML
|
||||
* @param {string} html - HTML контент
|
||||
* @param {string} pageUrl - URL текущей страницы
|
||||
* @param {string} htmlFilePath - Локальный путь к HTML файлу
|
||||
* @returns {string} - HTML с относительными ссылками
|
||||
*/
|
||||
function fixLinksInHtml(html, pageUrl, htmlFilePath) {
|
||||
// Паттерны для поиска URL в атрибутах
|
||||
const attrPatterns = [
|
||||
{ attr: 'href', regex: /(\bhref\s*=\s*)(["'])([^"']*)\2/gi },
|
||||
{ attr: 'src', regex: /(\bsrc\s*=\s*)(["'])([^"']*)\2/gi },
|
||||
{ attr: 'data-src', regex: /(\bdata-src\s*=\s*)(["'])([^"']*)\2/gi },
|
||||
{ attr: 'poster', regex: /(\bposter\s*=\s*)(["'])([^"']*)\2/gi },
|
||||
];
|
||||
|
||||
let result = html;
|
||||
let count = 0;
|
||||
|
||||
for (const { regex } of attrPatterns) {
|
||||
result = result.replace(regex, (match, prefix, quote, url) => {
|
||||
// Пропускаем специальные схемы
|
||||
if (!url ||
|
||||
url.startsWith("data:") ||
|
||||
url.startsWith("javascript:") ||
|
||||
url.startsWith("mailto:") ||
|
||||
url.startsWith("tel:") ||
|
||||
url.startsWith("blob:") ||
|
||||
url.startsWith("#")) {
|
||||
return match;
|
||||
}
|
||||
|
||||
// Обрабатываем только URL с нашего домена
|
||||
let absoluteUrl;
|
||||
try {
|
||||
absoluteUrl = new URL(url, pageUrl);
|
||||
} catch (e) {
|
||||
return match;
|
||||
}
|
||||
|
||||
// Пропускаем внешние домены
|
||||
if (!isAllowedUrl(absoluteUrl)) {
|
||||
return match;
|
||||
}
|
||||
|
||||
// Вычисляем локальный путь к файлу
|
||||
const targetFilePath = urlToPath(absoluteUrl.href, null);
|
||||
|
||||
// Вычисляем относительный путь
|
||||
const relativePath = getRelativePath(htmlFilePath, targetFilePath);
|
||||
|
||||
count++;
|
||||
return `${prefix}${quote}${relativePath}${quote}`;
|
||||
});
|
||||
}
|
||||
|
||||
// Обработка srcset (особый случай)
|
||||
result = result.replace(/(\bsrcset\s*=\s*)(["'])([^"']*)\2/gi, (match, prefix, quote, srcsetValue) => {
|
||||
const parts = srcsetValue.split(",").map(part => {
|
||||
const trimmed = part.trim();
|
||||
const spaceIdx = trimmed.indexOf(" ");
|
||||
const url = spaceIdx > 0 ? trimmed.slice(0, spaceIdx) : trimmed;
|
||||
const descriptor = spaceIdx > 0 ? trimmed.slice(spaceIdx) : "";
|
||||
|
||||
if (!url || url.startsWith("data:") || url.startsWith("blob:")) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
try {
|
||||
const absoluteUrl = new URL(url, pageUrl);
|
||||
if (!isAllowedUrl(absoluteUrl)) {
|
||||
return trimmed;
|
||||
}
|
||||
const targetFilePath = urlToPath(absoluteUrl.href, null);
|
||||
const relativePath = getRelativePath(htmlFilePath, targetFilePath);
|
||||
count++;
|
||||
return relativePath + descriptor;
|
||||
} catch (e) {
|
||||
return trimmed;
|
||||
}
|
||||
});
|
||||
|
||||
return `${prefix}${quote}${parts.join(", ")}${quote}`;
|
||||
});
|
||||
|
||||
// Обработка url() в inline-стилях и style-тегах
|
||||
result = result.replace(/url\(\s*(["']?)([^"')]+)\1\s*\)/gi, (match, quote, url) => {
|
||||
if (!url || url.startsWith("data:") || url.startsWith("blob:") || url.startsWith("#")) {
|
||||
return match;
|
||||
}
|
||||
|
||||
try {
|
||||
const absoluteUrl = new URL(url, pageUrl);
|
||||
if (!isAllowedUrl(absoluteUrl)) {
|
||||
return match;
|
||||
}
|
||||
const targetFilePath = urlToPath(absoluteUrl.href, null);
|
||||
const relativePath = getRelativePath(htmlFilePath, targetFilePath);
|
||||
count++;
|
||||
return `url(${quote}${relativePath}${quote})`;
|
||||
} catch (e) {
|
||||
return match;
|
||||
}
|
||||
});
|
||||
|
||||
linksFixed += count;
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Гарантируем, что HTML объявляет UTF-8
|
||||
*/
|
||||
function ensureUtf8Charset(html) {
|
||||
const metaCharset = /<meta\s+charset=["']?[^"'>\s]+["']?\s*\/?>(?=\s*<\/meta>)?|<meta\s+charset=["']?[^"'>\s]+["']?\s*\/?\s*>/gi;
|
||||
const metaContentType = /<meta\s+http-equiv=["']content-type["'][^>]*>/gi;
|
||||
|
||||
let updated = html.replace(metaCharset, '<meta charset="utf-8">');
|
||||
updated = updated.replace(metaContentType, '<meta http-equiv="Content-Type" content="text/html; charset=utf-8">');
|
||||
|
||||
if (updated === html) {
|
||||
const headTag = /<head[^>]*>/i;
|
||||
const match = updated.match(headTag);
|
||||
if (match && match.index != null) {
|
||||
const insertAt = match.index + match[0].length;
|
||||
updated = updated.slice(0, insertAt) + "\n <meta charset=\"utf-8\">" + updated.slice(insertAt);
|
||||
}
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
// ========== ОСНОВНЫЕ ФУНКЦИИ ==========
|
||||
|
||||
/**
|
||||
* Сохранение буфера в файл.
|
||||
* Возвращает true при успехе.
|
||||
*/
|
||||
async function saveBuffer(urlStr, buffer, contentType = null) {
|
||||
const filePath = urlToPath(urlStr, contentType);
|
||||
ensureDir(filePath);
|
||||
try {
|
||||
await fs.writeFile(filePath, buffer);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error("[saveBuffer error]", urlStr, e.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function extractLinksFromPage(page) {
|
||||
const discovered = await page.evaluate(() => {
|
||||
const links = new Set();
|
||||
const skipPattern = /^(data:|javascript:|mailto:|tel:|blob:|#)/i;
|
||||
const urlPattern = /url\(\s*(["']?)([^"')]+)\1\s*\)/gi;
|
||||
|
||||
const addResolvedUrl = (value) => {
|
||||
if (!value) return;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || skipPattern.test(trimmed)) return;
|
||||
|
||||
try {
|
||||
links.add(new URL(trimmed, document.baseURI).href);
|
||||
} catch (e) {
|
||||
// Игнорируем мусорные значения.
|
||||
}
|
||||
};
|
||||
|
||||
const addStyleUrls = (styleText) => {
|
||||
if (!styleText) return;
|
||||
|
||||
let match;
|
||||
while ((match = urlPattern.exec(styleText)) !== null) {
|
||||
addResolvedUrl(match[2]);
|
||||
}
|
||||
urlPattern.lastIndex = 0;
|
||||
};
|
||||
|
||||
for (const element of document.querySelectorAll("[href],[src],[data-src],[poster],[srcset],[style]")) {
|
||||
addResolvedUrl(element.getAttribute("href"));
|
||||
addResolvedUrl(element.getAttribute("src"));
|
||||
addResolvedUrl(element.getAttribute("data-src"));
|
||||
addResolvedUrl(element.getAttribute("poster"));
|
||||
|
||||
const srcset = element.getAttribute("srcset");
|
||||
if (srcset) {
|
||||
for (const part of srcset.split(",")) {
|
||||
const candidate = part.trim().split(/\s+/)[0];
|
||||
addResolvedUrl(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
addStyleUrls(element.getAttribute("style"));
|
||||
}
|
||||
|
||||
for (const styleElement of document.querySelectorAll("style")) {
|
||||
addStyleUrls(styleElement.textContent || "");
|
||||
}
|
||||
|
||||
return [...links];
|
||||
});
|
||||
|
||||
const normalized = new Set();
|
||||
for (const url of discovered) {
|
||||
const normalizedUrl = normalizeUrl(url);
|
||||
if (normalizedUrl) normalized.add(normalizedUrl);
|
||||
}
|
||||
|
||||
return [...normalized];
|
||||
}
|
||||
|
||||
/**
|
||||
* Скачивание ответа (ассеты: CSS/JS/картинки/шрифты и т.д.).
|
||||
* Защищено от гонок через completed/in-flight дедупликацию.
|
||||
*/
|
||||
async function downloadRequest(request, response) {
|
||||
const url = request.url();
|
||||
|
||||
try {
|
||||
const u = new URL(url);
|
||||
if (!isAllowedUrl(u)) return;
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (downloaded.has(url)) return;
|
||||
if (inFlightDownloads.has(url)) return inFlightDownloads.get(url);
|
||||
|
||||
const downloadPromise = (async () => {
|
||||
try {
|
||||
const contentType = response.headers()["content-type"] || "";
|
||||
|
||||
// Пропускаем JSON-API, чтобы они не перезаписывали HTML.
|
||||
if (contentType.includes("application/json") && !url.endsWith(".json")) {
|
||||
downloaded.add(url);
|
||||
return false;
|
||||
}
|
||||
|
||||
const buffer = await response.body();
|
||||
const saved = await saveBuffer(url, buffer, contentType);
|
||||
if (saved) downloaded.add(url);
|
||||
return saved;
|
||||
} catch (e) {
|
||||
if (!isShuttingDown && !e.message.includes("Response body is unavailable") && !e.message.includes("Target page, context or browser has been closed")) {
|
||||
console.error("[downloadRequest error]", url, e.message);
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
inFlightDownloads.delete(url);
|
||||
}
|
||||
})();
|
||||
|
||||
inFlightDownloads.set(url, downloadPromise);
|
||||
return downloadPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка одной страницы:
|
||||
* - Открытие
|
||||
* - Скачивание ассетов через response
|
||||
* - Сохранение HTML
|
||||
* - Извлечение ссылок
|
||||
*/
|
||||
async function crawlPage(browser, url, retryCount = 0) {
|
||||
if (isShuttingDown) return;
|
||||
if (seenPages.has(url) || seenPages.size >= MAX_PAGES) return;
|
||||
|
||||
seenPages.add(url);
|
||||
|
||||
const page = await browser.newPage({ userAgent: USER_AGENT });
|
||||
|
||||
// Отслеживаем незавершённые загрузки ресурсов
|
||||
const pendingDownloads = [];
|
||||
|
||||
// Ловим ответы и сохраняем ассеты
|
||||
page.on("response", (response) => {
|
||||
const request = response.request();
|
||||
const resourceType = request.resourceType();
|
||||
if (["document", "script", "stylesheet", "image", "font", "xhr", "fetch"].includes(resourceType)) {
|
||||
// Сохраняем промисы, чтобы дождаться перед закрытием страницы
|
||||
const downloadPromise = downloadRequest(request, response).catch(() => {});
|
||||
pendingDownloads.push(downloadPromise);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
// Используем domcontentloaded — стабильнее для SPA
|
||||
await page.goto(url, { waitUntil: "domcontentloaded", timeout: REQUEST_TIMEOUT });
|
||||
// Даём время подгрузиться ресурсам
|
||||
await new Promise((r) => setTimeout(r, WAIT_AFTER_LOAD));
|
||||
// Пробуем дождаться networkidle, но не падаем, если не получилось
|
||||
await page.waitForLoadState("networkidle", { timeout: 10000 }).catch(() => {});
|
||||
|
||||
// Дожидаемся завершения всех загрузок ассетов
|
||||
await Promise.all(pendingDownloads);
|
||||
|
||||
const links = await extractLinksFromPage(page);
|
||||
const html = await page.content();
|
||||
const contentType = "text/html";
|
||||
const filePath = urlToPath(url, contentType);
|
||||
|
||||
// Заменяем абсолютные ссылки на относительные если включено
|
||||
let htmlToSave = html;
|
||||
if (FIX_LINKS) {
|
||||
htmlToSave = fixLinksInHtml(html, url, filePath);
|
||||
}
|
||||
|
||||
htmlToSave = ensureUtf8Charset(htmlToSave);
|
||||
|
||||
await saveBuffer(url, Buffer.from(htmlToSave, "utf-8"), contentType);
|
||||
console.log(`[crawled] ${url} (${seenPages.size}/${MAX_PAGES}, queue: ${queue.length})`);
|
||||
|
||||
// Добавляем найденные ссылки в очередь
|
||||
for (const link of links) {
|
||||
if (!enqueued.has(link) && isPageUrl(link)) {
|
||||
enqueued.add(link);
|
||||
queue.push(link);
|
||||
}
|
||||
}
|
||||
|
||||
// Успешная страница — удаляем из списка ошибок
|
||||
failedPages.delete(url);
|
||||
|
||||
} catch (e) {
|
||||
// Во время остановки не логируем и не ретраим
|
||||
if (isShuttingDown) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.error("[crawlPage error]", url, e.message);
|
||||
|
||||
// Убираем из seen, чтобы можно было ретраить
|
||||
seenPages.delete(url);
|
||||
|
||||
// Логика повторов
|
||||
if (retryCount < MAX_RETRIES) {
|
||||
const newRetry = retryCount + 1;
|
||||
failedPages.set(url, newRetry);
|
||||
console.log(`[retry ${newRetry}/${MAX_RETRIES}] ${url}`);
|
||||
// Возвращаем в очередь
|
||||
queue.push(url);
|
||||
} else {
|
||||
console.error(`[gave up] ${url} after ${MAX_RETRIES} retries`);
|
||||
}
|
||||
} finally {
|
||||
// Закрываем вкладку в любом случае
|
||||
await page.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Корректное завершение работы (закрытие браузера и вывод статистики).
|
||||
*/
|
||||
async function shutdown(signal) {
|
||||
if (isShuttingDown) return;
|
||||
isShuttingDown = true;
|
||||
|
||||
console.log(`\n[${signal}] Shutting down gracefully...`);
|
||||
|
||||
if (browser) {
|
||||
try {
|
||||
await browser.close();
|
||||
console.log("[shutdown] Browser closed");
|
||||
} catch (e) {
|
||||
console.error("[shutdown error]", e.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[shutdown] Final stats - Pages: ${seenPages.size}, Assets: ${downloaded.size}, Links fixed: ${linksFixed}`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Точка входа.
|
||||
*/
|
||||
async function main() {
|
||||
// Регистрируем обработчики завершения
|
||||
process.on("SIGINT", () => shutdown("SIGINT"));
|
||||
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
||||
process.on("uncaughtException", (e) => {
|
||||
console.error("[uncaughtException]", e);
|
||||
shutdown("uncaughtException");
|
||||
});
|
||||
process.on("unhandledRejection", (e) => {
|
||||
console.error("[unhandledRejection]", e);
|
||||
});
|
||||
|
||||
console.log(`[start] Crawling ${BASE_NORMALIZED}`);
|
||||
console.log(`[config] Output: ${OUT_DIR}, Max pages: ${MAX_PAGES}, Concurrency: ${CONCURRENCY}`);
|
||||
|
||||
ensureDir(path.join(OUT_DIR, "index.html"));
|
||||
|
||||
// Инициализация очереди с базового URL
|
||||
const startUrl = normalizeUrl(BASE_NORMALIZED);
|
||||
if (!startUrl) {
|
||||
throw new Error(`BASE URL is not allowed by ALLOWED_URL_PREFIXES: ${BASE}`);
|
||||
}
|
||||
queue.push(startUrl);
|
||||
enqueued.add(startUrl);
|
||||
|
||||
browser = await chromium.launch({ headless: true });
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
// Основной цикл: берём батчи и обрабатываем параллельно
|
||||
while (queue.length > 0 && seenPages.size < MAX_PAGES && !isShuttingDown) {
|
||||
const batch = queue.splice(0, CONCURRENCY);
|
||||
|
||||
await Promise.all(
|
||||
batch.map((u) => {
|
||||
const retryCount = failedPages.get(u) || 0;
|
||||
return crawlPage(browser, u, retryCount);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Завершаем работу, если не было принудительного остановa
|
||||
if (!isShuttingDown) {
|
||||
await browser.close();
|
||||
|
||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
console.log(`\n[done] Pages: ${seenPages.size}, Assets: ${downloaded.size}, Links fixed: ${linksFixed}, Time: ${elapsed}s`);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error("[fatal]", e);
|
||||
process.exit(1);
|
||||
});
|
||||
+621
@@ -0,0 +1,621 @@
|
||||
// fix-links.js
|
||||
// Скрипт для исправления внешних ссылок на локальные файлы
|
||||
import fs from "fs/promises";
|
||||
import { existsSync, mkdirSync, createWriteStream, statSync } from "fs";
|
||||
import path from "path";
|
||||
import crypto from "crypto";
|
||||
import https from "https";
|
||||
import http from "http";
|
||||
|
||||
// ========== КОНФИГУРАЦИЯ ==========
|
||||
const MIRROR_DIR = path.resolve("mirror"); // Папка зеркала
|
||||
const DOWNLOAD_TIMEOUT = 15000; // Таймаут загрузки (мс)
|
||||
const MAX_RETRIES = 2; // Количество повторных попыток загрузки
|
||||
const DOWNLOAD_CONCURRENCY = 6; // Количество одновременных загрузок в пост-обработке
|
||||
|
||||
// Домены, которые нужно скачивать (ресурсы сайта)
|
||||
const ALLOWED_DOMAINS = [
|
||||
"mui.com",
|
||||
"v5.mui.com",
|
||||
"fonts.googleapis.com",
|
||||
"fonts.gstatic.com",
|
||||
"cdn.jsdelivr.net",
|
||||
"flagcdn.com",
|
||||
"avatars.githubusercontent.com",
|
||||
];
|
||||
|
||||
// Домены, которые НЕ нужно скачивать (внешние ссылки)
|
||||
const SKIP_DOMAINS = [
|
||||
"github.com",
|
||||
"x.com",
|
||||
"twitter.com",
|
||||
"linkedin.com",
|
||||
"youtube.com",
|
||||
"discord.com",
|
||||
"discord.gg",
|
||||
"googletagmanager.com",
|
||||
"google.com",
|
||||
"google-analytics.com",
|
||||
"notion.site",
|
||||
"codesandbox.io",
|
||||
"stackblitz.com",
|
||||
"codepen.io",
|
||||
"npmjs.com",
|
||||
"unpkg.com",
|
||||
"facebook.com",
|
||||
"instagram.com",
|
||||
];
|
||||
|
||||
// Статистика
|
||||
let stats = {
|
||||
filesProcessed: 0,
|
||||
linksFixed: 0,
|
||||
filesDownloaded: 0,
|
||||
downloadsFailed: 0,
|
||||
alreadyLocal: 0
|
||||
};
|
||||
|
||||
// Кеш уже обработанных URL (чтобы не качать повторно)
|
||||
const downloadCache = new Map(); // url -> localPath или null (если не удалось)
|
||||
const inFlightDownloads = new Map(); // url -> Promise<localPath|null>
|
||||
|
||||
const CONTENT_TYPE_EXT = {
|
||||
"text/css": ".css",
|
||||
"text/javascript": ".js",
|
||||
"application/javascript": ".js",
|
||||
"application/json": ".json",
|
||||
"image/png": ".png",
|
||||
"image/jpeg": ".jpg",
|
||||
"image/gif": ".gif",
|
||||
"image/svg+xml": ".svg",
|
||||
"image/webp": ".webp",
|
||||
"image/avif": ".avif",
|
||||
"font/woff": ".woff",
|
||||
"font/woff2": ".woff2",
|
||||
"application/font-woff": ".woff",
|
||||
"application/font-woff2": ".woff2",
|
||||
"font/ttf": ".ttf",
|
||||
"font/otf": ".otf",
|
||||
};
|
||||
|
||||
// ========== ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ==========
|
||||
|
||||
/**
|
||||
* Рекурсивное получение всех файлов в директории
|
||||
*/
|
||||
async function getAllFiles(dir, extensions = [".html", ".htm"]) {
|
||||
const files = [];
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...await getAllFiles(fullPath, extensions));
|
||||
} else if (extensions.some(ext => entry.name.toLowerCase().endsWith(ext))) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Санитизация пути для Windows
|
||||
*/
|
||||
function sanitizePath(p) {
|
||||
return p.replace(/[<>:"|?*]/g, "_");
|
||||
}
|
||||
|
||||
function getExtFromContentType(contentType) {
|
||||
if (!contentType) return null;
|
||||
const type = contentType.split(";")[0].trim().toLowerCase();
|
||||
return CONTENT_TYPE_EXT[type] ?? null;
|
||||
}
|
||||
|
||||
function appendQueryHash(filePath, search) {
|
||||
if (!search) return filePath;
|
||||
|
||||
const hash = crypto.createHash("md5").update(search).digest("hex").slice(0, 8);
|
||||
const ext = path.extname(filePath);
|
||||
if (ext) {
|
||||
return filePath.slice(0, -ext.length) + `_${hash}` + ext;
|
||||
}
|
||||
return `${filePath}_${hash}`;
|
||||
}
|
||||
|
||||
function withContentTypeExtension(filePath, contentType) {
|
||||
if (path.extname(filePath)) return filePath;
|
||||
|
||||
const ext = getExtFromContentType(contentType);
|
||||
if (!ext) return filePath;
|
||||
return filePath + ext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Преобразование URL в локальный путь
|
||||
*/
|
||||
function urlToLocalPath(urlStr, baseDir) {
|
||||
try {
|
||||
const u = new URL(urlStr);
|
||||
let pathname = sanitizePath(decodeURIComponent(u.pathname));
|
||||
|
||||
// Убираем начальный слеш
|
||||
if (pathname.startsWith("/")) {
|
||||
pathname = pathname.slice(1);
|
||||
}
|
||||
|
||||
// Если путь пустой или заканчивается на /, добавляем index.html
|
||||
if (!pathname || pathname.endsWith("/")) {
|
||||
pathname += "index.html";
|
||||
}
|
||||
|
||||
let localPath = path.join(baseDir, pathname);
|
||||
localPath = appendQueryHash(localPath, u.search);
|
||||
return localPath;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет, можно ли создать путь к файлу (нет конфликта с существующими файлами)
|
||||
*/
|
||||
function canCreatePath(filePath) {
|
||||
const parts = filePath.split(path.sep);
|
||||
let currentPath = parts[0];
|
||||
|
||||
for (let i = 1; i < parts.length - 1; i++) {
|
||||
currentPath = path.join(currentPath, parts[i]);
|
||||
if (existsSync(currentPath)) {
|
||||
const stat = statSync(currentPath);
|
||||
if (!stat.isDirectory()) {
|
||||
// Файл существует там, где должна быть папка
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function ensureDestinationDir(filePath) {
|
||||
if (!canCreatePath(filePath)) {
|
||||
throw new Error("Path conflict: file exists where directory needed");
|
||||
}
|
||||
|
||||
const dir = path.dirname(filePath);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function isRetryableStatus(statusCode) {
|
||||
return statusCode === 408 || statusCode === 429 || statusCode >= 500;
|
||||
}
|
||||
|
||||
/**
|
||||
* Скачивание файла по URL
|
||||
*/
|
||||
function downloadFile(url, destPath, retries = MAX_RETRIES) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const protocol = url.startsWith("https") ? https : http;
|
||||
|
||||
const request = protocol.get(url, {
|
||||
timeout: DOWNLOAD_TIMEOUT,
|
||||
headers: {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
||||
}
|
||||
}, (response) => {
|
||||
// Обработка редиректов
|
||||
if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
|
||||
let redirectUrl = response.headers.location;
|
||||
if (!redirectUrl.startsWith("http")) {
|
||||
redirectUrl = new URL(redirectUrl, url).href;
|
||||
}
|
||||
downloadFile(redirectUrl, destPath, retries)
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.statusCode !== 200) {
|
||||
if (retries > 0 && isRetryableStatus(response.statusCode)) {
|
||||
setTimeout(() => {
|
||||
downloadFile(url, destPath, retries - 1)
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
}, (MAX_RETRIES - retries + 1) * 500);
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new Error(`HTTP ${response.statusCode}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const finalPath = withContentTypeExtension(destPath, response.headers["content-type"] || "");
|
||||
try {
|
||||
ensureDestinationDir(finalPath);
|
||||
} catch (dirErr) {
|
||||
reject(new Error(`Cannot create directory: ${dirErr.message}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const file = createWriteStream(finalPath);
|
||||
response.pipe(file);
|
||||
|
||||
file.on("finish", () => {
|
||||
file.close();
|
||||
resolve(finalPath);
|
||||
});
|
||||
|
||||
file.on("error", (err) => {
|
||||
fs.unlink(finalPath).catch(() => {});
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
|
||||
request.on("error", (err) => {
|
||||
if (retries > 0) {
|
||||
setTimeout(() => {
|
||||
downloadFile(url, destPath, retries - 1)
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
}, (MAX_RETRIES - retries + 1) * 500);
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
|
||||
request.on("timeout", () => {
|
||||
request.destroy();
|
||||
reject(new Error("Timeout"));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет, является ли URL внешним (абсолютным)
|
||||
*/
|
||||
function isExternalUrl(url) {
|
||||
return url.startsWith("http://") || url.startsWith("https://");
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет, нужно ли скачивать URL по домену
|
||||
*/
|
||||
function shouldDownloadUrl(url) {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
const hostname = u.hostname.toLowerCase();
|
||||
|
||||
// Проверяем, в списке ли пропускаемых доменов
|
||||
for (const skipDomain of SKIP_DOMAINS) {
|
||||
if (hostname === skipDomain || hostname.endsWith("." + skipDomain)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Если есть список разрешённых доменов, проверяем
|
||||
if (ALLOWED_DOMAINS.length > 0) {
|
||||
for (const allowedDomain of ALLOWED_DOMAINS) {
|
||||
if (hostname === allowedDomain || hostname.endsWith("." + allowedDomain)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Не в списке разрешённых - но всё равно скачиваем ресурсы (CSS, JS, шрифты, изображения)
|
||||
const resourceExtensions = [".css", ".js", ".woff", ".woff2", ".ttf", ".otf", ".eot", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".avif", ".ico"];
|
||||
const pathname = u.pathname.toLowerCase();
|
||||
return resourceExtensions.some(ext => pathname.endsWith(ext));
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет, нужно ли обрабатывать этот URL (исключаем data:, javascript:, mailto: и т.д.)
|
||||
*/
|
||||
function shouldProcessUrl(url) {
|
||||
if (!url) return false;
|
||||
const skipPrefixes = ["data:", "javascript:", "mailto:", "tel:", "#", "blob:"];
|
||||
return !skipPrefixes.some(prefix => url.toLowerCase().startsWith(prefix));
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
/**
|
||||
* Вычисление относительного пути от одного файла к другому
|
||||
*/
|
||||
function getRelativePath(fromFile, toFile) {
|
||||
const fromDir = path.dirname(fromFile);
|
||||
let relativePath = path.relative(fromDir, toFile);
|
||||
// Заменяем обратные слеши на прямые для HTML
|
||||
relativePath = relativePath.replace(/\\/g, "/");
|
||||
// Если путь не начинается с . или /, добавляем ./
|
||||
if (!relativePath.startsWith(".") && !relativePath.startsWith("/")) {
|
||||
relativePath = "./" + relativePath;
|
||||
}
|
||||
return relativePath;
|
||||
}
|
||||
|
||||
async function mapWithConcurrency(items, limit, mapper) {
|
||||
const results = new Array(items.length);
|
||||
let currentIndex = 0;
|
||||
|
||||
async function worker() {
|
||||
while (currentIndex < items.length) {
|
||||
const index = currentIndex;
|
||||
currentIndex += 1;
|
||||
results[index] = await mapper(items[index], index);
|
||||
}
|
||||
}
|
||||
|
||||
const workers = Array.from({ length: Math.min(limit, items.length) }, () => worker());
|
||||
await Promise.all(workers);
|
||||
return results;
|
||||
}
|
||||
|
||||
function replaceUrlInSrcset(value, fromUrl, toUrl) {
|
||||
let changed = false;
|
||||
const parts = value.split(",").map((part) => {
|
||||
const trimmed = part.trim();
|
||||
const tokens = trimmed.split(/\s+/);
|
||||
if (tokens[0] !== fromUrl) return trimmed;
|
||||
changed = true;
|
||||
tokens[0] = toUrl;
|
||||
return tokens.join(" ");
|
||||
});
|
||||
|
||||
return changed ? parts.join(", ") : value;
|
||||
}
|
||||
|
||||
function replaceUrlReferences(content, fromUrl, toUrl) {
|
||||
let updated = content;
|
||||
const escapedUrl = escapeRegExp(fromUrl);
|
||||
|
||||
const attrPatterns = ["href", "src", "data-src", "poster"];
|
||||
for (const attr of attrPatterns) {
|
||||
const regex = new RegExp(`(\\b${attr}\\s*=\\s*)(["'])${escapedUrl}\\2`, "gi");
|
||||
updated = updated.replace(regex, `$1$2${toUrl}$2`);
|
||||
}
|
||||
|
||||
updated = updated.replace(/(\bsrcset\s*=\s*)(["'])([^"']*)\2/gi, (match, prefix, quote, value) => {
|
||||
const replaced = replaceUrlInSrcset(value, fromUrl, toUrl);
|
||||
return replaced === value ? match : `${prefix}${quote}${replaced}${quote}`;
|
||||
});
|
||||
|
||||
updated = updated.replace(/url\(\s*(["']?)([^"')]+)\1\s*\)/gi, (match, quote, value) => {
|
||||
if (value !== fromUrl) return match;
|
||||
return `url(${quote}${toUrl}${quote})`;
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлечение всех URL из HTML контента
|
||||
*/
|
||||
function extractUrls(content) {
|
||||
const urls = new Set();
|
||||
|
||||
// Паттерны для поиска URL
|
||||
const patterns = [
|
||||
// src="..." или src='...'
|
||||
/\bsrc\s*=\s*["']([^"']+)["']/gi,
|
||||
// href="..." или href='...'
|
||||
/\bhref\s*=\s*["']([^"']+)["']/gi,
|
||||
// url(...) в CSS
|
||||
/\burl\s*\(\s*["']?([^"')]+)["']?\s*\)/gi,
|
||||
// srcset="..."
|
||||
/\bsrcset\s*=\s*["']([^"']+)["']/gi,
|
||||
// poster="..."
|
||||
/\bposter\s*=\s*["']([^"']+)["']/gi,
|
||||
// data-src="..."
|
||||
/\bdata-src\s*=\s*["']([^"']+)["']/gi,
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
let match;
|
||||
while ((match = pattern.exec(content)) !== null) {
|
||||
const urlValue = match[1];
|
||||
|
||||
// Для srcset нужно особая обработка (может содержать несколько URL)
|
||||
if (match[0].toLowerCase().includes("srcset")) {
|
||||
const srcsetParts = urlValue.split(",");
|
||||
for (const part of srcsetParts) {
|
||||
const url = part.trim().split(/\s+/)[0];
|
||||
if (url && shouldProcessUrl(url)) {
|
||||
urls.add(url);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (shouldProcessUrl(urlValue)) {
|
||||
urls.add(urlValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(urls);
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка одного URL: проверка локального файла, скачивание, замена
|
||||
*/
|
||||
async function processUrl(url, htmlFilePath, baseUrl = null) {
|
||||
let absoluteUrl = url;
|
||||
let localPath = null;
|
||||
let cacheKey = url;
|
||||
|
||||
// Если URL относительный, пробуем найти файл локально
|
||||
if (!isExternalUrl(url)) {
|
||||
// Относительный URL - проверяем существует ли файл
|
||||
const htmlDir = path.dirname(htmlFilePath);
|
||||
const urlWithoutHash = url.split("#")[0];
|
||||
const queryIndex = urlWithoutHash.indexOf("?");
|
||||
const relativePathPart = queryIndex >= 0 ? urlWithoutHash.slice(0, queryIndex) : urlWithoutHash;
|
||||
const relativeSearch = queryIndex >= 0 ? urlWithoutHash.slice(queryIndex) : "";
|
||||
let possiblePath;
|
||||
|
||||
if (url.startsWith("/")) {
|
||||
// Абсолютный путь от корня сайта
|
||||
possiblePath = path.join(MIRROR_DIR, relativePathPart.slice(1));
|
||||
} else {
|
||||
// Относительный путь
|
||||
possiblePath = path.resolve(htmlDir, relativePathPart);
|
||||
}
|
||||
possiblePath = appendQueryHash(possiblePath, relativeSearch);
|
||||
|
||||
if (existsSync(possiblePath)) {
|
||||
stats.alreadyLocal++;
|
||||
return null; // Файл уже локальный
|
||||
}
|
||||
|
||||
// Если файл не найден и у нас нет baseUrl, не можем скачать
|
||||
if (!baseUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Формируем абсолютный URL для скачивания
|
||||
try {
|
||||
absoluteUrl = new URL(url, baseUrl).href;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
|
||||
localPath = possiblePath;
|
||||
cacheKey = absoluteUrl;
|
||||
} else {
|
||||
// Внешний URL - определяем локальный путь
|
||||
localPath = urlToLocalPath(url, MIRROR_DIR);
|
||||
cacheKey = absoluteUrl;
|
||||
}
|
||||
|
||||
if (!localPath) return null;
|
||||
|
||||
if (downloadCache.has(cacheKey)) {
|
||||
const cached = downloadCache.get(cacheKey);
|
||||
return cached ? getRelativePath(htmlFilePath, cached) : null;
|
||||
}
|
||||
|
||||
// Проверяем, существует ли файл локально
|
||||
if (existsSync(localPath)) {
|
||||
downloadCache.set(cacheKey, localPath);
|
||||
stats.alreadyLocal++;
|
||||
return getRelativePath(htmlFilePath, localPath);
|
||||
}
|
||||
|
||||
// Проверяем, нужно ли скачивать этот URL
|
||||
if (!shouldDownloadUrl(absoluteUrl)) {
|
||||
downloadCache.set(cacheKey, null);
|
||||
stats.skippedExternal = (stats.skippedExternal || 0) + 1;
|
||||
return null; // Не заменяем ссылки на внешние ресурсы
|
||||
}
|
||||
|
||||
if (!inFlightDownloads.has(cacheKey)) {
|
||||
const downloadPromise = (async () => {
|
||||
try {
|
||||
console.log(` 📥 Скачиваю: ${absoluteUrl}`);
|
||||
const downloadedPath = await downloadFile(absoluteUrl, localPath);
|
||||
downloadCache.set(cacheKey, downloadedPath);
|
||||
stats.filesDownloaded++;
|
||||
console.log(` ✅ Скачано: ${path.relative(MIRROR_DIR, downloadedPath)}`);
|
||||
return downloadedPath;
|
||||
} catch (err) {
|
||||
console.log(` ❌ Не удалось скачать: ${absoluteUrl} (${err.message})`);
|
||||
downloadCache.set(cacheKey, null);
|
||||
stats.downloadsFailed++;
|
||||
return null;
|
||||
} finally {
|
||||
inFlightDownloads.delete(cacheKey);
|
||||
}
|
||||
})();
|
||||
|
||||
inFlightDownloads.set(cacheKey, downloadPromise);
|
||||
}
|
||||
|
||||
const downloadedPath = await inFlightDownloads.get(cacheKey);
|
||||
return downloadedPath ? getRelativePath(htmlFilePath, downloadedPath) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка одного HTML файла
|
||||
*/
|
||||
async function processHtmlFile(filePath) {
|
||||
console.log(`\n📄 Обрабатываю: ${path.relative(MIRROR_DIR, filePath)}`);
|
||||
|
||||
let content = await fs.readFile(filePath, "utf-8");
|
||||
const originalContent = content;
|
||||
|
||||
// Пытаемся найти base URL из содержимого (тег <base> или мета-теги)
|
||||
let baseUrl = null;
|
||||
const baseMatch = content.match(/<base\s+href\s*=\s*["']([^"']+)["']/i);
|
||||
if (baseMatch) {
|
||||
baseUrl = baseMatch[1];
|
||||
}
|
||||
|
||||
const urls = extractUrls(content);
|
||||
const replacements = await mapWithConcurrency(urls, DOWNLOAD_CONCURRENCY, async (url) => {
|
||||
const newPath = await processUrl(url, filePath, baseUrl);
|
||||
return { url, newPath };
|
||||
});
|
||||
|
||||
for (const { url, newPath } of replacements) {
|
||||
if (!newPath) continue;
|
||||
|
||||
const newContent = replaceUrlReferences(content, url, newPath);
|
||||
if (newContent !== content) {
|
||||
content = newContent;
|
||||
stats.linksFixed++;
|
||||
}
|
||||
}
|
||||
|
||||
// Сохраняем файл если были изменения
|
||||
if (content !== originalContent) {
|
||||
await fs.writeFile(filePath, content, "utf-8");
|
||||
console.log(` 💾 Файл обновлён`);
|
||||
}
|
||||
|
||||
stats.filesProcessed++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Главная функция
|
||||
*/
|
||||
async function main() {
|
||||
console.log("🚀 Запуск обработки файлов в папке mirror\n");
|
||||
console.log("=" .repeat(60));
|
||||
console.log(`⚙️ Параллельных загрузок: ${DOWNLOAD_CONCURRENCY}, timeout: ${DOWNLOAD_TIMEOUT}ms`);
|
||||
|
||||
// Получаем все HTML файлы
|
||||
const htmlFiles = await getAllFiles(MIRROR_DIR, [".html", ".htm"]);
|
||||
console.log(`\n📁 Найдено HTML файлов: ${htmlFiles.length}\n`);
|
||||
|
||||
// Обрабатываем файлы
|
||||
for (const file of htmlFiles) {
|
||||
try {
|
||||
await processHtmlFile(file);
|
||||
} catch (err) {
|
||||
console.error(`❌ Ошибка при обработке ${file}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Выводим статистику
|
||||
console.log("\n" + "=".repeat(60));
|
||||
console.log("📊 СТАТИСТИКА:");
|
||||
console.log("=".repeat(60));
|
||||
console.log(` 📄 Обработано файлов: ${stats.filesProcessed}`);
|
||||
console.log(` 🔗 Исправлено ссылок: ${stats.linksFixed}`);
|
||||
console.log(` 📥 Скачано файлов: ${stats.filesDownloaded}`);
|
||||
console.log(` ❌ Не удалось скачать: ${stats.downloadsFailed}`);
|
||||
console.log(` ✅ Уже локальные: ${stats.alreadyLocal}`);
|
||||
console.log(` ⏭️ Пропущено внешних: ${stats.skippedExternal || 0}`);
|
||||
console.log("=".repeat(60));
|
||||
console.log("\n✨ Готово!");
|
||||
}
|
||||
|
||||
// Запуск
|
||||
main().catch(console.error);
|
||||
Generated
+60
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"name": "dfwebjs_download",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "dfwebjs_download",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"playwright": "^1.58.1"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.58.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.1.tgz",
|
||||
"integrity": "sha512-+2uTZHxSCcxjvGc5C891LrS1/NlxglGxzrC4seZiVjcYVQfUa87wBL6rTDqzGjuoWNjnBzRqKmF6zRYGMvQUaQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.58.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.58.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.1.tgz",
|
||||
"integrity": "sha512-bcWzOaTxcW+VOOGBCQgnaKToLJ65d6AqfLVKEWvexyS3AS6rbXl+xdpYRMGSRBClPvyj44njOWoxjNdL/H9UNg==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "dfwebjs_download",
|
||||
"version": "1.0.0",
|
||||
"description": "Установи зависимости:\r npm init -y\r npm i playwright",
|
||||
"main": "crawl.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"playwright": "^1.58.1"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user