/** * 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(/]*>(?!.*application\/ld\+json)[\s\S]*?<\/script>/gi, ''); // Оставляем JSON-LD разметку optimized = optimized.replace(/]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi, ''); // Удаляем 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, '''); } /** * Загружает статью по 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 ` ${title}
${content}
`; } /** * Очищает 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 };