ваше сообщение коммита
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -222,3 +222,7 @@ public-docs/
|
|||||||
|
|
||||||
# Техническая документация (внутренняя)
|
# Техническая документация (внутренняя)
|
||||||
docs/back-docs/
|
docs/back-docs/
|
||||||
|
|
||||||
|
# Local data and drafts
|
||||||
|
data/
|
||||||
|
WHITEPAPER-DLE.md
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 [];
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const tableName = 'admin_pages_simple';
|
req.on('error', (err) => {
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
|
||||||
const { rows } = await query(`
|
req.on('timeout', () => {
|
||||||
SELECT id, slug, title
|
req.destroy();
|
||||||
FROM ${tableName}
|
reject(new Error('Timeout /api/pages/blog/all'));
|
||||||
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 || [];
|
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 && isRenderableArticleHtml(articleHtml, article)) {
|
||||||
if (articleHtml) {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 -->
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user