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

This commit is contained in:
2026-04-22 13:41:56 +03:00
parent 1bf6b13246
commit ece395a60f
11 changed files with 444 additions and 164 deletions

View File

@@ -10,7 +10,6 @@
* GitHub: https://github.com/VC-HB3-Accelerator
*/
const puppeteer = require('puppeteer');
const fs = require('fs');
const path = require('path');
const http = require('http');
@@ -29,8 +28,94 @@ const OUTPUT_DIR = process.env.PRERENDER_OUTPUT_DIR ||
(process.env.NODE_ENV === 'production'
? '/app/frontend_dist/blog'
: path.join(__dirname, '../../frontend/dist/blog'));
// Путь к шаблону index.html фронтенда (для app-shell)
const FRONTEND_INDEX_HTML = process.env.PRERENDER_INDEX_TEMPLATE ||
(process.env.NODE_ENV === 'production'
? '/app/frontend_dist/index.html'
: path.join(__dirname, '../../frontend/dist/index.html'));
const TIMEOUT = 30000; // 30 секунд на загрузку страницы
/**
* Загружает app-shell (index.html) по URL, если локальный файл недоступен (например на VDS нет frontend/dist).
*/
function fetchAppShellFromUrl(urlString) {
return new Promise((resolve, reject) => {
let url;
try {
url = new URL(urlString);
} catch (e) {
reject(new Error('Неверный URL для app-shell: ' + urlString));
return;
}
const mod = url.protocol === 'https:' ? https : http;
const req = mod.request(url, { method: 'GET', timeout: 15000 }, (res) => {
if (res.statusCode !== 200) {
reject(new Error(`HTTP ${res.statusCode}`));
return;
}
const chunks = [];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => {
try {
resolve(Buffer.concat(chunks).toString('utf8'));
} catch (e) {
reject(e);
}
});
});
req.on('error', reject);
req.on('timeout', () => { req.destroy(); reject(new Error('Timeout')); });
req.end();
});
}
/**
* Возвращает HTML app-shell: сначала из файла, при неудаче — по URL (PRERENDER_BASE_URL).
*/
async function getAppShellTemplate() {
try {
const html = fs.readFileSync(FRONTEND_INDEX_HTML, 'utf8');
if (html && html.includes('<div id="app')) {
console.log('[pre-render] App-shell взят из файла:', FRONTEND_INDEX_HTML);
return html;
}
} catch (e) {
// файла нет (на VDS часто нет frontend/dist в backend)
}
const baseUrl = process.env.PRERENDER_BASE_URL || BASE_URL;
if (baseUrl && (baseUrl.startsWith('http://') || baseUrl.startsWith('https://'))) {
const shellUrl = baseUrl.replace(/\/$/, '') + '/';
try {
const html = await fetchAppShellFromUrl(shellUrl);
if (html && html.includes('<div id="app')) {
console.log('[pre-render] App-shell загружен по URL:', shellUrl);
return normalizeAppShell(html);
}
} catch (err) {
console.warn('[pre-render] Не удалось загрузить app-shell по URL:', err.message);
}
}
return null;
}
/**
* Нормализует app-shell:
* - очищает #app от уже отрендеренного контента (если он есть),
* - чтобы при пререндере не утащить случайный error-state в итоговый HTML.
*/
function normalizeAppShell(html) {
if (!html || typeof html !== 'string') return html;
// Оставляем пустой корневой контейнер приложения перед </body>.
const appWithBodyRegex = /<div id="app"[^>]*>[\s\S]*<\/div>\s*<\/body>/i;
if (appWithBodyRegex.test(html)) {
return html.replace(appWithBodyRegex, '<div id="app"></div>\n</body>');
}
return html;
}
/**
* Создает директорию если её нет
*/
@@ -199,13 +284,84 @@ function fetchArticleFromApi(slug) {
}
/**
* Собирает HTML страницы статьи из данных API (SSG без Puppeteer).
* Собирает HTML страницы статьи, встраивая контент в app-shell Vite (index.html),
* чтобы и SEO, и пользователь видели нормальную оформленную страницу.
* @param {object} article - данные статьи
* @param {string} baseUrl - базовый URL сайта
* @param {string|null} appShellTemplate - HTML index.html (из файла или по URL)
*/
function buildArticleHtml(article, baseUrl) {
function buildArticleHtml(article, baseUrl, appShellTemplate) {
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) : '';
// HTML фрагмент статьи, который будет вставлен внутрь #app
const articleInnerHtml = `
<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>
`;
const template = appShellTemplate;
if (template && typeof template === 'string' && template.includes('<div id="app')) {
let html = normalizeAppShell(template);
// Обновляем title
html = html.replace(/<title>[\s\S]*?<\/title>/i, `<title>${title}</title>`);
// Обновляем/добавляем description
if (html.match(/<meta\s+name=["']description["'][^>]*>/i)) {
html = html.replace(
/<meta\s+name=["']description["'][^>]*>/i,
`<meta name="description" content="${description}">`
);
} else {
html = html.replace(
/<\/head>/i,
` <meta name="description" content="${description}">\n</head>`
);
}
// Удаляем старые SEO-теги app-shell (чтобы не плодить дубли canonical/og/robots)
html = html
.replace(/<link\s+rel=["']canonical["'][^>]*>\s*/gi, '')
.replace(/<meta\s+property=["']og:url["'][^>]*>\s*/gi, '')
.replace(/<meta\s+property=["']og:title["'][^>]*>\s*/gi, '')
.replace(/<meta\s+property=["']og:description["'][^>]*>\s*/gi, '')
.replace(/<meta\s+property=["']og:type["'][^>]*>\s*/gi, '')
.replace(/<meta\s+name=["']robots["'][^>]*>\s*/gi, '');
// Канонический URL и OGмета
const ogBlock = `
<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">`;
if (html.includes('</head>')) {
html = html.replace(/<\/head>/i, `${ogBlock}\n</head>`);
}
// Вставляем контент внутрь #app, сохраняя весь app-shell (CSS + JS)
const appWithBodyRegex = /<div id="app"[^>]*>[\s\S]*<\/div>\s*<\/body>/i;
if (appWithBodyRegex.test(html)) {
html = html.replace(appWithBodyRegex, `<div id="app">${articleInnerHtml}</div>\n</body>`);
} else if (html.includes('<div id="app"></div>')) {
html = html.replace('<div id="app"></div>', `<div id="app">${articleInnerHtml}</div>`);
}
return html;
}
// Fallback: минимальный HTML (на случай, если index.html не найден)
return `<!DOCTYPE html>
<html lang="ru">
<head>
@@ -221,13 +377,7 @@ function buildArticleHtml(article, baseUrl) {
<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>
${articleInnerHtml}
</body>
</html>`;
}
@@ -265,32 +415,99 @@ function saveHtml(html, filePath) {
}
/**
* Получает список статей блога из БД
* Проверяет, что HTML подходит для публикации как SEO-страница статьи.
* Отсекаем error-state, чтобы не сохранять "Документ не найден" как индексируемую страницу.
*/
function isRenderableArticleHtml(html, article = {}) {
if (!html || typeof html !== 'string') return false;
const lower = html.toLowerCase();
const errorMarkers = [
'документ не найден',
'страница не найдена',
'запрашиваемый документ не существует или не опубликован',
'class="error-state"',
"class='error-state'"
];
if (errorMarkers.some((marker) => lower.includes(String(marker).toLowerCase()))) {
return false;
}
// Минимальная структурная проверка контента статьи
const hasArticleRoot = lower.includes('docs-content') || lower.includes('<article');
if (!hasArticleRoot) return false;
if (article && article.title) {
const title = String(article.title).trim().toLowerCase();
if (title.length > 0 && !lower.includes(title)) {
return false;
}
}
return true;
}
/**
* Получает список статей блога через тот же API, что использует SPA.
* Это гарантирует, что список для пререндеринга совпадает со списком на /blog.
*/
async function getBlogArticles() {
try {
const query = getQuery();
if (!query) {
console.error('[pre-render] БД не инициализирована');
return [];
}
const tableName = 'admin_pages_simple';
const { rows } = await query(`
SELECT id, slug, title
FROM ${tableName}
WHERE visibility = 'public'
AND status = 'published'
AND show_in_blog = TRUE
AND slug IS NOT NULL
AND slug != ''
ORDER BY created_at DESC
`);
return rows || [];
const url = new URL('/api/pages/blog/all', BACKEND_API_URL);
const mod = url.protocol === 'https:' ? https : http;
console.log('[pre-render] Запрос списка статей через API:', url.toString());
const articles = await new Promise((resolve, reject) => {
const req = mod.request(url, { method: 'GET', timeout: 15000 }, (res) => {
const chunks = [];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => {
try {
const body = Buffer.concat(chunks).toString('utf8');
if (res.statusCode !== 200) {
console.warn('[pre-render] API /api/pages/blog/all вернул статус', res.statusCode, 'тело:', body);
return reject(new Error(`API /api/pages/blog/all вернул ${res.statusCode}`));
}
const data = JSON.parse(body);
if (!Array.isArray(data)) {
console.warn('[pre-render] API /api/pages/blog/all вернул не массив, формат:', typeof data);
return resolve([]);
}
resolve(data);
} catch (e) {
console.error('[pre-render] Ошибка парсинга ответа /api/pages/blog/all:', e.message);
reject(e);
}
});
});
req.on('error', (err) => {
reject(err);
});
req.on('timeout', () => {
req.destroy();
reject(new Error('Timeout /api/pages/blog/all'));
});
req.end();
});
console.log(`[pre-render] Список статей из API: ${articles.length}`);
// Фильтруем на всякий случай и оставляем только статьи с валидным slug
return (articles || [])
.filter((a) => a && typeof a.slug === 'string' && a.slug.trim() !== '')
.map((a) => ({
id: a.id,
slug: a.slug,
title: a.title || a.slug,
}));
} catch (error) {
console.error('[pre-render] Ошибка получения статей из БД:', error);
console.error('[pre-render] Ошибка получения статей через API /api/pages/blog/all:', error.message || error);
return [];
}
}
@@ -311,54 +528,41 @@ async function preRenderBlog(options = {}) {
// Создаем директорию для вывода
ensureDir(OUTPUT_DIR);
// Временно убираем старые .html статей, чтобы nginx отдавал SPA (index.html), а не пустой пререндер
// Удаляем старый blog/index.html, чтобы /blog всегда отдавал корневой SPA (не «Нет статей в блоге»)
const blogIndexPath = path.join(OUTPUT_DIR, 'index.html');
try {
if (fs.existsSync(blogIndexPath)) {
fs.unlinkSync(blogIndexPath);
console.log('[pre-render] Удалён старый blog/index.html — /blog будет отдавать SPA');
}
} catch (e) {
console.warn('[pre-render] Не удалось удалить blog/index.html:', e.message);
}
// Старые .html статей переименовываем в .bak перед повторной генерацией
try {
const entries = fs.readdirSync(OUTPUT_DIR);
for (const name of entries) {
if (name !== 'index.html' && name.endsWith('.html')) {
if (name.endsWith('.html')) {
const p = path.join(OUTPUT_DIR, name);
fs.renameSync(p, p + '.bak');
console.log(`[pre-render] Временно переименован: ${name} -> ${name}.bak`);
console.log(`[pre-render] Переименован: ${name} -> ${name}.bak`);
}
}
} catch (e) {
console.warn('[pre-render] Не удалось переименовать старые файлы:', e.message);
}
// Инициализируем браузер
let browser;
try {
browser = await puppeteer.launch({
headless: true,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
'--disable-software-rasterizer',
'--disable-extensions'
]
});
} catch (error) {
console.error('[pre-render] Ошибка запуска браузера:', error.message);
throw new Error(`Не удалось запустить браузер: ${error.message}`);
}
try {
// Рендерим список статей
if (renderList) {
console.log('[pre-render] Рендеринг списка статей...');
const listHtml = await renderPage(browser, `${BASE_URL}/blog`, {
waitForSelector: '.blog-articles, .empty-state, .loading-state'
});
saveHtml(listHtml, path.join(OUTPUT_DIR, 'index.html'));
}
// Получаем список статей
// Список статей больше не пререндерим в index.html,
// чтобы /blog всегда работал как чистый SPA и не ломался при F5.
// Получаем список статей для индивидуального пререндеринга
const articles = await getBlogArticles();
console.log(`[pre-render] Найдено статей: ${articles.length}`);
if (renderArticles && articles.length > 0) {
// Загружаем app-shell один раз: из файла или по URL (на VDS файла нет — качаем с сайта)
let appShellTemplate = await getAppShellTemplate();
// Рендерим статьи
let articlesToRender;
if (specificSlug) {
@@ -383,20 +587,18 @@ async function preRenderBlog(options = {}) {
let articleHtml = null;
try {
const data = await fetchArticleFromApi(article.slug);
articleHtml = buildArticleHtml(data, publicBaseUrl);
articleHtml = buildArticleHtml(data, publicBaseUrl, appShellTemplate);
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'
});
}
console.warn(`[pre-render] API недоступен (${apiErr.message}), пропускаем статью без browser fallback`);
}
if (articleHtml) {
if (articleHtml && isRenderableArticleHtml(articleHtml, article)) {
const filePath = path.join(OUTPUT_DIR, `${sanitizedSlug}.html`);
saveHtml(articleHtml, filePath);
} else {
console.warn(
`[pre-render] Пропущено сохранение ${sanitizedSlug}: получен невалидный HTML (error-state или пустой контент)`
);
}
} catch (error) {
console.error(`[pre-render] Ошибка при рендеринге статьи ${article.slug}:`, error.message);
@@ -408,15 +610,6 @@ async function preRenderBlog(options = {}) {
} catch (error) {
console.error('[pre-render] Критическая ошибка:', error);
throw error;
} finally {
// Закрываем браузер, если он был открыт
if (browser) {
try {
await browser.close();
} catch (closeError) {
console.error('[pre-render] Ошибка при закрытии браузера:', closeError.message);
}
}
}
}