// 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 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 из содержимого (тег или мета-теги) let baseUrl = null; const baseMatch = content.match(/ { 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);