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