449 lines
16 KiB
JavaScript
449 lines
16 KiB
JavaScript
/**
|
||
* Copyright (c) 2024-2025 Тарабанов Александр Викторович
|
||
* 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, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
/**
|
||
* Загружает статью по 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 };
|
||
|