${title}
+ ${article.summary ? `${escapeHtml(article.summary)}
` : ''} +diff --git a/backend/Dockerfile b/backend/Dockerfile index ec69016..309de52 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -32,7 +32,24 @@ RUN echo 'Acquire::Retries "10";' > /etc/apt/apt.conf.d/80-retries && \ curl \ ca-certificates \ openssh-client \ - sshpass && \ + sshpass \ + libglib2.0-0 \ + libnss3 \ + libnspr4 \ + libatk1.0-0 \ + libatk-bridge2.0-0 \ + libcups2 \ + libdrm2 \ + libxkbcommon0 \ + libxcomposite1 \ + libxdamage1 \ + libxfixes3 \ + libxrandr2 \ + libgbm1 \ + libasound2 \ + libpango-1.0-0 \ + libcairo2 \ + libatspi2.0-0 && \ (apt-get install -y --no-install-recommends gcc-12 g++-12 g++ || \ (sleep 10 && apt-get update && apt-get install -y --no-install-recommends gcc-12 g++-12 g++)) && \ apt-get install -f -y || true && \ diff --git a/backend/routes/pages.js b/backend/routes/pages.js index 5c9a19a..eab2278 100644 --- a/backend/routes/pages.js +++ b/backend/routes/pages.js @@ -2118,7 +2118,9 @@ router.get('/public/robots.txt', async (req, res) => { const robotsContent = `User-agent: * Allow: / Allow: /blog +Allow: /blog/ Allow: /content/published +Allow: /content/published/ Disallow: /api/ Disallow: /ws Disallow: /admin/ diff --git a/backend/scripts/pre-render-blog.js b/backend/scripts/pre-render-blog.js index 2a43fda..bbe27a6 100644 --- a/backend/scripts/pre-render-blog.js +++ b/backend/scripts/pre-render-blog.js @@ -13,8 +13,13 @@ 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 || @@ -41,11 +46,12 @@ function ensureDir(dir) { async function waitForContent(page, selector, timeout = TIMEOUT) { try { await page.waitForSelector(selector, { timeout }); - // Дополнительная задержка для полной загрузки контента - // Используем setTimeout вместо устаревшего waitForTimeout - await new Promise(resolve => setTimeout(resolve, 2000)); + // Дополнительная задержка для полной загрузки контента и рендера (API + Vue) + await new Promise(resolve => setTimeout(resolve, 5000)); } catch (error) { console.warn(`[pre-render] Селектор ${selector} не найден, продолжаем...`); + // Всё равно даём время на подгрузку (API может быть медленнее) + await new Promise(resolve => setTimeout(resolve, 5000)); } } @@ -67,16 +73,38 @@ async function renderPage(browser, url, options = {}) { 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: 'networkidle2', + 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); + await waitForContent(page, options.waitForSelector, 45000); } // Получаем HTML @@ -126,6 +154,84 @@ function optimizeHtml(html, url) { 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)}
` : ''} +