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.`);