ваше сообщение коммита
This commit is contained in:
@@ -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 && \
|
||||||
|
|||||||
@@ -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/
|
||||||
|
|||||||
@@ -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, '<')
|
||||||
|
.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 `<!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)`);
|
||||||
// Сохраняем в файл с именем slug (используем sanitized slug для безопасности)
|
} catch (apiErr) {
|
||||||
const filePath = path.join(OUTPUT_DIR, `${sanitizedSlug}.html`);
|
console.warn(`[pre-render] API недоступен (${apiErr.message}), пробуем через браузер...`);
|
||||||
saveHtml(articleHtml, filePath);
|
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) {
|
} catch (error) {
|
||||||
console.error(`[pre-render] Ошибка при рендеринге статьи ${article.slug}:`, error.message);
|
console.error(`[pre-render] Ошибка при рендеринге статьи ${article.slug}:`, error.message);
|
||||||
// Продолжаем с другими статьями
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user