Функция: добавлен скрипт для исправления внешних ссылок и загрузки ресурсов

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

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

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

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

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

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

- Создан файл `package-lock.json` для блокировки версий зависимостей для обеспечения согласованной установки.
This commit is contained in:
Dmitriy Fofanov
2026-03-21 22:07:50 +03:00
parent 8559716b7d
commit a2397f0d39
7 changed files with 1598 additions and 0 deletions
+33
View File
@@ -0,0 +1,33 @@
node_modules/
# Logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Runtime data
pids/
*.pid
*.seed
*.pid.lock
# Dependency directories
.pnpm-store/
# Build / coverage
dist/
coverage/
# Environment files
.env
.env.*
!.env.example
# IDE / OS files
.vscode/
.idea/
.DS_Store
Thumbs.db
+14
View File
@@ -0,0 +1,14 @@
Авторское право (c) 2026, Автор(ы) проекта
Разрешается использовать, копировать, изменять и/или распространять это программное обеспечение
в любых целях (с оплатой или без), при условии, что указанное выше уведомление об авторских правах
и данное разрешение будут включены во все копии.
ПРОГРАММНОЕ ОБЕСПЕЧЕНИЕ ПРЕДОСТАВЛЯЕТСЯ «КАК ЕСТЬ», И АВТОР ОТКАЗЫВАЕТСЯ ОТ ЛЮБЫХ ГАРАНТИЙ
В ОТНОШЕНИИ ДАННОГО ПРОГРАММНОГО ОБЕСПЕЧЕНИЯ, ВКЛЮЧАЯ ВСЕ ПОДРАЗУМЕВАЕМЫЕ ГАРАНТИИ
КОММЕРЧЕСКОЙ ПРИГОДНОСТИ И ПРИГОДНОСТИ ДЛЯ ОПРЕДЕЛЁННОЙ ЦЕЛИ.
НИ В КОЕМ СЛУЧАЕ АВТОР НЕ НЕСЁТ ОТВЕТСТВЕННОСТИ ЗА ЛЮБЫЕ ОСОБЫЕ, ПРЯМЫЕ, КОСВЕННЫЕ
ИЛИ ПОСЛЕДУЮЩИЕ УБЫТКИ, ЛИБО ЛЮБЫЕ УБЫТКИ ВООБЩЕ, ВОЗНИКШИЕ В РЕЗУЛЬТАТЕ ПОТЕРИ
ВОЗМОЖНОСТИ ИСПОЛЬЗОВАНИЯ, ДАННЫХ ИЛИ ПРИБЫЛИ, НЕЗАВИСИМО ОТ ТОГО, БЫЛО ЛИ ЭТО
СЛЕДСТВИЕМ ДОГОВОРА, НЕБРЕЖНОСТИ ИЛИ ИНОГО ДЕЛИКТА, ВОЗНИКШЕГО ИЗ ИСПОЛЬЗОВАНИЯ
ИЛИ НЕВОЗМОЖНОСТИ ИСПОЛЬЗОВАНИЯ ДАННОГО ПРОГРАММНОГО ОБЕСПЕЧЕНИЯ.
+213
View File
@@ -1,2 +1,215 @@
# mirror-forge
Node.js-проект для зеркалирования сайта в локальную папку `mirror/` с помощью Playwright (headless Chromium).
Проект включает два сценария:
1. `crawl.js` — обходит страницы сайта, сохраняет HTML и ресурсы (CSS/JS/изображения/шрифты и т.д.).
2. `fix-links.js` — пост-обработка уже скачанного зеркала: ищет внешние URL в HTML, догружает недостающие файлы и заменяет ссылки на локальные относительные пути.
---
## Возможности
- Обход внутренних страниц с очередью URL и ограничением по количеству страниц.
- Параллельная загрузка (`CONCURRENCY`) для ускорения зеркалирования.
- Перехват `response` в браузере и сохранение ассетов по `Content-Type`.
- Автоповторы при ошибках (`MAX_RETRIES`).
- Извлечение новых ссылок из финального DOM страницы, а не только из сырого HTML.
- Корректное завершение по `Ctrl+C` (graceful shutdown).
- Нормализация путей для Windows (замена недопустимых символов).
- Поддержка query-строк через хеширование имени файла, чтобы избежать перезаписи.
- Опциональная замена абсолютных ссылок в HTML на относительные (`FIX_LINKS`).
- Принудительное сохранение HTML в UTF-8 (`<meta charset="utf-8">`).
---
## Стек и требования
- Node.js 18+
- npm
- Playwright (`playwright`)
- ОС: Windows / Linux / macOS
> В `package.json` проект работает в режиме ES Modules (`"type": "module"`).
---
## Установка
```bash
npm install
npx playwright install
```
Если зависимостей ещё нет, альтернативно:
```bash
npm i playwright
npx playwright install
```
---
## Быстрый старт
### 1) Скачивание сайта
```bash
node crawl.js
```
### 2) Дополнительная фиксация ссылок (опционально)
```bash
node fix-links.js
```
---
## Как устроен `crawl.js`
### Основной поток
1. В очередь добавляется `BASE`.
2. URL обрабатываются батчами по `CONCURRENCY`.
3. Для каждой страницы:
- выполняется `page.goto(..., waitUntil: "domcontentloaded")`;
- даётся дополнительная пауза `WAIT_AFTER_LOAD`;
- выполняется попытка дождаться `networkidle`;
- через `page.on("response")` сохраняются ассеты;
- HTML сохраняется в `mirror/`;
- извлекаются новые ссылки и добавляются в очередь.
4. При ошибках применяются автоповторы до `MAX_RETRIES`.
### Что скачивается
- HTML-документы.
- Скрипты, стили, изображения, шрифты.
- XHR/fetch-ответы (с ограничениями, см. ниже).
### Что фильтруется/ограничивается
- Обход ограничен разрешёнными URL-префиксами (`ALLOWED_URL_PREFIXES`) и origin, вычисленными из них.
- JSON-ответы без расширения `.json` пропускаются в загрузчике ассетов, чтобы не перезаписывать HTML.
- URL с неподдерживаемыми схемами (`data:`, `javascript:`, `mailto:`, `tel:`, `blob:`) игнорируются.
- Ассеты (по расширению) не попадают в очередь страниц.
---
## Конфигурация `crawl.js`
Ключевые параметры находятся вверху файла:
- `BASE` — стартовый URL обхода.
- `OUT_DIR` — папка вывода (по умолчанию `mirror`).
- `MAX_PAGES` — лимит страниц для обхода.
- `CONCURRENCY` — количество одновременно открытых вкладок.
- `WAIT_AFTER_LOAD` — задержка после `domcontentloaded`.
- `MAX_RETRIES` — число повторных попыток для страницы.
- `REQUEST_TIMEOUT` — таймаут навигации `page.goto`.
- `USER_AGENT` — User-Agent браузера.
- `FIX_LINKS` — включить замену ссылок в HTML на относительные в момент сохранения.
- `ALLOWED_URL_PREFIXES` — список URL-префиксов, которые разрешено обходить/скачивать.
---
## Как формируются локальные пути
- URL-path сохраняется как путь внутри `mirror/`.
- Путь, заканчивающийся на `/`, сохраняется как `.../index.html` (через `index` + расширение).
- Query-строка хешируется (`md5`, 8 символов) и добавляется к имени файла.
- Если у URL нет расширения, оно определяется по `Content-Type`; если определить нельзя — используется `.html`.
- Символы, недопустимые в Windows-путях, заменяются на `_`.
---
## Как устроен `fix-links.js`
`fix-links.js` предназначен для пост-обработки уже скачанного зеркала.
Что делает скрипт:
1. Рекурсивно находит HTML/HTM-файлы в `mirror/`.
2. Извлекает URL из `src/href/srcset/poster/data-src/url(...)`.
3. Для внешних и отсутствующих относительных ссылок:
- проверяет домены по правилам `ALLOWED_DOMAINS` и `SKIP_DOMAINS`;
- скачивает ресурсы в локальную структуру с ограничением по конкурентности;
- заменяет абсолютные ссылки на относительные пути.
4. Ведёт статистику: обработано файлов, исправлено ссылок, скачано/пропущено/ошибки.
Ключевые параметры в `fix-links.js`:
- `MIRROR_DIR` — папка зеркала.
- `DOWNLOAD_TIMEOUT` — таймаут загрузки одного ресурса.
- `DOWNLOAD_CONCURRENCY` — число одновременных загрузок при пост-обработке.
- `MAX_RETRIES` — число повторов загрузки.
- `ALLOWED_DOMAINS` — явно разрешённые домены для скачивания.
- `SKIP_DOMAINS` — домены, которые нужно пропускать.
---
## Логи и статус
### `crawl.js`
- `[start]` — старт обхода.
- `[config]` — активные параметры.
- `[crawled]` — страница сохранена.
- `[retry x/y]` — повторная попытка.
- `[gave up]` — страница не скачана после всех повторов.
- `[done]` — итоговая статистика.
### `fix-links.js`
- `📄 Обрабатываю` — файл в обработке.
- `📥 Скачиваю` / `✅ Скачано` / `❌ Не удалось скачать`.
- При неудачной загрузке исходная ссылка сохраняется, чтобы не превращать рабочий внешний URL в битый локальный путь.
- Итоговый блок `📊 СТАТИСТИКА`.
---
## Ограничения
- Это не «пиксель-в-пиксель офлайн-клон» сложных SPA/PWA.
- Контент, подгружаемый только после действий пользователя (скролл/клик/hover), может не попасть в зеркало.
- Внешние сервисы аналитики/соцсети/CDN могут оставаться внешними ссылками (зависит от доменных правил).
- API-данные с динамическими эндпоинтами не гарантируется получить полностью.
---
## Рекомендации по эксплуатации
- Начинайте с меньшего `MAX_PAGES` и `CONCURRENCY`, затем увеличивайте.
- При частых таймаутах увеличьте `REQUEST_TIMEOUT` и/или `WAIT_AFTER_LOAD`.
- Если сервер ограничивает запросы, уменьшите `CONCURRENCY`.
- Перед массовым запуском проверьте политики сайта (`robots.txt`, Terms of Service) и правовые ограничения.
---
## Типичный workflow
1. Настроить `BASE` и `ALLOWED_URL_PREFIXES` в `crawl.js`.
2. Запустить `node crawl.js`.
3. Проверить структуру `mirror/`.
4. При необходимости запустить `node fix-links.js`.
5. Открывать локальные HTML из `mirror/` для анализа/архивации/офлайн-просмотра.
---
## Структура проекта
```text
.
├─ crawl.js
├─ fix-links.js
├─ package.json
├─ README.md
└─ mirror/ # создаётся после запуска
```
---
## Лицензия
ISC (см. `package.json` и `LICENSE`).
+641
View File
@@ -0,0 +1,641 @@
// 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);
});
+621
View File
@@ -0,0 +1,621 @@
// 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);
+60
View File
@@ -0,0 +1,60 @@
{
"name": "dfwebjs_download",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dfwebjs_download",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"playwright": "^1.58.1"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.58.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.1.tgz",
"integrity": "sha512-+2uTZHxSCcxjvGc5C891LrS1/NlxglGxzrC4seZiVjcYVQfUa87wBL6rTDqzGjuoWNjnBzRqKmF6zRYGMvQUaQ==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.58.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.1.tgz",
"integrity": "sha512-bcWzOaTxcW+VOOGBCQgnaKToLJ65d6AqfLVKEWvexyS3AS6rbXl+xdpYRMGSRBClPvyj44njOWoxjNdL/H9UNg==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
}
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"name": "dfwebjs_download",
"version": "1.0.0",
"description": "Установи зависимости:\r npm init -y\r npm i playwright",
"main": "crawl.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "module",
"dependencies": {
"playwright": "^1.58.1"
}
}