dana-medical-site/web/scripts/verify-seo.mjs
2026-10-14 13:33:13 +00:00

110 lines
7.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import fs from 'node:fs/promises';
import path from 'node:path';
import assert from 'node:assert/strict';
import { DOMParser } from 'linkedom';
const manifest = JSON.parse(await fs.readFile('dist/seo-manifest.json', 'utf8'));
const profiles = JSON.parse(await fs.readFile('../seo/service-profiles.json', 'utf8'));
globalThis.DOMParser = DOMParser;
const { servicePriceSection, priceDirection } = await import('../src/lib/serviceNavigation.js');
assert.equal(servicePriceSection('estetic/rinoplastika', []), undefined);
assert.equal(servicePriceSection('estetic/rinoplastika', [{ slug: 'plasticheskaya_hirurgiya', items: [] }]), undefined);
assert.equal(servicePriceSection('estetic/rinoplastika', [{ children: [{ slug: 'plasticheskaya_hirurgiya', items: [{ id: 1 }] }] }]).slug, 'plasticheskaya_hirurgiya');
assert.equal(priceDirection('plasticheskaya_hirurgiya', []), undefined);
assert.equal(priceDirection('plasticheskaya_hirurgiya', [{ path: 'estetic' }]).path, 'estetic');
const { sanitizeHTML } = await import('../src/lib/sanitize.js');
const { canonicalInternalHref } = await import('../src/lib/links.js');
assert.equal(canonicalInternalHref('/include/licenses_detail.php?utm_source=test#info'), '/legaldocs/privacy/?utm_source=test#info');
assert.equal(canonicalInternalHref('/company/licenses/'), '/company/licenses/');
assert.equal(canonicalInternalHref('/privacy'), '/legaldocs/privacy/');
assert.equal(canonicalInternalHref('https://other.example/privacy'), 'https://other.example/privacy');
const sanitized = sanitizeHTML('<p onclick="bad()">Текст <strong>документа</strong></p><a href="/contacts/">Контакты</a><a href="jav&#x61;script:bad()">bad link</a><a href="java&#10;script:bad()">bad newline</a><svg onload="bad()"><script>bad()</script></svg><img src="x" onerror="bad()">');
assert.ok(sanitized.includes('<strong>документа</strong>'));
assert.ok(sanitized.includes('href="/contacts/"'));
assert.ok(!/javascript:|onclick|onerror|onload|<svg|<script|<img/i.test(sanitized));
const { parseBlocks } = await import('../.ssr/entry-server.js');
const parsed = parseBlocks([{ html: '<p>Описание процедуры</p><h3>Показания</h3><ul><li>Первый пункт</li></ul><script>bad()</script>' }]);
assert.equal(parsed.length, 2);
assert.equal(parsed[0].paragraphs[0], 'Описание процедуры');
assert.equal(parsed[1].title, 'Показания');
assert.equal(parsed[1].items[0], 'Первый пункт');
assert.ok(!JSON.stringify(parsed).includes('bad()'));
let sections = 0, assets = new Set();
for (const servicePath of manifest.services) {
const html = await fs.readFile(path.join('dist/services', servicePath, 'index.html'), 'utf8');
const doc = new DOMParser().parseFromString(html, 'text/html');
for (const selector of ['title', 'meta[name="description"]', 'link[rel="canonical"]', 'h1', '#page-data']) {
assert.equal(doc.querySelectorAll(selector).length, 1, `${servicePath}: ${selector} must be unique`);
}
assert.equal(doc.querySelector('link[rel="canonical"]').getAttribute('href'), `${manifest.siteURL}/services/${servicePath}/`);
if (profiles[servicePath]) {
assert.equal(doc.querySelector('h1').textContent, profiles[servicePath].h1);
assert.equal(doc.querySelector('title').textContent, profiles[servicePath].title);
assert.ok([...doc.querySelector('title').textContent].length <= 60);
assert.ok([...doc.querySelector('meta[name="description"]').getAttribute('content')].length <= 160);
}
const data = JSON.parse(doc.querySelector('#page-data').textContent);
const service = data[`service:${servicePath}`].data;
assert.equal(service.path, servicePath);
assert.ok(!Object.keys(data).some((k) => /admin|lead|session/i.test(k)), 'Public HTML must not include private API data');
if (service.content_sections.length && !service.children?.length) {
assert.ok(doc.querySelectorAll('.service-section,.service-urgent').length >= service.content_sections.length, `${servicePath}: main text missing before JS`);
sections += service.content_sections.length;
}
if (service.blocks?.some((b) => b.html?.replace(/<[^>]*>/g, '').trim()) && !service.children?.length) {
assert.ok(service.content_sections.length > 0, `${servicePath}: nonempty source content must survive prerendering`);
}
assert.equal(doc.querySelectorAll('script[type="application/ld+json"]').length, 1, `${servicePath}: missing structured data`);
for (const el of doc.querySelectorAll('script[type="application/ld+json"]')) {
const schema = JSON.parse(el.textContent);
assert.equal(schema['@context'], 'https://schema.org');
assert.ok(schema['@graph'].some((n) => n['@type'] === 'BreadcrumbList'));
}
for (const el of doc.querySelectorAll('[src],link[href]')) {
const resource = el.getAttribute('src') || el.getAttribute('href');
if (resource.startsWith('/assets/')) assets.add(resource);
}
}
for (const resource of assets) assert.ok((await fs.stat(path.join('dist', resource))).isFile(), `Missing SSR asset: ${resource}`);
assert.ok(sections > 0, 'No source content made it into HTML');
console.log(`SEO HTML verified: ${manifest.services.length} pages, ${sections} content sections before JS, ${assets.size} local assets.`);
// Живой рендер получает данные страницы через stdin кусками по 64 КБ. Русская буква на границе
// куска не должна превращаться в «<><C2AB>»: пробуем оба сдвига, чтобы граница попала внутрь буквы.
{
const { spawnSync } = await import('node:child_process');
const template = await fs.readFile('dist/index.html', 'utf8');
for (const pad of ['', 'a']) {
const text = pad + 'Субъект персональных данных имеет право. '.repeat(6000);
const request = JSON.stringify({ url: '/legaldocs/privacy/', siteURL: manifest.siteURL, template,
common: { branches: [], settings: {} },
initial: { 'page:privacy': { data: { id: 1, slug: 'privacy', title: 'Политика', blocks: [{ type: 'html', html: `<p>${text}</p>` }], seo: {} } } } });
const run = spawnSync(process.execPath, ['scripts/render-page.mjs'], { input: request, maxBuffer: 64 << 20 });
assert.equal(run.status, 0, run.stderr?.toString());
const html = run.stdout.toString('utf8');
assert.ok(html.includes('имеет право'), 'rendered page has the text');
assert.ok(!html.includes('\uFFFD'), 'multi-byte characters survived stdin chunking');
}
console.log('Live renderer keeps UTF-8 intact across 64 KB input chunks.');
}
if (process.env.VERIFY_URL) {
const base = process.env.VERIFY_URL.replace(/\/$/, '');
for (const [url, status] of [['/services/estetic/rinoplastika/', 200], ['/services/not-a-service/', 404], ['/not-a-page/', 404], ['/robots.txt', 200], ['/sitemap.xml', 200]]) {
const response = await fetch(base + url, { redirect: 'manual' });
assert.equal(response.status, status, `${url}: wrong status`);
if (url.endsWith('rinoplastika/')) assert.ok((await response.text()).includes('Ринопластика в Махачкале'));
if (url === '/sitemap.xml') {
const body = await response.text();
const sitemap = new DOMParser().parseFromString(body, 'text/xml');
const locs = [...sitemap.querySelectorAll('loc')].map((n) => n.textContent);
assert.equal(new Set(locs).size, locs.length);
assert.ok(locs.includes(`${manifest.siteURL}/services/estetic/rinoplastika/`));
assert.ok(!locs.some((l) => l.includes('not-a-service')));
}
}
const redirect = await fetch(base + '/services/estetic/rinoplastika?utm_source=test', { redirect: 'manual' });
assert.equal(redirect.status, 301);
assert.equal(redirect.headers.get('location'), '/services/estetic/rinoplastika/?utm_source=test');
console.log('Live Go routes verified: HTML, 301, 404, robots.txt, sitemap.xml.');
}