Files
Dmitriy Fofanov a2397f0d39 Функция: добавлен скрипт для исправления внешних ссылок и загрузки ресурсов
- Создан файл `fix-links.js` для обработки загрузки внешних ресурсов и исправления ссылок в HTML-файлах.

- Добавлена ​​конфигурация для разрешенных и пропускаемых доменов, настроек загрузки и механизмов кэширования.

- Реализованы функции для обработки файлов, обработки URL-адресов и одновременных загрузок.

- Введено отслеживание статистики для обработанных файлов, исправленных ссылок и сбоев загрузки.

Задача: инициализация package.json и package-lock.json

- Добавлен файл `package.json` с метаданными проекта и зависимостями.

- Создан файл `package-lock.json` для блокировки версий зависимостей для обеспечения согласованной установки.
2026-03-21 22:07:50 +03:00

642 lines
22 KiB
JavaScript
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.
// 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);
});