/** * 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 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')); // Путь к шаблону index.html фронтенда (для app-shell) const FRONTEND_INDEX_HTML = process.env.PRERENDER_INDEX_TEMPLATE || (process.env.NODE_ENV === 'production' ? '/app/frontend_dist/index.html' : path.join(__dirname, '../../frontend/dist/index.html')); const TIMEOUT = 30000; // 30 секунд на загрузку страницы /** * Загружает app-shell (index.html) по URL, если локальный файл недоступен (например на VDS нет frontend/dist). */ function fetchAppShellFromUrl(urlString) { return new Promise((resolve, reject) => { let url; try { url = new URL(urlString); } catch (e) { reject(new Error('Неверный URL для app-shell: ' + urlString)); return; } const mod = url.protocol === 'https:' ? https : http; const req = mod.request(url, { method: 'GET', timeout: 15000 }, (res) => { if (res.statusCode !== 200) { reject(new Error(`HTTP ${res.statusCode}`)); return; } const chunks = []; res.on('data', (chunk) => chunks.push(chunk)); res.on('end', () => { try { resolve(Buffer.concat(chunks).toString('utf8')); } catch (e) { reject(e); } }); }); req.on('error', reject); req.on('timeout', () => { req.destroy(); reject(new Error('Timeout')); }); req.end(); }); } /** * Возвращает HTML app-shell: сначала из файла, при неудаче — по URL (PRERENDER_BASE_URL). */ async function getAppShellTemplate() { try { const html = fs.readFileSync(FRONTEND_INDEX_HTML, 'utf8'); if (html && html.includes('
. const appWithBodyRegex = /
]*>[\s\S]*<\/div>\s*<\/body>/i; if (appWithBodyRegex.test(html)) { return html.replace(appWithBodyRegex, '
\n'); } return html; } /** * Создает директорию если её нет */ 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 страницы статьи, встраивая контент в app-shell Vite (index.html), * чтобы и SEO, и пользователь видели нормальную оформленную страницу. * @param {object} article - данные статьи * @param {string} baseUrl - базовый URL сайта * @param {string|null} appShellTemplate - HTML index.html (из файла или по URL) */ function buildArticleHtml(article, baseUrl, appShellTemplate) { 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) : ''; // HTML фрагмент статьи, который будет вставлен внутрь #app const articleInnerHtml = `
${content}
`; const template = appShellTemplate; if (template && typeof template === 'string' && template.includes('
]*>/i)) { html = html.replace( /]*>/i, `` ); } else { html = html.replace( /<\/head>/i, ` \n` ); } // Удаляем старые SEO-теги app-shell (чтобы не плодить дубли canonical/og/robots) html = html .replace(/]*>\s*/gi, '') .replace(/]*>\s*/gi, '') .replace(/]*>\s*/gi, '') .replace(/]*>\s*/gi, '') .replace(/]*>\s*/gi, '') .replace(/]*>\s*/gi, ''); // Канонический URL и OG‑мета const ogBlock = ` `; if (html.includes('')) { html = html.replace(/<\/head>/i, `${ogBlock}\n`); } // Вставляем контент внутрь #app, сохраняя весь app-shell (CSS + JS) const appWithBodyRegex = /
]*>[\s\S]*<\/div>\s*<\/body>/i; if (appWithBodyRegex.test(html)) { html = html.replace(appWithBodyRegex, `
${articleInnerHtml}
\n`); } else if (html.includes('
')) { html = html.replace('
', `
${articleInnerHtml}
`); } return html; } // Fallback: минимальный HTML (на случай, если index.html не найден) return ` ${title} ${articleInnerHtml} `; } /** * Очищает 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; } } /** * Проверяет, что HTML подходит для публикации как SEO-страница статьи. * Отсекаем error-state, чтобы не сохранять "Документ не найден" как индексируемую страницу. */ function isRenderableArticleHtml(html, article = {}) { if (!html || typeof html !== 'string') return false; const lower = html.toLowerCase(); const errorMarkers = [ 'документ не найден', 'страница не найдена', 'запрашиваемый документ не существует или не опубликован', 'class="error-state"', "class='error-state'" ]; if (errorMarkers.some((marker) => lower.includes(String(marker).toLowerCase()))) { return false; } // Минимальная структурная проверка контента статьи const hasArticleRoot = lower.includes('docs-content') || lower.includes(' 0 && !lower.includes(title)) { return false; } } return true; } /** * Получает список статей блога через тот же API, что использует SPA. * Это гарантирует, что список для пререндеринга совпадает со списком на /blog. */ async function getBlogArticles() { try { const url = new URL('/api/pages/blog/all', BACKEND_API_URL); const mod = url.protocol === 'https:' ? https : http; console.log('[pre-render] Запрос списка статей через API:', url.toString()); const articles = await new Promise((resolve, reject) => { const req = mod.request(url, { method: 'GET', timeout: 15000 }, (res) => { const chunks = []; res.on('data', (chunk) => chunks.push(chunk)); res.on('end', () => { try { const body = Buffer.concat(chunks).toString('utf8'); if (res.statusCode !== 200) { console.warn('[pre-render] API /api/pages/blog/all вернул статус', res.statusCode, 'тело:', body); return reject(new Error(`API /api/pages/blog/all вернул ${res.statusCode}`)); } const data = JSON.parse(body); if (!Array.isArray(data)) { console.warn('[pre-render] API /api/pages/blog/all вернул не массив, формат:', typeof data); return resolve([]); } resolve(data); } catch (e) { console.error('[pre-render] Ошибка парсинга ответа /api/pages/blog/all:', e.message); reject(e); } }); }); req.on('error', (err) => { reject(err); }); req.on('timeout', () => { req.destroy(); reject(new Error('Timeout /api/pages/blog/all')); }); req.end(); }); console.log(`[pre-render] Список статей из API: ${articles.length}`); // Фильтруем на всякий случай и оставляем только статьи с валидным slug return (articles || []) .filter((a) => a && typeof a.slug === 'string' && a.slug.trim() !== '') .map((a) => ({ id: a.id, slug: a.slug, title: a.title || a.slug, })); } catch (error) { console.error('[pre-render] Ошибка получения статей через API /api/pages/blog/all:', error.message || 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); // Удаляем старый blog/index.html, чтобы /blog всегда отдавал корневой SPA (не «Нет статей в блоге») const blogIndexPath = path.join(OUTPUT_DIR, 'index.html'); try { if (fs.existsSync(blogIndexPath)) { fs.unlinkSync(blogIndexPath); console.log('[pre-render] Удалён старый blog/index.html — /blog будет отдавать SPA'); } } catch (e) { console.warn('[pre-render] Не удалось удалить blog/index.html:', e.message); } // Старые .html статей переименовываем в .bak перед повторной генерацией try { const entries = fs.readdirSync(OUTPUT_DIR); for (const name of entries) { if (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); } try { // Список статей больше не пререндерим в index.html, // чтобы /blog всегда работал как чистый SPA и не ломался при F5. // Получаем список статей для индивидуального пререндеринга const articles = await getBlogArticles(); console.log(`[pre-render] Найдено статей: ${articles.length}`); if (renderArticles && articles.length > 0) { // Загружаем app-shell один раз: из файла или по URL (на VDS файла нет — качаем с сайта) let appShellTemplate = await getAppShellTemplate(); // Рендерим статьи 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, appShellTemplate); console.log(`[pre-render] Статья получена через API (SSG)`); } catch (apiErr) { console.warn(`[pre-render] API недоступен (${apiErr.message}), пропускаем статью без browser fallback`); } if (articleHtml && isRenderableArticleHtml(articleHtml, article)) { const filePath = path.join(OUTPUT_DIR, `${sanitizedSlug}.html`); saveHtml(articleHtml, filePath); } else { console.warn( `[pre-render] Пропущено сохранение ${sanitizedSlug}: получен невалидный HTML (error-state или пустой контент)` ); } } 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; } } // Если скрипт запущен напрямую 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 };