From ece395a60f7fe120121c96382f0ca8d512ed679e Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 22 Apr 2026 13:41:56 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B2=D0=B0=D1=88=D0=B5=20=D1=81=D0=BE=D0=BE?= =?UTF-8?q?=D0=B1=D1=89=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=BE=D0=BC=D0=BC?= =?UTF-8?q?=D0=B8=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 + backend/app.js | 22 ++ backend/package.json | 3 +- backend/routes/pages.js | 89 ++--- backend/scripts/pre-render-blog.js | 357 ++++++++++++++---- frontend/index.html | 7 +- frontend/nginx-local.conf | 29 ++ frontend/nginx-simple.conf | 11 + frontend/src/components/docs/DocsContent.vue | 57 ++- frontend/src/views/BlogView.vue | 11 +- frontend/src/views/content/PublicPageView.vue | 18 +- 11 files changed, 444 insertions(+), 164 deletions(-) diff --git a/.gitignore b/.gitignore index 400c996..dae3123 100644 --- a/.gitignore +++ b/.gitignore @@ -222,3 +222,7 @@ public-docs/ # Техническая документация (внутренняя) docs/back-docs/ + +# Local data and drafts +data/ +WHITEPAPER-DLE.md diff --git a/backend/app.js b/backend/app.js index e9e0b5d..609a1a2 100644 --- a/backend/app.js +++ b/backend/app.js @@ -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); diff --git a/backend/package.json b/backend/package.json index c5836af..873cf42 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/routes/pages.js b/backend/routes/pages.js index ddf4854..3ef4509 100644 --- a/backend/routes/pages.js +++ b/backend/routes/pages.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 = ` - - - ${baseUrl}/ - daily - 1.0 - - - ${baseUrl}/blog - daily - 0.9 - - - ${baseUrl}/content/published - daily - 0.8 - - - ${baseUrl}/gitea - weekly - 0.7 - -`; - + // Генерируем XML sitemap (каждый в одну строку — удобно для grep и без переносов внутри ) + let sitemap = `\n\n`; + sitemap += ` ${escapeXml(baseUrl + '/')}daily1.0\n`; + sitemap += ` ${escapeXml(baseUrl + '/blog')}daily0.9\n`; + sitemap += ` ${escapeXml(baseUrl + '/content/published')}daily0.8\n`; + sitemap += ` ${escapeXml(baseUrl + '/gitea')}weekly0.7\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 += ` - ${escapeXml(pageUrl)} - ${lastmod.split('T')[0]} - weekly - 0.8 - -`; + const pageUrl = `${baseUrl}/blog/${encodeURIComponent(page.slug.trim())}`; + sitemap += ` ${escapeXml(pageUrl)}${lastmod.split('T')[0]}weekly0.8\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 += ` - ${escapeXml(pageUrl)} - ${lastmod.split('T')[0]} - weekly - 0.7 - -`; + const pageUrl = `${baseUrl}/content/published/${encodeURIComponent(page.slug.trim())}`; + sitemap += ` ${escapeXml(pageUrl)}${lastmod.split('T')[0]}weekly0.7\n`; } } - + sitemap += ``; res.setHeader('Content-Type', 'application/xml; charset=UTF-8'); diff --git a/backend/scripts/pre-render-blog.js b/backend/scripts/pre-render-blog.js index e894742..df60052 100644 --- a/backend/scripts/pre-render-blog.js +++ b/backend/scripts/pre-render-blog.js @@ -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('
. + const appWithBodyRegex = /
]*>[\s\S]*<\/div>\s*<\/body>/i; + if (appWithBodyRegex.test(html)) { + return html.replace(appWithBodyRegex, '
\n'); + } + + 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 = ` +
+ +
${content}
+
+ `; + + const template = appShellTemplate; + + if (template && typeof template === 'string' && template.includes('
]*>/i)) { + html = html.replace( + /]*>/i, + `` + ); + } else { + html = html.replace( + /<\/head>/i, + ` \n` + ); + } + + // Удаляем старые SEO-теги app-shell (чтобы не плодить дубли canonical/og/robots) + html = html + .replace(/]*>\s*/gi, '') + .replace(/]*>\s*/gi, '') + .replace(/]*>\s*/gi, '') + .replace(/]*>\s*/gi, '') + .replace(/]*>\s*/gi, '') + .replace(/]*>\s*/gi, ''); + + // Канонический URL и OG‑мета + const ogBlock = ` + + + + + + `; + + if (html.includes('')) { + html = html.replace(/<\/head>/i, `${ogBlock}\n`); + } + + // Вставляем контент внутрь #app, сохраняя весь app-shell (CSS + JS) + const appWithBodyRegex = /
]*>[\s\S]*<\/div>\s*<\/body>/i; + if (appWithBodyRegex.test(html)) { + html = html.replace(appWithBodyRegex, `
${articleInnerHtml}
\n`); + } else if (html.includes('
')) { + html = html.replace('
', `
${articleInnerHtml}
`); + } + + return html; + } + + // Fallback: минимальный HTML (на случай, если index.html не найден) return ` @@ -221,13 +377,7 @@ function buildArticleHtml(article, baseUrl) { -
- -
${content}
-
+ ${articleInnerHtml} `; } @@ -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(' 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); - } - } } } diff --git a/frontend/index.html b/frontend/index.html index 3c1cd06..5c71bfd 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -7,13 +7,14 @@ VC HB3 Accelerator — венчурный фонд и платформа DLE - + + - - + + diff --git a/frontend/nginx-local.conf b/frontend/nginx-local.conf index 61aaab2..3c289da 100644 --- a/frontend/nginx-local.conf +++ b/frontend/nginx-local.conf @@ -67,6 +67,20 @@ http { 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 сертификатов location /.well-known/acme-challenge/ { root /var/www/certbot; @@ -120,6 +134,21 @@ http { 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; } diff --git a/frontend/nginx-simple.conf b/frontend/nginx-simple.conf index 0f10492..6970f83 100644 --- a/frontend/nginx-simple.conf +++ b/frontend/nginx-simple.conf @@ -221,6 +221,7 @@ http { 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; @@ -253,6 +254,9 @@ http { # Pre-rendered blog pages (SEO optimization) location /blog { + if ($arg_page != "") { + return 301 /blog; + } root /usr/share/nginx/html; try_files $uri $uri.html /blog/index.html /index.html; @@ -262,6 +266,13 @@ http { 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)$ { expires 1y; diff --git a/frontend/src/components/docs/DocsContent.vue b/frontend/src/components/docs/DocsContent.vue index 07021c2..dbfae59 100644 --- a/frontend/src/components/docs/DocsContent.vue +++ b/frontend/src/components/docs/DocsContent.vue @@ -180,7 +180,7 @@