56 lines
6.2 KiB
JavaScript
56 lines
6.2 KiB
JavaScript
import fs from 'node:fs/promises';
|
||
import { DOMParser } from 'linkedom';
|
||
const base = (process.env.VERIFY_URL || 'http://127.0.0.1:8080').replace(/\/$/, '');
|
||
const profiles = JSON.parse(await fs.readFile('../seo/service-profiles.json', 'utf8'));
|
||
async function get(path) {
|
||
const response = await fetch(base + path, { signal: AbortSignal.timeout(15000) });
|
||
if (!response.ok) throw new Error(`${path}: HTTP ${response.status}`);
|
||
return response;
|
||
}
|
||
const prices = (await (await get('/api/v1/prices')).json()).data;
|
||
const flatten = (tree) => (tree || []).flatMap((s) => [s, ...flatten(s.children)]);
|
||
const plastic = flatten(prices).find((s) => s.slug === 'plasticheskaya_hirurgiya');
|
||
const groups = new Map();
|
||
for (const item of plastic?.items || []) {
|
||
const key = item.name.toLowerCase().replace(/\s+/g, ' ').trim();
|
||
if (!groups.has(key)) groups.set(key, []);
|
||
groups.get(key).push(item);
|
||
}
|
||
const ambiguous = [...groups.values()].filter((items) => new Set(items.map((p) => `${p.price}:${p.unit || ''}:${Boolean(p.from)}`)).size > 1);
|
||
let md = '# Проверка контента SEO-посадочных\n\n';
|
||
md += `Проверено: ${new Date().toISOString()}. Источник: текущие публичные данные проекта. Это редакторский список, не медицинская или юридическая проверка. Новые операции, цены, авторство, гарантии и ответы не публикуются автоматически.\n\n`;
|
||
const pages = [];
|
||
for (const [path, profile] of Object.entries(profiles)) {
|
||
const url = `/services/${path}/`;
|
||
const doc = new DOMParser().parseFromString(await (await get(url)).text(), 'text/html');
|
||
const initial = JSON.parse(doc.getElementById('page-data').textContent);
|
||
const service = initial[`service:${path}`].data;
|
||
const headings = [...doc.querySelectorAll('h2')].map((n) => n.textContent.trim());
|
||
const content = (service.content_sections || []).map((s) => [s.title, ...s.paragraphs, ...s.items].join(' ')).join(' ');
|
||
const topics = [
|
||
['Показания', /показан|кому подход/i],
|
||
['Ограничения и противопоказания', /противопоказ|ограничени/i],
|
||
['Риски и возможные осложнения', /осложнен|риск/i],
|
||
['Подготовка', /подготов/i],
|
||
['Восстановление и наблюдение', /восстанов|реабилитац|послеоперац/i],
|
||
].map(([topic, re]) => ({ topic, mentioned: re.test(content) }));
|
||
const record = { path, headings, linkedPrices: service.prices?.length || 0, ownDoctors: service.doctors?.map((d) => ({ name: d.full_name, slug: d.slug })) || [], topics };
|
||
pages.push(record);
|
||
md += `## ${profile.cluster}\n\nURL: ${url}\n\n`;
|
||
md += `Связанных в БД позиций прайса: ${record.linkedPrices}. Врачей, явно связанных с услугой: ${record.ownDoctors.length}. Связь с услугой сама по себе не доказывает авторство текста или право проводить конкретную операцию.\n\n`;
|
||
md += `Видимые H2: ${headings.join('; ') || 'нет'}.\n\n`;
|
||
md += 'Темы в исходном описании (поиск слов, не оценка полноты/качества):\n\n';
|
||
for (const t of topics) md += `- ${t.topic}: ${t.mentioned ? 'упоминание найдено, требуется врачебная проверка' : 'упоминание не найдено, запросить проверенный текст'}.\n`;
|
||
md += '\nДо выпуска: согласовать медицинского автора и проверяющего с реальными профилями; дату проверки; источники; фактическое место проведения; состав цены; права на отзывы и фото до/после. FAQ и новое авторство не размечать до утверждения.\n\n';
|
||
}
|
||
md += '## Прайс: неоднозначные одноимённые позиции\n\n';
|
||
md += 'Одинаковые названия с разными суммами могут относиться к врачам, категориям или условиям. Их нельзя считать ошибкой без проверки. Публикация минимальной суммы как цены операции и автоматическая связь с посадочной заблокированы на редакционном уровне: код не делает такого сопоставления. Уже существующий прайс не изменён.\n\n';
|
||
for (const items of ambiguous) {
|
||
md += `- ${items[0].name}: ${items.map((p) => `ID ${p.id} — ${p.price} ₽${p.unit ? ` / ${p.unit}` : ''}`).join('; ')}.\n`;
|
||
}
|
||
md += '\nОсобенно проверить блефаропластику верхних век (ID 11390 / 11391: 3 000 / 30 000 ₽) и нижних век (ID 11399: 3 000 ₽). Не исправлять по догадке. Общие ссылки с услуги ведут в прайс направления, без обещания конкретной цены или доступности каждого варианта операции.\n\n';
|
||
md += '## Неперенесённые разделы\n\nО клинике: запись pages/about есть, но содержательный текст отсутствует. Новости, галерея, партнёры, надзорные органы, вакансии и FAQ — требуют переноса подтверждённых данных. Пункты подвала сохраняются визуально, но не ведут на 404. Внутренние старые URL по-прежнему возвращают 404, не пустые 200.\n';
|
||
await fs.writeFile('../seo/content-gaps.md', md);
|
||
await fs.writeFile('../seo/content-audit.json', JSON.stringify({ checkedAt: new Date().toISOString(), pages, ambiguousPrices: ambiguous }, null, 2));
|
||
console.log(`Content audit: ${pages.length} landing pages, ${ambiguous.length} ambiguous price groups. See seo/content-gaps.md.`);
|