${title}
${article.summary ? `${escapeHtml(article.summary)}
` : ''}/** * 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(/'); // Удаляем 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 `
${escapeHtml(article.summary)}
` : ''}