ваше сообщение коммита
This commit is contained in:
@@ -134,6 +134,19 @@ const vdsRoutes = require('./routes/vds'); // Добавляем импорт м
|
||||
|
||||
const app = express();
|
||||
|
||||
// Публичные SEO endpoints не должны зависеть от сессий/БД сессий.
|
||||
// Иначе при таймаутах session store Google/гости получают 499 и фронт падает в error-state.
|
||||
function isPublicSeoEndpoint(url = '') {
|
||||
return (
|
||||
url.startsWith('/api/pages/blog/') ||
|
||||
url === '/api/pages/blog/all' ||
|
||||
url.startsWith('/api/pages/published/') ||
|
||||
url === '/api/settings/footer-dle' ||
|
||||
url === '/api/pages/public/sitemap.xml' ||
|
||||
url === '/api/pages/public/robots.txt'
|
||||
);
|
||||
}
|
||||
|
||||
// Указываем хост явно
|
||||
app.set('host', '0.0.0.0');
|
||||
app.set('port', process.env.PORT || 8000);
|
||||
@@ -165,6 +178,11 @@ app.use(
|
||||
|
||||
// Настройка сессии (используем геттер, чтобы всегда был актуальный middleware)
|
||||
app.use((req, res, next) => {
|
||||
if (isPublicSeoEndpoint(req.originalUrl || req.url || '')) {
|
||||
req.session = req.session || {};
|
||||
return next();
|
||||
}
|
||||
|
||||
try {
|
||||
sessionConfig.sessionMiddleware(req, res, (err) => {
|
||||
// Обрабатываем ошибки сессий (например, таймауты подключения к БД)
|
||||
@@ -209,6 +227,10 @@ app.use((req, res, next) => {
|
||||
|
||||
// Добавим middleware для проверки сессии
|
||||
app.use(async (req, res, next) => {
|
||||
if (isPublicSeoEndpoint(req.originalUrl || req.url || '')) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// console.log('Request cookies:', req.headers.cookie);
|
||||
// console.log('Session ID:', req.sessionID);
|
||||
|
||||
|
||||
@@ -23,9 +23,8 @@
|
||||
"fix-duplicates": "node scripts/fix-duplicate-identities.js",
|
||||
"deploy:multichain": "node scripts/deploy/deploy-multichain.js",
|
||||
"deploy:modules": "node scripts/deploy/deploy-modules.js",
|
||||
"generate:abi": "node scripts/generate-abi.js",
|
||||
"generate:flattened": "node scripts/generate-flattened.js",
|
||||
"compile:full": "npx hardhat compile && npm run generate:abi && npm run generate:flattened",
|
||||
"compile:full": "npx hardhat compile && npm run generate:flattened",
|
||||
"seed:legal": "node scripts/seed/legalTemplatesSeed.js",
|
||||
"import:legal": "node scripts/import-legal-docs.js",
|
||||
"prerender:blog": "node scripts/pre-render-blog.js",
|
||||
|
||||
@@ -2121,6 +2121,19 @@ Allow: /blog
|
||||
Allow: /blog/
|
||||
Allow: /content/published
|
||||
Allow: /content/published/
|
||||
|
||||
# Разрешаем Googlebot публичные API для рендера/проверки индексации
|
||||
Allow: /api/pages/blog/all
|
||||
Allow: /api/pages/blog/
|
||||
Allow: /api/pages/published/
|
||||
Allow: /api/settings/footer-dle
|
||||
Allow: /api/pages/public/sitemap.xml
|
||||
Allow: /api/pages/public/robots.txt
|
||||
|
||||
# Закрываем приватные и служебные API
|
||||
Disallow: /api/auth/
|
||||
Disallow: /api/admin/
|
||||
Disallow: /api/ws
|
||||
Disallow: /api/
|
||||
Disallow: /ws
|
||||
Disallow: /admin/
|
||||
@@ -2155,83 +2168,49 @@ router.get('/public/sitemap.xml', async (req, res) => {
|
||||
`SELECT to_regclass($1) as exists`, [tableName]
|
||||
);
|
||||
|
||||
// Генерируем XML sitemap
|
||||
let sitemap = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>${baseUrl}/</loc>
|
||||
<changefreq>daily</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>${baseUrl}/blog</loc>
|
||||
<changefreq>daily</changefreq>
|
||||
<priority>0.9</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>${baseUrl}/content/published</loc>
|
||||
<changefreq>daily</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>${baseUrl}/gitea</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
`;
|
||||
|
||||
// Генерируем XML sitemap (каждый <url> в одну строку — удобно для grep и без переносов внутри <loc>)
|
||||
let sitemap = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n`;
|
||||
sitemap += ` <url><loc>${escapeXml(baseUrl + '/')}</loc><changefreq>daily</changefreq><priority>1.0</priority></url>\n`;
|
||||
sitemap += ` <url><loc>${escapeXml(baseUrl + '/blog')}</loc><changefreq>daily</changefreq><priority>0.9</priority></url>\n`;
|
||||
sitemap += ` <url><loc>${escapeXml(baseUrl + '/content/published')}</loc><changefreq>daily</changefreq><priority>0.8</priority></url>\n`;
|
||||
sitemap += ` <url><loc>${escapeXml(baseUrl + '/gitea')}</loc><changefreq>weekly</changefreq><priority>0.7</priority></url>\n`;
|
||||
|
||||
if (existsRes.rows[0].exists) {
|
||||
// Получаем страницы блога (с show_in_blog = true)
|
||||
const { rows: blogPages } = await db.getQuery()(`
|
||||
SELECT id, slug, updated_at, created_at
|
||||
FROM ${tableName}
|
||||
WHERE status = 'published' AND visibility = 'public' AND show_in_blog = TRUE
|
||||
ORDER BY created_at DESC
|
||||
`);
|
||||
|
||||
// Добавляем страницы блога с использованием slug
|
||||
|
||||
for (const page of blogPages) {
|
||||
if (!page.slug || typeof page.slug !== 'string' || page.slug.trim() === '') {
|
||||
continue;
|
||||
}
|
||||
const dateObj = page.updated_at || page.created_at || new Date();
|
||||
const lastmod = dateObj instanceof Date ? dateObj.toISOString() : String(dateObj);
|
||||
const pageUrl = page.slug
|
||||
? `${baseUrl}/blog/${page.slug}`
|
||||
: `${baseUrl}/blog?page=${page.id}`;
|
||||
|
||||
sitemap += ` <url>
|
||||
<loc>${escapeXml(pageUrl)}</loc>
|
||||
<lastmod>${lastmod.split('T')[0]}</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
`;
|
||||
const pageUrl = `${baseUrl}/blog/${encodeURIComponent(page.slug.trim())}`;
|
||||
sitemap += ` <url><loc>${escapeXml(pageUrl)}</loc><lastmod>${lastmod.split('T')[0]}</lastmod><changefreq>weekly</changefreq><priority>0.8</priority></url>\n`;
|
||||
}
|
||||
|
||||
// Получаем остальные публичные страницы (без show_in_blog)
|
||||
|
||||
const { rows: otherPages } = await db.getQuery()(`
|
||||
SELECT id, slug, updated_at, created_at
|
||||
FROM ${tableName}
|
||||
WHERE status = 'published' AND visibility = 'public' AND (show_in_blog IS NULL OR show_in_blog = FALSE)
|
||||
ORDER BY created_at DESC
|
||||
`);
|
||||
|
||||
// Добавляем остальные публичные страницы с использованием slug
|
||||
|
||||
for (const page of otherPages) {
|
||||
if (!page.slug || typeof page.slug !== 'string' || page.slug.trim() === '') {
|
||||
continue;
|
||||
}
|
||||
const dateObj = page.updated_at || page.created_at || new Date();
|
||||
const lastmod = dateObj instanceof Date ? dateObj.toISOString() : String(dateObj);
|
||||
const pageUrl = page.slug
|
||||
? `${baseUrl}/content/published/${page.slug}`
|
||||
: `${baseUrl}/content/published?page=${page.id}`;
|
||||
|
||||
sitemap += ` <url>
|
||||
<loc>${escapeXml(pageUrl)}</loc>
|
||||
<lastmod>${lastmod.split('T')[0]}</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
`;
|
||||
const pageUrl = `${baseUrl}/content/published/${encodeURIComponent(page.slug.trim())}`;
|
||||
sitemap += ` <url><loc>${escapeXml(pageUrl)}</loc><lastmod>${lastmod.split('T')[0]}</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>\n`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
sitemap += `</urlset>`;
|
||||
|
||||
res.setHeader('Content-Type', 'application/xml; charset=UTF-8');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user