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 ` + + + + + ${title} + + + + + + + + + +
+ +
${content}
+
+ +`; +} + /** * Очищает slug для использования в имени файла */ @@ -205,6 +311,19 @@ async function preRenderBlog(options = {}) { // Создаем директорию для вывода 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; @@ -252,27 +371,35 @@ async function preRenderBlog(options = {}) { 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 { - // Проверяем, что slug валидный 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})`); - - const articleHtml = await renderPage(browser, `${BASE_URL}/blog/${encodeURIComponent(article.slug)}`, { - waitForSelector: '.docs-content, .article-view' - }); - - // Сохраняем в файл с именем slug (используем sanitized slug для безопасности) - const filePath = path.join(OUTPUT_DIR, `${sanitizedSlug}.html`); - saveHtml(articleHtml, filePath); + 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); - // Продолжаем с другими статьями } } } diff --git a/frontend/nginx-simple.conf b/frontend/nginx-simple.conf index faefc40..7e89bdf 100644 --- a/frontend/nginx-simple.conf +++ b/frontend/nginx-simple.conf @@ -60,6 +60,20 @@ http { # ~*MSIE\ [1-9]\. 1; } + # Редирект www на основной домен (устраняет 403 для www) + server { + listen 80; + server_name www.${DOMAIN}; + return 301 https://${DOMAIN}$request_uri; + } + server { + listen 443 ssl http2; + server_name www.${DOMAIN}; + ssl_certificate /etc/letsencrypt/live/${DOMAIN}/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/${DOMAIN}/privkey.pem; + return 301 https://${DOMAIN}$request_uri; + } + # HTTP сервер - редирект на HTTPS server { listen 80; diff --git a/webssh-agent/docker-compose.prod.yml b/webssh-agent/docker-compose.prod.yml index 2b9dcbe..89a9333 100644 --- a/webssh-agent/docker-compose.prod.yml +++ b/webssh-agent/docker-compose.prod.yml @@ -128,6 +128,7 @@ services: volumes: - ./backend:/app - backend_node_modules:/app/node_modules + - ./frontend/dist:/app/frontend_dist - ./ssl:/app/ssl:ro # Доступ к Docker socket для управления контейнерами на VDS - /var/run/docker.sock:/var/run/docker.sock:ro @@ -246,6 +247,8 @@ services: - /etc/letsencrypt:/etc/letsencrypt:ro # Webroot для certbot - /var/www/certbot:/var/www/certbot + # Pre-rendered blog (SEO: полный HTML для статей, без «ложной 404») + - ./frontend/dist/blog:/usr/share/nginx/html/blog:ro depends_on: - backend - frontend