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

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

4
.gitignore vendored
View File

@@ -222,3 +222,7 @@ public-docs/
# Техническая документация (внутренняя) # Техническая документация (внутренняя)
docs/back-docs/ docs/back-docs/
# Local data and drafts
data/
WHITEPAPER-DLE.md

View File

@@ -134,6 +134,19 @@ const vdsRoutes = require('./routes/vds'); // Добавляем импорт м
const app = express(); 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('host', '0.0.0.0');
app.set('port', process.env.PORT || 8000); app.set('port', process.env.PORT || 8000);
@@ -165,6 +178,11 @@ app.use(
// Настройка сессии (используем геттер, чтобы всегда был актуальный middleware) // Настройка сессии (используем геттер, чтобы всегда был актуальный middleware)
app.use((req, res, next) => { app.use((req, res, next) => {
if (isPublicSeoEndpoint(req.originalUrl || req.url || '')) {
req.session = req.session || {};
return next();
}
try { try {
sessionConfig.sessionMiddleware(req, res, (err) => { sessionConfig.sessionMiddleware(req, res, (err) => {
// Обрабатываем ошибки сессий (например, таймауты подключения к БД) // Обрабатываем ошибки сессий (например, таймауты подключения к БД)
@@ -209,6 +227,10 @@ app.use((req, res, next) => {
// Добавим middleware для проверки сессии // Добавим middleware для проверки сессии
app.use(async (req, res, next) => { app.use(async (req, res, next) => {
if (isPublicSeoEndpoint(req.originalUrl || req.url || '')) {
return next();
}
// console.log('Request cookies:', req.headers.cookie); // console.log('Request cookies:', req.headers.cookie);
// console.log('Session ID:', req.sessionID); // console.log('Session ID:', req.sessionID);

View File

@@ -23,9 +23,8 @@
"fix-duplicates": "node scripts/fix-duplicate-identities.js", "fix-duplicates": "node scripts/fix-duplicate-identities.js",
"deploy:multichain": "node scripts/deploy/deploy-multichain.js", "deploy:multichain": "node scripts/deploy/deploy-multichain.js",
"deploy:modules": "node scripts/deploy/deploy-modules.js", "deploy:modules": "node scripts/deploy/deploy-modules.js",
"generate:abi": "node scripts/generate-abi.js",
"generate:flattened": "node scripts/generate-flattened.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", "seed:legal": "node scripts/seed/legalTemplatesSeed.js",
"import:legal": "node scripts/import-legal-docs.js", "import:legal": "node scripts/import-legal-docs.js",
"prerender:blog": "node scripts/pre-render-blog.js", "prerender:blog": "node scripts/pre-render-blog.js",

View File

@@ -2121,6 +2121,19 @@ Allow: /blog
Allow: /blog/ Allow: /blog/
Allow: /content/published Allow: /content/published
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: /api/
Disallow: /ws Disallow: /ws
Disallow: /admin/ Disallow: /admin/
@@ -2155,33 +2168,14 @@ router.get('/public/sitemap.xml', async (req, res) => {
`SELECT to_regclass($1) as exists`, [tableName] `SELECT to_regclass($1) as exists`, [tableName]
); );
// Генерируем XML sitemap // Генерируем XML sitemap (каждый <url> в одну строку — удобно для grep и без переносов внутри <loc>)
let sitemap = `<?xml version="1.0" encoding="UTF-8"?> let sitemap = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n`;
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> sitemap += ` <url><loc>${escapeXml(baseUrl + '/')}</loc><changefreq>daily</changefreq><priority>1.0</priority></url>\n`;
<url> sitemap += ` <url><loc>${escapeXml(baseUrl + '/blog')}</loc><changefreq>daily</changefreq><priority>0.9</priority></url>\n`;
<loc>${baseUrl}/</loc> sitemap += ` <url><loc>${escapeXml(baseUrl + '/content/published')}</loc><changefreq>daily</changefreq><priority>0.8</priority></url>\n`;
<changefreq>daily</changefreq> sitemap += ` <url><loc>${escapeXml(baseUrl + '/gitea')}</loc><changefreq>weekly</changefreq><priority>0.7</priority></url>\n`;
<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>
`;
if (existsRes.rows[0].exists) { if (existsRes.rows[0].exists) {
// Получаем страницы блога (с show_in_blog = true)
const { rows: blogPages } = await db.getQuery()(` const { rows: blogPages } = await db.getQuery()(`
SELECT id, slug, updated_at, created_at SELECT id, slug, updated_at, created_at
FROM ${tableName} FROM ${tableName}
@@ -2189,24 +2183,16 @@ router.get('/public/sitemap.xml', async (req, res) => {
ORDER BY created_at DESC ORDER BY created_at DESC
`); `);
// Добавляем страницы блога с использованием slug
for (const page of blogPages) { 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 dateObj = page.updated_at || page.created_at || new Date();
const lastmod = dateObj instanceof Date ? dateObj.toISOString() : String(dateObj); const lastmod = dateObj instanceof Date ? dateObj.toISOString() : String(dateObj);
const pageUrl = page.slug const pageUrl = `${baseUrl}/blog/${encodeURIComponent(page.slug.trim())}`;
? `${baseUrl}/blog/${page.slug}` sitemap += ` <url><loc>${escapeXml(pageUrl)}</loc><lastmod>${lastmod.split('T')[0]}</lastmod><changefreq>weekly</changefreq><priority>0.8</priority></url>\n`;
: `${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>
`;
} }
// Получаем остальные публичные страницы (без show_in_blog)
const { rows: otherPages } = await db.getQuery()(` const { rows: otherPages } = await db.getQuery()(`
SELECT id, slug, updated_at, created_at SELECT id, slug, updated_at, created_at
FROM ${tableName} FROM ${tableName}
@@ -2214,21 +2200,14 @@ router.get('/public/sitemap.xml', async (req, res) => {
ORDER BY created_at DESC ORDER BY created_at DESC
`); `);
// Добавляем остальные публичные страницы с использованием slug
for (const page of otherPages) { 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 dateObj = page.updated_at || page.created_at || new Date();
const lastmod = dateObj instanceof Date ? dateObj.toISOString() : String(dateObj); const lastmod = dateObj instanceof Date ? dateObj.toISOString() : String(dateObj);
const pageUrl = page.slug const pageUrl = `${baseUrl}/content/published/${encodeURIComponent(page.slug.trim())}`;
? `${baseUrl}/content/published/${page.slug}` sitemap += ` <url><loc>${escapeXml(pageUrl)}</loc><lastmod>${lastmod.split('T')[0]}</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>\n`;
: `${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>
`;
} }
} }

View File

@@ -10,7 +10,6 @@
* GitHub: https://github.com/VC-HB3-Accelerator * GitHub: https://github.com/VC-HB3-Accelerator
*/ */
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 http = require('http');
@@ -29,8 +28,94 @@ const OUTPUT_DIR = process.env.PRERENDER_OUTPUT_DIR ||
(process.env.NODE_ENV === 'production' (process.env.NODE_ENV === 'production'
? '/app/frontend_dist/blog' ? '/app/frontend_dist/blog'
: path.join(__dirname, '../../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 секунд на загрузку страницы 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 canonical = `${baseUrl}/blog/${encodeURIComponent(article.slug || '')}`;
const title = article.title ? escapeHtml(article.title) : 'Статья'; const title = article.title ? escapeHtml(article.title) : 'Статья';
const description = article.summary ? escapeHtml(article.summary) : title; const description = article.summary ? escapeHtml(article.summary) : title;
const content = article.content != null ? String(article.content) : ''; 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> return `<!DOCTYPE html>
<html lang="ru"> <html lang="ru">
<head> <head>
@@ -221,13 +377,7 @@ function buildArticleHtml(article, baseUrl) {
<meta name="robots" content="index, follow"> <meta name="robots" content="index, follow">
</head> </head>
<body> <body>
<article class="docs-content"> ${articleInnerHtml}
<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> </body>
</html>`; </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() { async function getBlogArticles() {
try { try {
const query = getQuery(); const url = new URL('/api/pages/blog/all', BACKEND_API_URL);
if (!query) { const mod = url.protocol === 'https:' ? https : http;
console.error('[pre-render] БД не инициализирована');
return [];
}
const tableName = 'admin_pages_simple'; console.log('[pre-render] Запрос списка статей через API:', url.toString());
const { rows } = await query(` const articles = await new Promise((resolve, reject) => {
SELECT id, slug, title const req = mod.request(url, { method: 'GET', timeout: 15000 }, (res) => {
FROM ${tableName} const chunks = [];
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 || []; 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) { } catch (error) {
console.error('[pre-render] Ошибка получения статей из БД:', error); console.error('[pre-render] Ошибка получения статей через API /api/pages/blog/all:', error.message || error);
return []; return [];
} }
} }
@@ -311,54 +528,41 @@ async function preRenderBlog(options = {}) {
// Создаем директорию для вывода // Создаем директорию для вывода
ensureDir(OUTPUT_DIR); 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 { try {
const entries = fs.readdirSync(OUTPUT_DIR); const entries = fs.readdirSync(OUTPUT_DIR);
for (const name of entries) { for (const name of entries) {
if (name !== 'index.html' && name.endsWith('.html')) { if (name.endsWith('.html')) {
const p = path.join(OUTPUT_DIR, name); const p = path.join(OUTPUT_DIR, name);
fs.renameSync(p, p + '.bak'); fs.renameSync(p, p + '.bak');
console.log(`[pre-render] Временно переименован: ${name} -> ${name}.bak`); console.log(`[pre-render] Переименован: ${name} -> ${name}.bak`);
} }
} }
} catch (e) { } catch (e) {
console.warn('[pre-render] Не удалось переименовать старые файлы:', e.message); console.warn('[pre-render] Не удалось переименовать старые файлы:', e.message);
} }
// Инициализируем браузер
let browser;
try { try {
browser = await puppeteer.launch({ // Список статей больше не пререндерим в index.html,
headless: true, // чтобы /blog всегда работал как чистый SPA и не ломался при F5.
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'));
}
// Получаем список статей
const articles = await getBlogArticles(); const articles = await getBlogArticles();
console.log(`[pre-render] Найдено статей: ${articles.length}`); console.log(`[pre-render] Найдено статей: ${articles.length}`);
if (renderArticles && articles.length > 0) { if (renderArticles && articles.length > 0) {
// Загружаем app-shell один раз: из файла или по URL (на VDS файла нет — качаем с сайта)
let appShellTemplate = await getAppShellTemplate();
// Рендерим статьи // Рендерим статьи
let articlesToRender; let articlesToRender;
if (specificSlug) { if (specificSlug) {
@@ -383,20 +587,18 @@ async function preRenderBlog(options = {}) {
let articleHtml = null; let articleHtml = null;
try { try {
const data = await fetchArticleFromApi(article.slug); const data = await fetchArticleFromApi(article.slug);
articleHtml = buildArticleHtml(data, publicBaseUrl); articleHtml = buildArticleHtml(data, publicBaseUrl, appShellTemplate);
console.log(`[pre-render] Статья получена через API (SSG)`); console.log(`[pre-render] Статья получена через API (SSG)`);
} catch (apiErr) { } catch (apiErr) {
console.warn(`[pre-render] API недоступен (${apiErr.message}), пробуем через браузер...`); console.warn(`[pre-render] API недоступен (${apiErr.message}), пропускаем статью без browser fallback`);
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) { if (articleHtml && isRenderableArticleHtml(articleHtml, article)) {
const filePath = path.join(OUTPUT_DIR, `${sanitizedSlug}.html`); const filePath = path.join(OUTPUT_DIR, `${sanitizedSlug}.html`);
saveHtml(articleHtml, filePath); saveHtml(articleHtml, filePath);
} else {
console.warn(
`[pre-render] Пропущено сохранение ${sanitizedSlug}: получен невалидный HTML (error-state или пустой контент)`
);
} }
} catch (error) { } catch (error) {
console.error(`[pre-render] Ошибка при рендеринге статьи ${article.slug}:`, error.message); console.error(`[pre-render] Ошибка при рендеринге статьи ${article.slug}:`, error.message);
@@ -408,15 +610,6 @@ async function preRenderBlog(options = {}) {
} catch (error) { } catch (error) {
console.error('[pre-render] Критическая ошибка:', error); console.error('[pre-render] Критическая ошибка:', error);
throw error; throw error;
} finally {
// Закрываем браузер, если он был открыт
if (browser) {
try {
await browser.close();
} catch (closeError) {
console.error('[pre-render] Ошибка при закрытии браузера:', closeError.message);
}
}
} }
} }

View File

@@ -7,13 +7,14 @@
<title>VC HB3 Accelerator — венчурный фонд и платформа DLE</title> <title>VC HB3 Accelerator — венчурный фонд и платформа DLE</title>
<meta name="description" content="Финтех-стартап VC HB3 Accelerator: венчурный фонд и поставщик ПО. Платформа DLE для бизнеса, акселератор, песочница. Для инвесторов, предпринимателей, регуляторов, подрядчиков." /> <meta name="description" content="Финтех-стартап VC HB3 Accelerator: венчурный фонд и поставщик ПО. Платформа DLE для бизнеса, акселератор, песочница. Для инвесторов, предпринимателей, регуляторов, подрядчиков." />
<meta name="keywords" content="VC HB3 Accelerator, венчурный фонд, финтех, DLE, Digital Legal Entity, акселератор, песочница, для инвесторов, для предпринимателей, для регуляторов" /> <meta name="keywords" content="VC HB3 Accelerator, венчурный фонд, финтех, DLE, Digital Legal Entity, акселератор, песочница, для инвесторов, для предпринимателей, для регуляторов" />
<link rel="canonical" href="https://hb3-accelerator.com/" /> <meta name="robots" content="index, follow" />
<link rel="canonical" href="/" />
<!-- Open Graph — информация о компании VC HB3 Accelerator --> <!-- Open Graph — информация о компании VC HB3 Accelerator -->
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta property="og:title" content="VC HB3 Accelerator — венчурный фонд и платформа DLE" /> <meta property="og:title" content="VC HB3 Accelerator — венчурный фонд и платформа DLE" />
<meta property="og:description" content="Добро пожаловать в VC HB3 Accelerator. Финтех-стартап — венчурный фонд и поставщик ПО. Разрабатываем платформу DLE, помогаем бизнесу настроить и протестировать её в песочнице. Инвестируем в компании участников акселератора." /> <meta property="og:description" content="Добро пожаловать в VC HB3 Accelerator. Финтех-стартап — венчурный фонд и поставщик ПО. Разрабатываем платформу DLE, помогаем бизнесу настроить и протестировать её в песочнице. Инвестируем в компании участников акселератора." />
<meta property="og:url" content="https://hb3-accelerator.com/" /> <meta property="og:url" content="/" />
<meta property="og:image" content="https://hb3-accelerator.com/dle-logo.png" /> <meta property="og:image" content="/dle-logo.png" />
<meta property="og:locale" content="ru_RU" /> <meta property="og:locale" content="ru_RU" />
<meta property="og:site_name" content="VC HB3 Accelerator" /> <meta property="og:site_name" content="VC HB3 Accelerator" />
<!-- Twitter Card --> <!-- Twitter Card -->

View File

@@ -67,6 +67,20 @@ http {
add_header X-XSS-Protection "1; mode=block" always; add_header X-XSS-Protection "1; mode=block" always;
} }
location /blog {
if ($arg_page != "") {
return 301 /blog;
}
try_files $uri $uri/ /index.html;
}
location = /content/published {
if ($arg_page != "") {
return 301 /content/published;
}
try_files $uri $uri/ /index.html;
}
# Certbot webroot для автоматического получения SSL сертификатов # Certbot webroot для автоматического получения SSL сертификатов
location /.well-known/acme-challenge/ { location /.well-known/acme-challenge/ {
root /var/www/certbot; root /var/www/certbot;
@@ -120,6 +134,21 @@ http {
proxy_set_header X-Forwarded-Port $server_port; proxy_set_header X-Forwarded-Port $server_port;
} }
# Gitea служебный раздел: запрещаем индексацию
location ^~ /gitea/ {
proxy_pass http://dapp-gitea:3000/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
add_header X-Robots-Tag "noindex, nofollow, noarchive, nosnippet, noimageindex" always;
proxy_redirect off;
proxy_http_version 1.1;
proxy_connect_timeout 120s;
proxy_send_timeout 1800s;
proxy_read_timeout 1800s;
}
# Скрытие информации о сервере # Скрытие информации о сервере
server_tokens off; server_tokens off;
} }

View File

@@ -221,6 +221,7 @@ http {
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
add_header X-Robots-Tag "noindex, nofollow, noarchive, nosnippet, noimageindex" always;
proxy_redirect off; proxy_redirect off;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_connect_timeout 120s; proxy_connect_timeout 120s;
@@ -253,6 +254,9 @@ http {
# Pre-rendered blog pages (SEO optimization) # Pre-rendered blog pages (SEO optimization)
location /blog { location /blog {
if ($arg_page != "") {
return 301 /blog;
}
root /usr/share/nginx/html; root /usr/share/nginx/html;
try_files $uri $uri.html /blog/index.html /index.html; try_files $uri $uri.html /blog/index.html /index.html;
@@ -262,6 +266,13 @@ http {
add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Frame-Options "SAMEORIGIN" always;
} }
location = /content/published {
if ($arg_page != "") {
return 301 /content/published;
}
try_files $uri $uri/ /index.html;
}
# Статические файлы # Статические файлы
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y; expires 1y;

View File

@@ -180,7 +180,7 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted, watch, nextTick } from 'vue'; import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue';
import { useRouter, useRoute } from 'vue-router'; import { useRouter, useRoute } from 'vue-router';
import { marked } from 'marked'; import { marked } from 'marked';
import DOMPurify from 'dompurify'; import DOMPurify from 'dompurify';
@@ -220,6 +220,23 @@ const relatedArticles = ref([]);
// Определяем, это страница блога // Определяем, это страница блога
const isBlogPage = computed(() => route.path.startsWith('/blog')); const isBlogPage = computed(() => route.path.startsWith('/blog'));
const isPublishedPage = computed(() => route.path.startsWith('/content/published'));
const shouldManageNoindex = computed(() => isBlogPage.value || isPublishedPage.value);
function setRobotsMeta(content = 'index, follow') {
let robotsMeta = document.querySelector('meta[name="robots"]');
if (!robotsMeta) {
robotsMeta = document.createElement('meta');
robotsMeta.setAttribute('name', 'robots');
document.head.appendChild(robotsMeta);
}
// Не выставляем noindex вне документных маршрутов, чтобы не блокировать индексацию главной.
if (content.startsWith('noindex') && !shouldManageNoindex.value) {
robotsMeta.setAttribute('content', 'index, follow');
return;
}
robotsMeta.setAttribute('content', content);
}
// Установка мета-тегов для SEO // Установка мета-тегов для SEO
function updateMetaTags(pageData) { function updateMetaTags(pageData) {
@@ -240,22 +257,21 @@ function updateMetaTags(pageData) {
const description = seoData?.description || pageData.summary || ''; const description = seoData?.description || pageData.summary || '';
const keywords = seoData?.keywords || ''; const keywords = seoData?.keywords || '';
// Определяем canonical URL в зависимости от текущего маршрута и наличия slug // Определяем canonical URL только по slug (без fallback на ?page=, чтобы исключить дубли)
const currentPath = window.location.pathname; const currentPath = window.location.pathname;
let canonicalUrl; let canonicalUrl;
if (currentPath.startsWith('/blog')) { if (currentPath.startsWith('/blog')) {
// Используем slug если есть, иначе fallback на query параметр if (!pageData.slug || typeof pageData.slug !== 'string' || pageData.slug.trim() === '') {
if (pageData.slug && typeof pageData.slug === 'string' && pageData.slug.trim() !== '') { setRobotsMeta('noindex, follow');
canonicalUrl = `${window.location.origin}/blog/${encodeURIComponent(pageData.slug)}`; return;
} else if (pageData.id) {
canonicalUrl = `${window.location.origin}/blog?page=${pageData.id}`;
} else {
canonicalUrl = `${window.location.origin}/blog`;
} }
canonicalUrl = `${window.location.origin}/blog/${encodeURIComponent(pageData.slug.trim())}`;
} else { } else {
canonicalUrl = pageData.id if (!pageData.slug || typeof pageData.slug !== 'string' || pageData.slug.trim() === '') {
? `${window.location.origin}/content/published?page=${pageData.id}` setRobotsMeta('noindex, follow');
: `${window.location.origin}/content/published`; return;
}
canonicalUrl = `${window.location.origin}/content/published/${encodeURIComponent(pageData.slug.trim())}`;
} }
// Обновляем title // Обновляем title
@@ -297,7 +313,7 @@ function updateMetaTags(pageData) {
updateOrCreateMeta('og:url', canonicalUrl, 'property'); updateOrCreateMeta('og:url', canonicalUrl, 'property');
// Robots meta // Robots meta
updateOrCreateMeta('robots', 'index, follow'); setRobotsMeta('index, follow');
// Добавляем JSON-LD разметку для статьи // Добавляем JSON-LD разметку для статьи
addArticleJsonLd(pageData, canonicalUrl); addArticleJsonLd(pageData, canonicalUrl);
@@ -487,11 +503,19 @@ async function loadPage() {
if (error.response?.data && error.response.status !== 404) { if (error.response?.data && error.response.status !== 404) {
console.warn('[DocsContent] Пытаемся использовать данные из error.response.data'); console.warn('[DocsContent] Пытаемся использовать данные из error.response.data');
page.value = error.response.data; page.value = error.response.data;
if (!page.value || !page.value.id) {
setRobotsMeta('noindex, follow');
}
} else { } else {
page.value = null; page.value = null;
// Для любого error-state показываем noindex, иначе Google получает soft-404.
setRobotsMeta('noindex, follow');
} }
} finally { } finally {
isLoading.value = false; isLoading.value = false;
if (!page.value) {
setRobotsMeta('noindex, follow');
}
console.log('[DocsContent] loadPage завершен:', { console.log('[DocsContent] loadPage завершен:', {
hasPage: !!page.value, hasPage: !!page.value,
isLoading: isLoading.value, isLoading: isLoading.value,
@@ -831,6 +855,11 @@ onMounted(() => {
} }
setupVideoErrorHandlers(); setupVideoErrorHandlers();
}); });
onUnmounted(() => {
// В SPA noindex может "залипать" между маршрутами, поэтому сбрасываем его при уходе со страницы документа.
setRobotsMeta('index, follow');
});
</script> </script>
<style scoped> <style scoped>

View File

@@ -243,10 +243,11 @@ function addBlogJsonLd() {
'name': 'Блог', 'name': 'Блог',
'description': 'Публикации и статьи', 'description': 'Публикации и статьи',
'url': `${window.location.origin}/blog`, 'url': `${window.location.origin}/blog`,
'blogPost': pages.value.slice(0, 10).map(page => { 'blogPost': pages.value
const url = (page.slug && typeof page.slug === 'string' && page.slug.trim() !== '') .filter(page => page.slug && typeof page.slug === 'string' && page.slug.trim() !== '')
? `${window.location.origin}/blog/${encodeURIComponent(page.slug)}` .slice(0, 10)
: (page.id ? `${window.location.origin}/blog?page=${page.id}` : `${window.location.origin}/blog`); .map(page => {
const url = `${window.location.origin}/blog/${encodeURIComponent(page.slug.trim())}`;
return { return {
'@type': 'BlogPosting', '@type': 'BlogPosting',

View File

@@ -266,9 +266,21 @@ function updatePageMetaTags() {
const keywords = seoData?.keywords || ''; const keywords = seoData?.keywords || '';
// Определяем canonical URL // Определяем canonical URL
const pageUrl = page.value.slug if (!page.value.slug || typeof page.value.slug !== 'string' || page.value.slug.trim() === '') {
? `${window.location.origin}/content/published/${encodeURIComponent(page.value.slug)}` // Без slug страницу не индексируем, чтобы не плодить дубли по ?page=
: `${window.location.origin}/content/published?page=${page.value.id}`; const robotsMeta = document.querySelector('meta[name="robots"]');
if (robotsMeta) {
robotsMeta.setAttribute('content', 'noindex, follow');
} else {
const meta = document.createElement('meta');
meta.setAttribute('name', 'robots');
meta.setAttribute('content', 'noindex, follow');
document.head.appendChild(meta);
}
return;
}
const pageUrl = `${window.location.origin}/content/published/${encodeURIComponent(page.value.slug.trim())}`;
// Обновляем title // Обновляем title
document.title = title; document.title = title;