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

119 lines
27 KiB
JavaScript
Raw Permalink 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 assert from 'node:assert/strict';
const input = process.argv[2] || 'seo/clusters_exact.csv';
const text = (await fs.readFile(input, 'utf8')).replace(/^\uFEFF/, '');
function parseCSV(csv) {
const result = []; let row = [], field = '', quoted = false;
for (let i = 0; i < csv.length; i++) {
const c = csv[i];
if (c === '"') { if (quoted && csv[i + 1] === '"') { field += '"'; i++; } else quoted = !quoted; }
else if (c === ';' && !quoted) { row.push(field); field = ''; }
else if (c === '\n' && !quoted) { row.push(field.replace(/\r$/, '')); if (row.some(Boolean)) result.push(row); row = []; field = ''; }
else field += c;
}
if (field || row.length) { row.push(field.replace(/\r$/, '')); result.push(row); }
assert.equal(quoted, false, 'Unterminated quoted CSV field');
return result;
}
const [headers, ...records] = parseCSV(text);
assert.deepEqual(headers, ['Кластер (страница)', 'Гео', 'Интент', 'Фраза', 'Точная', 'Широкая']);
const rows = records.map((r) => {
assert.equal(r.length, 6, 'Unexpected column count');
assert.match(r[4], /^\d+$/, `Missing exact frequency: ${r[3]}`);
return { cluster: r[0], geo: Boolean(r[1]), intent: r[2], phrase: r[3], exact: Number(r[4]), broad: Number(r[5]) };
});
const profiles = JSON.parse(await fs.readFile('seo/service-profiles.json', 'utf8'));
const definitions = {
'Ринопластика': ['estetic/rinoplastika', 'Ринопластика', ['Стоимость ринопластики в Махачкале', 'Клиника и специалисты', 'Продолжительность операции и восстановление', 'Отзывы пациентов']],
'Блефаропластика': ['estetic/blefaroplastika', 'Блефаропластика', ['Стоимость блефаропластики в Махачкале', 'Специалисты клиники', 'Отзывы пациентов']],
'Абдоминопластика': ['estetic/abdominoplastika', 'Абдоминопластика', ['Стоимость абдоминопластики в Махачкале', 'Отзывы пациентов']],
'Липосакция': ['estetic/liposaktsiya', 'Липосакция', ['Стоимость липосакции в Махачкале', 'Липосакция живота', 'Продолжительность и последствия']],
'Увеличение груди': ['estetic/mammoplastika', 'Маммопластика', ['Стоимость маммопластики в Махачкале', 'Увеличение груди', 'Отзывы пациентов']],
'Отопластика': ['estetic/ottoplastika', 'Отопластика', ['Стоимость отопластики в Махачкале', 'Коррекция ушей']],
'Риносептопластика': ['estetic/rinoseptoplastika', 'Риносептопластика', ['Стоимость риносептопластики в Махачкале', 'Исправление перегородки носа', 'Продолжительность риносептопластики']],
'Подтяжка груди': ['estetic/podtyazhka-grudi', 'Подтяжка груди', ['Стоимость подтяжки груди', 'Подтяжка груди в Махачкале']],
'Уменьшение груди': ['estetic/umenshenie-grudi', 'Уменьшение груди', ['Стоимость уменьшения груди']],
'Лапароскопия (хаб)': ['ginekologiya/laparoskopiya', 'Лапароскопия', ['Стоимость лапароскопии в Махачкале', 'Отзывы пациентов']],
'Гистероскопия / полипы': ['ginekologiya/gisteroskopiya', 'Гистероскопия и удаление полипов', ['Гистероскопия и гистерорезектоскопия', 'Стоимость удаления полипа матки']],
'Киста / резекция яичника': ['ginekologiya/udalenie-kisty-yaichnika', 'Удаление кисты яичника', ['Стоимость удаления кисты яичника', 'Резекция яичника']],
'Маточные трубы': ['ginekologiya/operatsii-na-matochnykh-trubakh', 'Операции на маточных трубах', ['Лапароскопия маточных труб и стоимость', 'Лечение непроходимости маточных труб']],
'Миома матки': ['ginekologiya/udalenie-miomy-matki', 'Удаление миомы матки', ['Стоимость удаления миомы матки']],
'Шейка матки / метропластика': ['ginekologiya/metroplastika', 'Метропластика и операции на шейке матки', ['Стоимость метропластики', 'Операции на шейке матки']],
'Эндометриоз': ['ginekologiya/lechenie-endometrioza', 'Лечение эндометриоза', ['Лечение эндометриоза', 'Лапароскопия при эндометриозе', 'Отзывы пациентов']],
};
const groups = [...new Set(rows.map((r) => r.cluster))].map((name) => {
const phrases = rows.filter((r) => r.cluster === name).sort((a, b) => b.exact - a.exact);
const exact = phrases.reduce((sum, r) => sum + r.exact, 0);
const geo = phrases.filter((r) => r.geo).reduce((sum, r) => sum + r.exact, 0);
const definition = definitions[name];
const path = definition?.[0];
const nameForPage = definition?.[1];
const title = profiles[path]?.title || `${nameForPage || name} в Махачкале | DANA`;
const description = profiles[path]?.description || `${nameForPage} в DANA Medical в Махачкале. Информация об услуге, стоимость и запись на консультацию.`;
const plastic = path?.startsWith('estetic/');
const average = definition ? plastic ? 218200 : 29696 : null;
return { name, exact, geo, positive: phrases.filter((r) => r.exact > 0).length,
core: phrases[0], phrases, path, title, description,
h1: profiles[path]?.h1 || `${nameForPage} в Махачкале`, h2: definition?.[2] || [],
average, priority: average ? exact * average : null,
state: !definition ? 'Классифицировать, не отдельная страница' : profiles[path] ? 'Существующая страница' : 'План: подтвердить услугу и контент',
};
}).sort((a, b) => (b.priority || 0) - (a.priority || 0));
assert.equal(rows.reduce((sum, r) => sum + r.exact, 0), groups.reduce((sum, g) => sum + g.exact, 0));
assert.equal(new Set(rows.map((r) => r.phrase)).size, rows.length, 'Duplicate phrases would inflate demand');
for (const g of groups.filter((g) => g.path)) {
assert.ok([...g.title].length <= 60, `Title too long: ${g.name}`);
assert.ok([...g.description].length <= 160, `Description too long: ${g.name}`);
}
const n = (v) => Number(v).toLocaleString('ru-RU');
const esc = (v) => String(v).replace(/\|/g, '\\|');
let md = '# SEO-структура DANA Medical\n\n';
md += `Источник семантики: clusters_exact.csv, ${rows.length} фраз, ${groups.length} групп. Точный спрос: ${n(groups.reduce((s, g) => s + g.exact, 0))}. Широкие частоты не суммировались. Экономика направлений взята из предоставленного задания за июнь 2026, отдельно не проверена. Анализ: 15 сентября 2026.\n\n`;
md += 'Реальный стек: Go net/http + PostgreSQL + React/Vite SPA. Существующие URL сохраняются. Готовый HTML услуг генерируется при сборке теми же React-компонентами и отдаётся Go. Новые медицинские услуги и геофилиалы без подтверждения не публикуются.\n\n';
md += '## 1. Сводка кластеров\n\n| Кластер | Точная | Гео | Фраз > 0 | Ядро | Частота ядра | Состояние |\n|---|---:|---:|---:|---|---:|---|\n';
for (const g of groups) md += `| ${g.name} | ${g.exact} | ${g.geo} | ${g.positive} | ${g.core.phrase} | ${g.core.exact} | ${g.state} |\n`;
md += '\nВ предоставленном CSV нет групп «УЗИ при беременности», «Грыжа», «Наркоз / стационар». Их статус — ожидает добора; частоты неизвестны, это не измеренный ноль. Они не включены в дерево и расчёт. «— прочее —» содержит две информационные фразы о лапароскопии (сумма 4): предложено перенести их в FAQ хаба после согласования; отдельная посадочная не нужна. Спрос хаба по исходному кластеру 210, после такой переклассификации 214, в таблице приоритетов сохранена исходная сумма 210.\n\n';
md += '## 2. Дерево страниц\n\n```text\n/services/\n';
for (const section of ['estetic', 'ginekologiya']) {
md += ` ${section}/\n`;
for (const g of groups.filter((g) => g.path?.startsWith(section + '/'))) md += ` ${g.path.split('/')[1]}/ — ${g.name} (${g.state})\n`;
}
md += '```\n\nЛапароскопия — один хаб, ссылки на показания добавлять после подтверждения услуг. Низкочастотные гинекологические страницы — поздняя очередь, не первый этап.\n\n';
md += 'Липосакция: хвост «живота» даёт ' + rows.filter((r) => r.cluster === 'Липосакция' && r.phrase.includes('живота')).reduce((s, r) => s + r.exact, 0) + ' точных показов. Потенциальная подстраница /services/estetic/liposaktsiya/liposaktsiya-zhivota/ требует подтверждения услуги и отдельного текста; до её создания все 248 запросов относятся к основной странице. При разделении: основная 188, живот 60, не 248 + 60.\n\n';
md += 'Ринопластика: хвосты — цена, город, отзывы, длительность, а не самостоятельные виды операции. Страница /ceny/ дублировала бы коммерческий интент и разделила сигналы основной посадочной. Сейчас одна страница с ценами и FAQ. Геоверсии допустимы только при подтверждённых филиалах и отличающемся контенте. Блефаропластика: ядро 388/533 = 72,8%; абдоминопластика: 356/426 = 83,6%; дополнительные страницы не нужны.\n\n';
md += '## 3. Карточки страниц\n\nМета и H2 для новых страниц — редакционные предложения, а не опубликованные утверждения о доступности операций. Наличие маммопластики не доказывает доступность увеличения, уменьшения или подтяжки груди по отдельности: существующая общая страница получает мета «Маммопластика», специализированные обещания не добавлены. H2 по медицинским вопросам публиковать только вместе с проверенным врачом текстом.\n\n';
for (const g of groups.filter((g) => g.path)) {
md += `### ${g.name}\n\nURL: /services/${g.path}/\n\nСостояние: ${g.state}.\n\nTitle (${[...g.title].length}): ${g.title}\n\nDescription (${[...g.description].length}): ${g.description}\n\nH1: ${g.h1}\n\nПлан H2:\n\n`;
for (const h of g.h2) md += `- ${h}\n`;
md += `\nСуммарный спрос исходного кластера: ${g.exact}; гео: ${g.geo}. Ниже полный список фраз, закреплённых за кластером в исходном файле. Геофразы пока не считаются закрытыми отдельными филиальными страницами.\n\n| Фраза | Точная | Гео | Интент |\n|---|---:|---|---|\n`;
for (const r of g.phrases) md += `| ${esc(r.phrase)} | ${r.exact} | ${r.geo ? 'да' : 'нет'} | ${r.intent} |\n`;
md += '\n';
}
md += '## 4. Геостраницы\n\nВ БД есть только ул. Ляхова, 7 и ул. Ляхова, 22 в Махачкале. Каспийск и Дербент упомянуты в задании, но адреса, телефоны, хирурги и доступность операций в проекте отсутствуют. Новые геостраницы не созданы. Нельзя заменять их автоматически клонированным текстом с другим городом.\n\n| Кластер | Махачкала | Каспийск | Дербент | Прочее гео | Решение |\n|---|---:|---:|---:|---:|---|\n';
for (const g of groups.filter((g) => g.geo > 0 && g.path)) {
const sum = (city) => g.phrases.filter((r) => r.geo && r.phrase.includes(city)).reduce((s, r) => s + r.exact, 0);
const m = sum('махачкал'), k = sum('каспийск'), d = sum('дербент');
md += `| ${g.name} | ${m} | ${k} | ${d} | ${g.geo - m - k - d} | ${g.name === 'Ринопластика' && d >= 20 ? 'Дербент — кандидат после подтверждения' : g.name === 'Блефаропластика' && k >= 10 ? 'Каспийск — кандидат после подтверждения' : 'Одна основная страница; филиальный блок только при подтверждении'} |\n`;
}
md += '\nУ ринопластики Дербент даёт 21 точный показ (20 + 1 ценовой), Каспийск — 7. У блефаропластики Каспийск — 13, Дербент — 7. Сначала проверить ринопластику в Дербенте и блефаропластику в Каспийске. Порог не является универсальным правилом: это относительная очередь внутри текущего набора. При выделении города его фразы и спрос вычитаются из основного URL. Махачкала — основной URL, отдельная копия /makhachkala/ не нужна.\n\n';
md += '## 5. Приоритеты\n\nСкоринг = точный спрос × средний чек направления. Это сравнительный индекс, НЕ прогноз выручки: показы не равны посетителям или операциям, конверсия, маржа и стоимость продвижения неизвестны. Чек пластики 218 200 ₽ применяется к направлению в целом, а не заявляется ценой любой операции. Чек оперативной гинекологии 29 696 ₽ используется как приближение для гинекологических групп, включая диагностические, их индивидуальные чеки неизвестны.\n\n| Очередь | Кластер | Точная | Чек направления, ₽ | Расчёт | Индекс |\n|---:|---|---:|---:|---|---:|\n';
groups.filter((g) => g.priority !== null).forEach((g, i) => { md += `| ${i + 1} | ${g.name} | ${g.exact} | ${n(g.average)} | ${g.exact} × ${n(g.average)} | ${n(g.priority)} |\n`; });
md += '\nФормульная очередь и очередь внедрения отличаются: риносептопластика имеет высокий индекс, но услуга и контент не подтверждены. Внедрены мета шести существующих посадочных; новые страницы остаются в плане.\n\n';
md += '## 6. Брифы трёх приоритетных страниц\n\nОбщее требование: автор-врач с реальной ссылкой на профиль, дата медицинской проверки, подтверждённая лицензия, актуальная стоимость с составом услуги. Не выдумывать стаж, результаты, гарантии, противопоказания или сроки. Медицинские тексты и ответы на вопросы утверждает профильный врач. До/после публиковать только с документированным согласием пациента; отзывы — только реальные и прошедшие модерацию.\n\n';
for (const name of ['Ринопластика', 'Блефаропластика', 'Абдоминопластика']) {
const g = groups.find((g) => g.name === name);
md += `### ${name}\n\nПервый экран: ${g.h1}, реальное описание услуги и запись на консультацию. Блок цен: актуальные позиции прайса, что входит в цену и что оплачивается отдельно. Карточка именно оперирующего хирурга, а не автоматическая приписка всем врачам направления. Лицензия и реквизиты, фактический адрес проведения операции. Портфолио до/после и реальные отзывы, если получены необходимые согласия.\n\nВопросы из семантики:\n\n`;
for (const h of g.h2) md += `- ${h}\n`;
md += '\nВопросы безопасности сверх SEO-спроса: показания, ограничения, риски, альтернативы, подготовка и контроль после вмешательства. Это обязательный контентный бриф, а не новые кластеры и не придуманные частоты. Не публиковать ответы без врача. Источники: профильные клинические рекомендации по конкретной операции и актуальная версия документа; редактор фиксирует ссылки после выбора врачом. Общий рубрикатор https://cr.minzdrav.gov.ru/ не выдавать за источник конкретной медицинской рекомендации.\n\n';
}
md += '## 7. Реализация и эксплуатация\n\nМаршруты: один параметризованный Go-хендлер сайта; API сохраняет /api/v1/services/{path...}. Существующие пути из services.path — источник маршрутов. Контент, цены и связи с врачами остаются в PostgreSQL, SEO-профили шести страниц — в seo/service-profiles.json. Непустые SEO-поля из БД имеют приоритет: существующие редакторские данные не перезаписываются. Для редких согласованных SEO-правок небольшой JSON в репозитории проще нового CMS-слоя; основной медицинский контент уже редактируется через БД/админку.\n\nИерархия разметки: существующий Layout (шапка/подвал) → ServicePage → DirectionView или ServiceView → секции описания, карточки врачей, прайс и BookingSection. Новые независимые Go-шаблоны не дублируют дизайн: renderToString при сборке сохраняет готовый HTML React-компонентов, Go отдаёт статические файлы. JS гидратирует тот же HTML и включает формы/навигацию; основной текст присутствует до JS. React остаётся прежним, тяжёлый новый фреймворк не добавлен.\n\nSchema.org: MedicalWebPage + BreadcrumbList на услуге; MedicalClinic — только реальные филиалы; Physician — только реальные видимые врачи с профилями, без выдуманного авторства; MedicalProcedure — шесть подтверждённых существующих страниц пластики. Авторство и лицензии не добавляются в JSON-LD без проверки. Разметка не гарантирует расширенный сниппет.\n\nSitemap: Go строит XML из опубликованных услуг в БД, сверяет с manifest успешной HTML-сборки и наличием файла. Заглушки и неизвестные URL не включаются. Robots исключает /api/, /admin и query-URL; локальный адрес полностью закрыт от индексации. Удалённая/снятая с публикации услуга не отдается даже при оставшемся HTML. Неизвестная услуга — 404, опубликованная без HTML — 503, а не пустая 200. Канонический вариант страниц со слешем; вариант без слеша — 301 с сохранением query. Canonical и Open Graph берутся из тех же SEO-данных. При смене маршрута SPA мета обновляются.\n\nЗапуск:\n\n```powershell\n# Сначала запустить API с DATABASE_URL и MEDIA_DIR.\n# Первый запуск без web/dist допустим и предоставляет API для генератора.\ncd web\n$env:DANA_API = "http://127.0.0.1:8080"\n$env:SITE_URL = "https://danamedical.ru" # пример, подтвердить боевой домен\nnpm ci\nnpm run build\n# Запустить Go с WEB_DIR=./web/dist и тем же SITE_URL.\n```\n\nНа боевом сервере reverse proxy должен отправлять не только /api/ и /media/, но и страницы услуг, /robots.txt, /sitemap.xml в Go. Нельзя подменять услуги SPA fallback-ом nginx/Vite. В dev npm run dev остаётся SPA; для проверки готового HTML открывать Go-порт 8080. После правок контента, цен, врачей, SEO или филиалов обязательна повторная HTML-сборка и перезапуск Go. Автоматический rebuild после админских правок пока не реализован. Рекомендуемый релиз: сборка в отдельную директорию и переключение WEB_DIR после успеха, чтобы не показывать промежуточную сборку.\n\n';
md += '## 8. Что требует уточнения\n\n- Подтвердить боевой домен, HTTPS и прокси. Локальная сборка canonical http://127.0.0.1:8080 намеренно закрыта от индексации. Деплой и отправка sitemap в панели поисковиков не выполнялись.\n- Полный semantics_exact.csv в этом запросе не предоставлен; использовать только переданный clusters_exact.csv. Числа 3519/606/350 из задания не подменяют фактическое количество строк переданного файла.\n- Адреса, телефоны, лицензии и доступность операций в Каспийске/Дербенте; ЦОМ также не заводить без данных.\n- Риносептопластика не равна септопластике, нельзя автоматически переименовать существующую услугу.\n- Подтвердить отдельные виды маммопластики и гинекологических операций перед созданием посадочных.\n- Все медицинские H2/FAQ, авторство, источники, портфолио, состав стоимости, оперирующие хирурги и лицензия требуют редакторской/врачебной проверки.\n- Небольшой ненулевой информационный спрос есть (в том числе восстановление после ринопластики 3 и группа прочее 4); вывод задания о нулевом info не буквальный. Отдельный блог не нужен, ответы размещаются на коммерческих страницах после проверки.\n- Новые геоверсии и липосакция живота — предложения, пока не включены в sitemap и не опубликованы.\n- Индекс приоритета не заменяет реальные чеки конкретных услуг и конверсии.\n- SSR-снимки обновляются сборкой; нужен процесс rebuild после админских изменений до боевого использования.\n';
// Keep the generated operational section aligned with the current renderer.
const runtime = await fs.readFile('seo/DEPLOYMENT.md', 'utf8');
md = md.replace(/## 7\. Реализация и эксплуатация[\s\S]*?(?=## 8\.)/, `## 7. Реализация и эксплуатация\n\n${runtime.replace(/^# HTML и выпуск SEO-версии\n/, '').replace(/^## /gm, '### ')}\n\n`);
md = md.replace('- SSR-снимки обновляются сборкой; нужен процесс rebuild после админских изменений до боевого использования.', '- Проверить боевой Node runtime, прокси и производительность SSR под реальной нагрузкой; Docker-образ пока не запускался.');
await fs.writeFile('seo-structure.md', md);
await fs.writeFile('seo/cluster-analysis.json', JSON.stringify({ source: 'clusters_exact.csv', rows: rows.length, groups }, null, 2));
console.log(JSON.stringify({ rows: rows.length, clusters: groups.length, exact: groups.reduce((s, g) => s + g.exact, 0), top: groups.slice(0, 3).map((g) => ({ name: g.name, exact: g.exact, priority: g.priority })) }, null, 2));