ваше сообщение коммита

This commit is contained in:
2026-02-20 16:46:17 +03:00
parent ca1016c6e0
commit 6a41eed575
5 changed files with 181 additions and 18 deletions

View File

@@ -32,7 +32,24 @@ RUN echo 'Acquire::Retries "10";' > /etc/apt/apt.conf.d/80-retries && \
curl \ curl \
ca-certificates \ ca-certificates \
openssh-client \ 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++ || \ (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++)) && \ (sleep 10 && apt-get update && apt-get install -y --no-install-recommends gcc-12 g++-12 g++)) && \
apt-get install -f -y || true && \ apt-get install -f -y || true && \

View File

@@ -2118,7 +2118,9 @@ router.get('/public/robots.txt', async (req, res) => {
const robotsContent = `User-agent: * const robotsContent = `User-agent: *
Allow: / Allow: /
Allow: /blog Allow: /blog
Allow: /blog/
Allow: /content/published Allow: /content/published
Allow: /content/published/
Disallow: /api/ Disallow: /api/
Disallow: /ws Disallow: /ws
Disallow: /admin/ Disallow: /admin/

View File

@@ -13,8 +13,13 @@
const puppeteer = require('puppeteer'); const puppeteer = require('puppeteer');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const http = require('http');
const https = require('https');
const { initDbPool, getQuery } = require('../db'); const { initDbPool, getQuery } = require('../db');
// URL бэкенда для запроса статей (тот же контейнер)
const BACKEND_API_URL = process.env.BACKEND_API_URL || 'http://localhost:8000';
// Конфигурация // Конфигурация
// В Docker используем имя контейнера, локально - localhost // В Docker используем имя контейнера, локально - localhost
const BASE_URL = process.env.PRERENDER_BASE_URL || const BASE_URL = process.env.PRERENDER_BASE_URL ||
@@ -41,11 +46,12 @@ function ensureDir(dir) {
async function waitForContent(page, selector, timeout = TIMEOUT) { async function waitForContent(page, selector, timeout = TIMEOUT) {
try { try {
await page.waitForSelector(selector, { timeout }); await page.waitForSelector(selector, { timeout });
// Дополнительная задержка для полной загрузки контента // Дополнительная задержка для полной загрузки контента и рендера (API + Vue)
// Используем setTimeout вместо устаревшего waitForTimeout await new Promise(resolve => setTimeout(resolve, 5000));
await new Promise(resolve => setTimeout(resolve, 2000));
} catch (error) { } catch (error) {
console.warn(`[pre-render] Селектор ${selector} не найден, продолжаем...`); 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.setDefaultNavigationTimeout(TIMEOUT);
page.setDefaultTimeout(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}`); console.log(`[pre-render] Загрузка: ${url}`);
await page.goto(url, { await page.goto(url, {
waitUntil: 'networkidle2', waitUntil: 'domcontentloaded',
timeout: TIMEOUT timeout: TIMEOUT
}); });
// Ждем загрузки контента // Дождаться ответа API (если задано), затем дать время на рендер Vue
if (waitApiPromise) {
await waitApiPromise;
await new Promise(resolve => setTimeout(resolve, 6000));
}
// Ждем появления контента по селектору (долгая пауза на случай медленного API)
if (options.waitForSelector) { if (options.waitForSelector) {
await waitForContent(page, options.waitForSelector); await waitForContent(page, options.waitForSelector, 45000);
} }
// Получаем HTML // Получаем HTML
@@ -126,6 +154,84 @@ function optimizeHtml(html, url) {
return optimized; return optimized;
} }
/**
* Экранирует HTML для безопасной вставки в текст/атрибуты
*/
function escapeHtml(s) {
if (s == null) return '';
const str = String(s);
return str
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/**
* Загружает статью по 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 `<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${title}</title>
<meta name="description" content="${description}">
<link rel="canonical" href="${canonical}">
<meta property="og:title" content="${title}">
<meta property="og:description" content="${description}">
<meta property="og:url" content="${canonical}">
<meta property="og:type" content="article">
<meta name="robots" content="index, follow">
</head>
<body>
<article class="docs-content">
<header class="page-header">
<h1 class="page-title">${title}</h1>
${article.summary ? `<p class="page-summary">${escapeHtml(article.summary)}</p>` : ''}
</header>
<div class="page-content">${content}</div>
</article>
</body>
</html>`;
}
/** /**
* Очищает slug для использования в имени файла * Очищает slug для использования в имени файла
*/ */
@@ -205,6 +311,19 @@ async function preRenderBlog(options = {}) {
// Создаем директорию для вывода // Создаем директорию для вывода
ensureDir(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; let browser;
@@ -252,27 +371,35 @@ async function preRenderBlog(options = {}) {
articlesToRender = articles; articlesToRender = articles;
} }
const publicBaseUrl = process.env.PUBLIC_SITE_URL || (BASE_URL.startsWith('https') ? BASE_URL : 'https://hb3-accelerator.com');
for (const article of articlesToRender) { for (const article of articlesToRender) {
try { try {
// Проверяем, что slug валидный
if (!article.slug || typeof article.slug !== 'string' || article.slug.trim() === '') { if (!article.slug || typeof article.slug !== 'string' || article.slug.trim() === '') {
console.warn(`[pre-render] Пропущена статья с невалидным slug: ${article.id}`); console.warn(`[pre-render] Пропущена статья с невалидным slug: ${article.id}`);
continue; continue;
} }
const sanitizedSlug = sanitizeSlug(article.slug); const sanitizedSlug = sanitizeSlug(article.slug);
console.log(`[pre-render] Рендеринг статьи: ${sanitizedSlug} (${article.title})`); console.log(`[pre-render] Рендеринг статьи: ${sanitizedSlug} (${article.title})`);
let articleHtml = null;
const articleHtml = await renderPage(browser, `${BASE_URL}/blog/${encodeURIComponent(article.slug)}`, { try {
waitForSelector: '.docs-content, .article-view' 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'
}); });
}
// Сохраняем в файл с именем slug (используем sanitized slug для безопасности) }
if (articleHtml) {
const filePath = path.join(OUTPUT_DIR, `${sanitizedSlug}.html`); const filePath = path.join(OUTPUT_DIR, `${sanitizedSlug}.html`);
saveHtml(articleHtml, filePath); saveHtml(articleHtml, filePath);
}
} catch (error) { } catch (error) {
console.error(`[pre-render] Ошибка при рендеринге статьи ${article.slug}:`, error.message); console.error(`[pre-render] Ошибка при рендеринге статьи ${article.slug}:`, error.message);
// Продолжаем с другими статьями
} }
} }
} }

View File

@@ -60,6 +60,20 @@ http {
# ~*MSIE\ [1-9]\. 1; # ~*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 # HTTP сервер - редирект на HTTPS
server { server {
listen 80; listen 80;

View File

@@ -128,6 +128,7 @@ services:
volumes: volumes:
- ./backend:/app - ./backend:/app
- backend_node_modules:/app/node_modules - backend_node_modules:/app/node_modules
- ./frontend/dist:/app/frontend_dist
- ./ssl:/app/ssl:ro - ./ssl:/app/ssl:ro
# Доступ к Docker socket для управления контейнерами на VDS # Доступ к Docker socket для управления контейнерами на VDS
- /var/run/docker.sock:/var/run/docker.sock:ro - /var/run/docker.sock:/var/run/docker.sock:ro
@@ -246,6 +247,8 @@ services:
- /etc/letsencrypt:/etc/letsencrypt:ro - /etc/letsencrypt:/etc/letsencrypt:ro
# Webroot для certbot # Webroot для certbot
- /var/www/certbot:/var/www/certbot - /var/www/certbot:/var/www/certbot
# Pre-rendered blog (SEO: полный HTML для статей, без «ложной 404»)
- ./frontend/dist/blog:/usr/share/nginx/html/blog:ro
depends_on: depends_on:
- backend - backend
- frontend - frontend