Files
DLE/backend/scripts/pre-render-blog.js
2026-03-01 22:03:48 +03:00

449 lines
16 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.

/**
* Copyright (c) 2024-2026 Тарабанов Александр Викторович
* All rights reserved.
*
* This software is proprietary and confidential.
* Unauthorized copying, modification, or distribution is prohibited.
*
* For licensing inquiries: info@hb3-accelerator.com
* Website: https://hb3-accelerator.com
* GitHub: https://github.com/VC-HB3-Accelerator
*/
const puppeteer = require('puppeteer');
const fs = require('fs');
const path = require('path');
const http = require('http');
const https = require('https');
const { initDbPool, getQuery } = require('../db');
// URL бэкенда для запроса статей (тот же контейнер)
const BACKEND_API_URL = process.env.BACKEND_API_URL || 'http://localhost:8000';
// Конфигурация
// В Docker используем имя контейнера, локально - localhost
const BASE_URL = process.env.PRERENDER_BASE_URL ||
process.env.FRONTEND_URL ||
(process.env.NODE_ENV === 'production' ? 'http://dapp-frontend:5173' : 'http://localhost:5173');
const OUTPUT_DIR = process.env.PRERENDER_OUTPUT_DIR ||
(process.env.NODE_ENV === 'production'
? '/app/frontend_dist/blog'
: path.join(__dirname, '../../frontend/dist/blog'));
const TIMEOUT = 30000; // 30 секунд на загрузку страницы
/**
* Создает директорию если её нет
*/
function ensureDir(dir) {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
/**
* Ожидает загрузки контента на странице
*/
async function waitForContent(page, selector, timeout = TIMEOUT) {
try {
await page.waitForSelector(selector, { timeout });
// Дополнительная задержка для полной загрузки контента и рендера (API + Vue)
await new Promise(resolve => setTimeout(resolve, 5000));
} catch (error) {
console.warn(`[pre-render] Селектор ${selector} не найден, продолжаем...`);
// Всё равно даём время на подгрузку (API может быть медленнее)
await new Promise(resolve => setTimeout(resolve, 5000));
}
}
/**
* Рендерит страницу и возвращает HTML
*/
async function renderPage(browser, url, options = {}) {
if (!browser) {
throw new Error('Браузер не инициализирован');
}
const page = await browser.newPage();
try {
// Устанавливаем viewport
await page.setViewport({ width: 1920, height: 1080 });
// Устанавливаем таймауты
page.setDefaultNavigationTimeout(TIMEOUT);
page.setDefaultTimeout(TIMEOUT);
// Для статей: ждём ответ API /api/pages/blog/ перед снимком
let waitApiPromise = null;
if (options.waitForApiPattern) {
waitApiPromise = page.waitForResponse(
(response) => {
const ok = response.url().includes(options.waitForApiPattern) && response.status() === 200;
if (ok) console.log(`[pre-render] Получен ответ API: ${response.url().split('?')[0]}`);
return ok;
},
{ timeout: TIMEOUT }
).catch((err) => {
console.warn('[pre-render] Ожидание API истекло или ошибка:', err.message);
return null;
});
}
// Переходим на страницу
console.log(`[pre-render] Загрузка: ${url}`);
await page.goto(url, {
waitUntil: 'domcontentloaded',
timeout: TIMEOUT
});
// Дождаться ответа API (если задано), затем дать время на рендер Vue
if (waitApiPromise) {
await waitApiPromise;
await new Promise(resolve => setTimeout(resolve, 6000));
}
// Ждем появления контента по селектору (долгая пауза на случай медленного API)
if (options.waitForSelector) {
await waitForContent(page, options.waitForSelector, 45000);
}
// Получаем HTML
const html = await page.content();
if (!html || html.trim().length === 0) {
throw new Error('Получен пустой HTML');
}
// Оптимизируем HTML: удаляем скрипты, которые не нужны для SEO
const optimizedHtml = optimizeHtml(html, url);
return optimizedHtml;
} catch (error) {
console.error(`[pre-render] Ошибка при рендеринге ${url}:`, error.message);
throw error;
} finally {
try {
await page.close();
} catch (closeError) {
console.warn(`[pre-render] Ошибка при закрытии страницы: ${closeError.message}`);
}
}
}
/**
* Оптимизирует HTML для статического контента
*/
function optimizeHtml(html, url) {
// Удаляем скрипты, которые выполняются только на клиенте
// Но оставляем мета-теги и структуру
let optimized = html;
// Удаляем inline скрипты, которые не нужны для SEO
optimized = optimized.replace(/<script[^>]*>(?!.*application\/ld\+json)[\s\S]*?<\/script>/gi, '');
// Оставляем JSON-LD разметку
optimized = optimized.replace(/<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi,
'<script type="application/ld+json">$1</script>');
// Удаляем WebSocket подключения
optimized = optimized.replace(/new WebSocket\([^)]+\)/gi, '');
// Удаляем console.log для уменьшения размера
optimized = optimized.replace(/console\.(log|warn|error)\([^)]*\)/gi, '');
return optimized;
}
/**
* Экранирует HTML для безопасной вставки в текст/атрибуты
*/
function escapeHtml(s) {
if (s == null) return '';
const str = String(s);
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/**
* Загружает статью по slug через API бэкенда (без браузера).
* Используется для SSG: один источник данных, стабильный результат в Docker.
*/
function fetchArticleFromApi(slug) {
return new Promise((resolve, reject) => {
const url = new URL(`/api/pages/blog/${encodeURIComponent(slug)}`, BACKEND_API_URL);
const mod = url.protocol === 'https:' ? https : http;
const req = mod.request(url, { method: 'GET', timeout: 15000 }, (res) => {
if (res.statusCode !== 200) {
reject(new Error(`API вернул ${res.statusCode}`));
return;
}
const chunks = [];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => {
try {
const body = Buffer.concat(chunks).toString('utf8');
resolve(JSON.parse(body));
} catch (e) {
reject(e);
}
});
});
req.on('error', reject);
req.on('timeout', () => { req.destroy(); reject(new Error('Timeout')); });
req.end();
});
}
/**
* Собирает HTML страницы статьи из данных API (SSG без Puppeteer).
*/
function buildArticleHtml(article, baseUrl) {
const canonical = `${baseUrl}/blog/${encodeURIComponent(article.slug || '')}`;
const title = article.title ? escapeHtml(article.title) : 'Статья';
const description = article.summary ? escapeHtml(article.summary) : title;
const content = article.content != null ? String(article.content) : '';
return `<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${title}</title>
<meta name="description" content="${description}">
<link rel="canonical" href="${canonical}">
<meta property="og:title" content="${title}">
<meta property="og:description" content="${description}">
<meta property="og:url" content="${canonical}">
<meta property="og:type" content="article">
<meta name="robots" content="index, follow">
</head>
<body>
<article class="docs-content">
<header class="page-header">
<h1 class="page-title">${title}</h1>
${article.summary ? `<p class="page-summary">${escapeHtml(article.summary)}</p>` : ''}
</header>
<div class="page-content">${content}</div>
</article>
</body>
</html>`;
}
/**
* Очищает slug для использования в имени файла
*/
function sanitizeSlug(slug) {
if (!slug) return 'page';
// Удаляем небезопасные символы для имени файла
return slug.replace(/[^a-z0-9\-_]/gi, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
}
/**
* Сохраняет HTML в файл
*/
function saveHtml(html, filePath) {
try {
ensureDir(path.dirname(filePath));
// Проверяем, что путь безопасен (защита от path traversal)
const resolvedPath = path.resolve(filePath);
const outputDirResolved = path.resolve(OUTPUT_DIR);
if (!resolvedPath.startsWith(outputDirResolved)) {
throw new Error(`Небезопасный путь: ${filePath}`);
}
fs.writeFileSync(resolvedPath, html, 'utf8');
console.log(`[pre-render] Сохранено: ${resolvedPath}`);
} catch (error) {
console.error(`[pre-render] Ошибка сохранения файла ${filePath}:`, error.message);
throw error;
}
}
/**
* Получает список статей блога из БД
*/
async function getBlogArticles() {
try {
const query = getQuery();
if (!query) {
console.error('[pre-render] БД не инициализирована');
return [];
}
const tableName = 'admin_pages_simple';
const { rows } = await query(`
SELECT id, slug, title
FROM ${tableName}
WHERE visibility = 'public'
AND status = 'published'
AND show_in_blog = TRUE
AND slug IS NOT NULL
AND slug != ''
ORDER BY created_at DESC
`);
return rows || [];
} catch (error) {
console.error('[pre-render] Ошибка получения статей из БД:', error);
return [];
}
}
/**
* Основная функция pre-rendering
*/
async function preRenderBlog(options = {}) {
const {
renderList = true,
renderArticles = true,
specificSlug = null
} = options;
console.log('[pre-render] Начало pre-rendering блога...');
console.log(`[pre-render] Base URL: ${BASE_URL}`);
console.log(`[pre-render] Output dir: ${OUTPUT_DIR}`);
// Создаем директорию для вывода
ensureDir(OUTPUT_DIR);
// Временно убираем старые .html статей, чтобы nginx отдавал SPA (index.html), а не пустой пререндер
try {
const entries = fs.readdirSync(OUTPUT_DIR);
for (const name of entries) {
if (name !== 'index.html' && name.endsWith('.html')) {
const p = path.join(OUTPUT_DIR, name);
fs.renameSync(p, p + '.bak');
console.log(`[pre-render] Временно переименован: ${name} -> ${name}.bak`);
}
}
} catch (e) {
console.warn('[pre-render] Не удалось переименовать старые файлы:', e.message);
}
// Инициализируем браузер
let browser;
try {
browser = await puppeteer.launch({
headless: true,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
'--disable-software-rasterizer',
'--disable-extensions'
]
});
} catch (error) {
console.error('[pre-render] Ошибка запуска браузера:', error.message);
throw new Error(`Не удалось запустить браузер: ${error.message}`);
}
try {
// Рендерим список статей
if (renderList) {
console.log('[pre-render] Рендеринг списка статей...');
const listHtml = await renderPage(browser, `${BASE_URL}/blog`, {
waitForSelector: '.blog-articles, .empty-state, .loading-state'
});
saveHtml(listHtml, path.join(OUTPUT_DIR, 'index.html'));
}
// Получаем список статей
const articles = await getBlogArticles();
console.log(`[pre-render] Найдено статей: ${articles.length}`);
if (renderArticles && articles.length > 0) {
// Рендерим статьи
let articlesToRender;
if (specificSlug) {
// Фильтруем по slug (точное совпадение)
articlesToRender = articles.filter(a => a.slug && a.slug.trim() === specificSlug.trim());
if (articlesToRender.length === 0) {
console.warn(`[pre-render] Статья со slug "${specificSlug}" не найдена`);
}
} else {
articlesToRender = articles;
}
const publicBaseUrl = process.env.PUBLIC_SITE_URL || (BASE_URL.startsWith('https') ? BASE_URL : 'https://hb3-accelerator.com');
for (const article of articlesToRender) {
try {
if (!article.slug || typeof article.slug !== 'string' || article.slug.trim() === '') {
console.warn(`[pre-render] Пропущена статья с невалидным slug: ${article.id}`);
continue;
}
const sanitizedSlug = sanitizeSlug(article.slug);
console.log(`[pre-render] Рендеринг статьи: ${sanitizedSlug} (${article.title})`);
let articleHtml = null;
try {
const data = await fetchArticleFromApi(article.slug);
articleHtml = buildArticleHtml(data, publicBaseUrl);
console.log(`[pre-render] Статья получена через API (SSG)`);
} catch (apiErr) {
console.warn(`[pre-render] API недоступен (${apiErr.message}), пробуем через браузер...`);
if (browser) {
articleHtml = await renderPage(browser, `${BASE_URL}/blog/${encodeURIComponent(article.slug)}`, {
waitForApiPattern: '/api/pages/blog/',
waitForSelector: '.docs-content .page-header, .page-header'
});
}
}
if (articleHtml) {
const filePath = path.join(OUTPUT_DIR, `${sanitizedSlug}.html`);
saveHtml(articleHtml, filePath);
}
} catch (error) {
console.error(`[pre-render] Ошибка при рендеринге статьи ${article.slug}:`, error.message);
}
}
}
console.log('[pre-render] Pre-rendering завершен успешно!');
} catch (error) {
console.error('[pre-render] Критическая ошибка:', error);
throw error;
} finally {
// Закрываем браузер, если он был открыт
if (browser) {
try {
await browser.close();
} catch (closeError) {
console.error('[pre-render] Ошибка при закрытии браузера:', closeError.message);
}
}
}
}
// Если скрипт запущен напрямую
if (require.main === module) {
(async () => {
try {
// Инициализируем БД
await initDbPool();
// Парсим аргументы командной строки
const args = process.argv.slice(2);
const options = {
renderList: !args.includes('--no-list'),
renderArticles: !args.includes('--no-articles'),
specificSlug: args.find(arg => arg.startsWith('--slug='))?.split('=')[1] || null
};
await preRenderBlog(options);
process.exit(0);
} catch (error) {
console.error('[pre-render] Фатальная ошибка:', error);
process.exit(1);
}
})();
}
module.exports = { preRenderBlog };