- Создан файл `fix-links.js` для обработки загрузки внешних ресурсов и исправления ссылок в HTML-файлах. - Добавлена конфигурация для разрешенных и пропускаемых доменов, настроек загрузки и механизмов кэширования. - Реализованы функции для обработки файлов, обработки URL-адресов и одновременных загрузок. - Введено отслеживание статистики для обработанных файлов, исправленных ссылок и сбоев загрузки. Задача: инициализация package.json и package-lock.json - Добавлен файл `package.json` с метаданными проекта и зависимостями. - Создан файл `package-lock.json` для блокировки версий зависимостей для обеспечения согласованной установки.
642 lines
22 KiB
JavaScript
642 lines
22 KiB
JavaScript
// 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);
|
||
});
|