// 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 = /\s]+["']?\s*\/?>(?=\s*<\/meta>)?|\s]+["']?\s*\/?\s*>/gi; const metaContentType = /]*>/gi; let updated = html.replace(metaCharset, ''); updated = updated.replace(metaContentType, ''); if (updated === html) { const headTag = /]*>/i; const match = updated.match(headTag); if (match && match.index != null) { const insertAt = match.index + match[0].length; updated = updated.slice(0, insertAt) + "\n " + 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); });