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

622 lines
19 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.
// 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);