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