План реализации · компаньон функциональной спецификации v3
Рабочий план: контракты данных и API, архитектурные решения, 42 задачи с пошаговыми разборами, файлами, зависимостями и критериями «готово», волны, миграции, тест-план, деплой. Домен не блокирует разработку — только го-лайв.
01
feature/seo-w1-backend, 150 тестов).
Состояние раздела, десять решений, которые не надо отменять, и грабли для продолжающего —
docs/internal/14-seo-section.md в репозитории; его читать раньше этого плана.
Фактический контракт админ-API — backend/docs/SEO_ADMIN_API.md: §4.2 ниже устарел в десяти местах,
все изменения в пользу спеки. Добавлены АР-13 и задача B-12.backend/admin-panel/, Django backend/nikea-backend/django-main-app/, витрина web-events/, деплой стенда backend/local/demo/.02
dusha.uxbrain.ru, за basic auth) и локально. Домен нужен ровно трём вещам, и все они — последний шаг («го-лайв», §10):| Что | Нужен ли домен | Как работаем без него |
|---|---|---|
| Весь код: модели, API, редактор, балл, ИИ, публикация, блог-страницы, счётчик | нет | Блог-маршруты живут на стендовом podushe (dusha.uxbrain.ru/podushe/blog/…) за basic auth с noindex — публикация, 301/410, sitemap, счётчик проверяются целиком |
| ИИ (OpenRouter) и Вордстат | нет | ключи не привязаны к домену — работают со стенда сразу |
| Критерии приёмки A-1…A-7 | нет | прогоняются на стенде; JSON-LD проверяется валидатором по копии кода страницы |
| Открытие индексации (робот видит сайт) | да | го-лайв: DNS + site-блок Caddy без noindex/auth |
| Кабинеты: Метрика, Вебмастер, GSC, IndexNow | да (Вебмастер/GSC подтверждают права на домен) | счётчик Метрики можно завести заранее на стендовый домен для отладки целей, затем создать боевой |
| Реальные данные аналитики (позиции, CTR) | да (после го-лайва и индексации) | сборщики этапа 2 отлаживаются на моках + на стендовом счётчике Метрики |
03
| ID | Решение | Обоснование и отвергнутые альтернативы |
|---|---|---|
| АР-1 | Новый Django-app seo/ владеет моделями; adminpanel/views/seo*.py — только вьюхи админ-API. | Инвариант проекта: adminpanel моделями не владеет. В events/ класть неверно — статьи двух продуктов. Образец публикуемой сущности — EventCollection (events/models.py:83). |
| АР-2 | Редактор — Editor.js, self-hosted в admin-panel/assets/vendor/editorjs/ + кастомные блоки (cta, event_card, feed, faq, aside). | SPA — vanilla JS без сборщика, CSP script-src 'self' → CDN нельзя, файлы кладём локально; Editor.js работает как standalone-скрипты и отдаёт ровно наш формат «массив блоков JSON». Свой contenteditable — отвергнут (месяцы на краевые случаи); TipTap/ProseMirror — требуют сборки. |
| АР-3 | Контент — блоки JSON (схема §4.1); сервер валидирует схему и санитизирует инлайн (whitelist b, i, a[href], схемы http/https); витрина рендерит блоки Vue-компонентами. Сырой HTML не проходит никогда. | Закрывает ED-2/MT-2 («менеджер не может сломать разметку») и XSS одним механизмом. Рендер компонентами даёт SSR и единый вид. |
| АР-4 | Балл — единственная реализация на Python (seo/scoring/). Ответ автосейва уже содержит seo_score + чек-лист (§4.3) — отдельный запрос не нужен; POST …/score остаётся для явного пересчёта. На клиенте — только мгновенные индикаторы полей (пиксельная ширина title/description — таблица ширин глифов в JS). | Дублировать лемматизацию и 20 проверок в JS — гарантированный рассинхрон клиентского и серверного балла. Автосейв ≤ 5 с + ответ ≤ 300 мс закрывают и «живость» (A-3), и «истина на сервере» (SC-1) без второго запроса. |
| АР-5 | Лемматизация — pymorphy3 + LRU-кеш словоформ; предложения — razdel; канцелярит — словарь в конфиге; пиксельные ширины — предрассчитанная таблица (Arial 20/14 px как приближение выдачи). | TF-эвристики по русскому без лемм бесполезны. pymorphy3+razdel — стандарт, с кешем укладывается в бюджет NF-2 (РР-4). |
| АР-6 | LLM — свой тонкий клиент OpenRouter (seo/llm.py): chat-completions (stream) + images; SSE до браузера через StreamingHttpResponse; расход → AiSpend; бюджет-гейт перед вызовом; протокол §4.4. | Образец стиля — voice/llm.py (env-конфиг, голый HTTP, fail-soft), но без стриминга и учёта — пишем свой. SDK не тянем: две ручки, зависимость не окупается. |
| АР-7 | Блокировка — Redis (существующий cache db 1): ключ seo:lock:{article_id} → {staff, since}, TTL 120 с, heartbeat 30 с; перехват = перезапись + запись в аудит. | Без новых таблиц; TTL сам снимает блок умершей вкладки (EC-9). |
| АР-8 | Инвалидация страниц: SWR + принудительный сброс. routeRules '/blog/**': swr 300; Nuxt server-route POST /api/_revalidate (заголовок x-secret: BLOG_REVALIDATE_SECRET, тело {paths:[…]}) удаляет ключи из nitro-cache storage; Django зовёт его из on_commit конвейера. | Голый SWR — «статья видна через ≤ 5 мин», нарушает A-1 (≤ 60 с). Без кеша — страдает CWV. Запасной план при нестабильности сброса — РР-2. |
| АР-9 | Мультидомен: один образ витрины — N контейнеров. Все доменные различия — runtime-env (SITE_PRODUCT, NUXT_PUBLIC_SITE_ORIGIN, id счётчика Метрики), не build-time. Caddy — отдельный публичный site-блок на домен. | Сейчас NUXT_APP_BASE_URL запекается в билд; для блога так нельзя — иначе два билда на каждый чих. Стендовый блок Caddy не трогаем. |
| АР-10 | Sitemap, RSS, robots — Nuxt server-routes, данные из публичного blog-API, кеш 10 мин, сбрасываются тем же revalidate. | Файлы обязаны жить на домене витрины; генерация рядом с рендером — нет синхронизации между сервисами. |
| АР-11 | Воркер — сервис worker в demo-compose (manage.py rundramatiq + dramatiq-crontab); все фоновые задачи раздела — акторы в seo/tasks.py. | Критическая дыра стенда: cron-задачи сейчас не исполняются вовсе. Нужен рано (W1): расписание, свёртка счётчика, ретраи, срез данных для ИИ. |
| АР-12 | Дев-контур без домена: блог-маршруты включаются на стендовом podushe (за basic auth, NUXT_PUBLIC_INDEXABLE=0); Django-стенд знает BLOG_ORIGIN_EVENTS=https://dusha.uxbrain.ru/podushe; вся цепочка публикации, включая revalidate и sitemap, работает и приёмается на стенде. Го-лайв = поднять второй контейнер с боевыми env + DNS. | Снимает домен с критического пути полностью (§2). Ничего не выбрасывается: контейнер го-лайва использует тот же образ. |
| АР-13 | У статьи две копии: колонки — черновик (туда пишет автосейв), Article.published — снимок показываемых полей на момент публикации; публичная ручка (B-8) читает его, конвейер (B-7) кладёт туда article.publish_snapshot(). Удвоены только показываемые поля; адрес, рубрики, продукт и даты — в одном экземпляре, поэтому смена адреса применяется сразу. | Без разделения правка опубликованной статьи уезжала на сайт сама в окне SWR: «Опубликовать изменения» оставалась кнопкой без работы, откат к версии менял живую страницу до вычитки, а текст от ИИ выходил раньше человека (AI-5). Одна колонка чинит три обещания сразу. Дублировать искомые поля нельзя — по ним ищут, а искать по JSON нечем. |
04
notices (EC-7), поля zone, saved_at, scheduled_at, public_url,
has_unpublished_changes, ручки создания, чтения и дублирования статьи, отказы вместо молчания на неизвестное поле и
неверный фильтр. Все изменения — в пользу спеки. Правда: backend/docs/SEO_ADMIN_API.md, там же помечено каждое расхождение.
Отдельно для S-2/S-4: editor.save() у Editor.js отдаёт {time, blocks, version:"2.30.7"} — валидатор такое
отвергает, отображение в формат §4.1 обязано быть на клиенте.Фиксируются в первый день W1 (задача B-0) — дальше B/S/F пишутся против них параллельно.
Article.blocks){ "version": 1, "blocks": [
{"id":"b1","type":"paragraph","data":{"text":"Инлайн: <b>, <i>, <a href>. Остальное режется."}},
{"id":"b2","type":"header", "data":{"text":"…","level":2}}, // level ∈ {2,3}
{"id":"b3","type":"image", "data":{"file_id":"s3-key","alt":"…","caption":"",
"width":"column","w":1600,"h":900}}, // width ∈ column|wide; alt обязателен
{"id":"b4","type":"list", "data":{"style":"unordered","items":["…"]}}, // style ∈ unordered|ordered
{"id":"b5","type":"quote", "data":{"text":"…","caption":"источник"}},
{"id":"b6","type":"aside", "data":{"kind":"tip","text":"…"}}, // kind ∈ tip|important|fact
{"id":"b7","type":"faq", "data":{"items":[{"q":"…","a":"…"}]}},
{"id":"b8","type":"cta", "data":{"kind":"app","label":"Скачать приложение","href":null}},
// kind ∈ app|link; app → сторы по product
{"id":"b9","type":"event_card","data":{"event_id":"uuid"}},
{"id":"bA","type":"feed", "data":{"kind":"events","mode":"filter","city":"moscow",
"categories":[],"days":14,"free_only":false,
"ids":[],"sort":"soonest","count":6,
"view":"grid","title":"Ближайшие события"}},
// kind ∈ events|profiles; mode ∈ filter|manual
{"id":"bB","type":"delimiter","data":{}}
]}
Валидатор seo/blocks.py: неизвестный тип/поле — ошибка 400 с указанием блока; санитайзер инлайна применяется к paragraph.text, quote.text, list.items[], faq.items[].a. Порядок массива = порядок на странице.
GET /seo/articles?status=&product=&author=&tag=&needs_review=&q=&sort=&page=&limit=
→ {"items":[{id, product, status, h1, slug, cover_thumb, seo_score,
author:{id,name}, needs_review, published_at, updated_at,
views_30d}], // views_30d — null до этапа 2
"page":{page, limit, total}}
PATCH /seo/articles/{id} // автосейв: любое подмножество полей
body: {h1?, lead?, blocks?, seo_title?, seo_description?, slug?, cover_id?,
cover_alt?, author_id?, tags?, keywords?, product?}
→ 200 {"saved_at":"…", "seo_score":78, "zone":"warn", "checks":[…см. 4.3…]}
→ 409 {"error":"locked","by":{"name":"Марина"},"since":"…"} // нет лока
→ 400 {"error":"blocks_invalid","block_id":"b7","detail":"…"}
POST /seo/articles/{id}/publish → 200 {version:5, url:"…"} | 422 {blockers:[…]} | 409 warnings-confirm
POST /seo/articles/{id}/unpublish | schedule {at} | review-done
POST /seo/articles/{id}/lock | heartbeat | unlock | takeover
GET /seo/articles/{id}/versions → [{n, created_at, by, summary}]
POST /seo/articles/{id}/versions/{n}/restore → черновик из снапшота
"checks":[{"id":"title_length","group":"meta","weight":8,
"passed":false,"partial":0.0,
"message":"Заголовок 74 знака — сократите до 60",
"hint":"Поисковики обрезают заголовок по ширине…", // раскрытие
"anchor":"seo_title"}] // куда скроллить UI
Порядок групп фиксирован: meta → structure → content → media → links → misc. partial ∈ [0,1] — частичный зачёт (проверка №10). Сумма weight = 100 (валидируется тестом).
POST /seo/ai/generate {article_id, brief} → stream:
event: plan data: {h1, lead, sections:[{h2, thesis}], title_variants:[…5],
description_variants:[…3], cta_after:[1,4], feed_after:2}
// клиент показывает план; после подтверждения:
POST /seo/ai/generate {article_id, plan, run:true} → stream:
event: section data: {index:0, blocks:[…схема 4.1…]} // по секции за раз
event: usage data: {tokens_in, tokens_out, cost_rub}
event: done data: {needs_review:true}
event: error data: {code:"budget_exceeded"|"upstream_down"|…, message}
POST /seo/ai/chat {article_id, message} → stream:
event: delta data: {text:"…"} // текст ответа
event: proposal data: {target:"blocks|seo_title|…", before, after} // diff
event: done data: {usage}
POST /seo/ai/edit {article_id, block_id, range, action} // action ∈ rephrase|shorten|expand|simplify|fix
POST /seo/ai/image {article_id, prompt, kind:"cover|inline"} → {variants:[{tmp_id, url}]} → POST …/image/accept
POST /api/v1/blog/track // батч ≤ 20, sendBeacon-совместимый, без кук
{"s":"uuid-вкладки","e":[
{"t":"view","a":"article_id","src":"yandex"}, // src вычислен на клиенте по referer:
{"t":"scroll","a":"…","v":75}, // yandex|google|search|social|direct|internal
{"t":"read","a":"…"},{"t":"time","a":"…","v":184},
{"t":"cta_click","a":"…","b":"b8","k":"app"},
{"t":"feed_view","a":"…","b":"bA"},
{"t":"feed_item_click","a":"…","b":"bA","item":"event_uuid","pos":2}]}
→ 204. Троттлинг 60 запросов/мин/IP; UA-ботофильтр; неизвестный t — молча пропустить.
GET /api/v1/blog/articles?product=events&tag=&page= → карточки + пагинация
GET /api/v1/blog/articles/{slug}?product=events
→ 200 {article + blocks + author + related[3]}
→ 301 {"location":"/blog/new-slug"} // slug из slug_history
→ 410 | 404
GET /api/v1/blog/feed/{article_id}/{block_id} → {items:[…карточки…]} // события или анкеты
GET /api/v1/blog/authors/{slug}, /api/v1/blog/tags/{tag}
05
| ID | Задача | Реализует | Когда нужна | Дн. |
|---|---|---|---|---|
| I-1 | сделано Воркер Dramatiq в compose (АР-11) | PB-3, AN-1 | W1 | 1 |
| I-2 | Ключи OpenRouter + заявка Вордстат API | AI-*, KW-1 | к W2 | 0.5 |
| I-3 | Дев-контур блога на стенде (АР-12) | §2 | к W3 | 0.5 |
| I-4 | Домен: покупка (O-1, команда), DNS | Д-2 | к го-лайву | 0.5 + ожидание |
| I-5 | Кабинеты: Метрика (+цели), Вебмастер, GSC, IndexNow-ключ | AN-1, PB-1 | к го-лайву | 0.5 |
| I-6 | Го-лайв: публичный site-блок Caddy + боевой контейнер витрины | BL-1 | веха после W3 | 0.5 |
worker: образ api, entrypoint python manage.py rundramatiq --processes 1 --threads 4, depends_on db/redis healthy.seo.ping (@cron каждую минуту, пишет строку в лог) — убедиться, что крон реально тикает на стенде.backend/docs/ADMIN_STAGE_DEPLOY.md.podushe добавить env: SITE_PRODUCT=events, BLOG_REVALIDATE_SECRET.BLOG_ORIGIN_EVENTS=https://dusha.uxbrain.ru/podushe — конвейер публикации зовёт revalidate сюда./podushe/blog отвечает за basic auth, noindex на месте (стенд остаётся закрытым).dusha.uxbrain.ru/podushe/blog/<slug> ≤ 60 с.podushe-public: тот же образ, env боевого домена, NUXT_PUBLIC_INDEXABLE=1.X-Robots-Tag, без basic auth, свой robots.txt/sitemap изнутри приложения.BLOG_ORIGIN_EVENTS на боевой домен; IndexNow-ключ положить по /{key}.txt.curl -I https://домен/blog — 200, без noindex-заголовков; robots.txt отдаёт sitemap; Вебмастер видит сайт без ошибок.06
| ID | Задача | Реализует | После | Дн. |
|---|---|---|---|---|
| B-0 | Зафиксировать контракты §4 (ревью вдвоём) | — | — | 0.5 |
| B-1 | сделано Модели + миграции + валидатор блоков | §8 спеки | B-0 | 2 |
| B-2 | сделано CRUD статей + автосейв | LS-1, ED-1 | B-1 | 2 |
| B-3 | сделано Блокировка (Redis) | ED-3 | B-2 | 1 |
| B-4 | Движок балла | SC-1 | B-1 | 4 |
| B-5 | Вордстат-клиент + кеш | KW-1 | B-1, I-2 | 2 |
| B-6 | LLM-прокси + контекст + бюджет | AI-1…AI-5 | B-1, I-2 | 4 |
| B-7 | Конвейер публикации + расписание | PB-1…PB-4 | B-1 | 3 |
| B-8 | Публичное blog-API + лента | BL-1, FD-1/2 | B-7 | 2 |
| B-9 | Счётчик + свёртка | TR-1 | B-1 | 1.5 |
| B-10 | CRUD базы знаний / авторов / анкет | KB-1 | B-1 | 1 |
| B-11 | RBAC + аудит + тестовый добор | AC-1 | B-2…B-10 | 1.5 |
| B-12 | Загрузка файлов | ED-2, MT-2, EC-6 | B-2 | 1 |
seo; 9 моделей по §8 спеки, поля и индексы — §11 плана. У Article: public_path property (/blog/<slug>), domain property (по product из env).seo/blocks.py: validate(blocks) → blocks | BlockError(block_id, detail) по схеме §4.1; sanitize_inline(html) — whitelist b/i/a[href http|https], всё прочее — strip тегов, текст сохраняется.slugify_ru(h1): транслитерация ГОСТ-подобная, стоп-слова, ≤ 6 слов.tests_dating_tz/factories_seo.py).adminpanel/pagination.py.score:null).slug_history (append, dedupe).published_at есть (EC-12).acquire(article, staff) / heartbeat / release / takeover на Redis (АР-7); TTL 120 с.textprep.prepare(article) → Ctx: плоский текст из блоков, предложения (razdel), леммы (pymorphy3 + LRU 100k словоформ), дерево заголовков, ссылки, картинки, счёт слов. Готовится один раз на вызов — все 20 проверок читают Ctx.def run(ctx, cfg) → CheckResult(passed, partial, message, hint). Пороги — из settings.SEO_SCORE_CFG (веса, лимиты, словарь канцелярита).px.py: ширина строки в px по таблице ширин глифов (латиница+кириллица, Arial 20px desktop / 14px mobile); экспорт этой же таблицы в JSON для клиентского индикатора (S-5).topRequests(phrase, regions), dynamics, regions; OAuth-токен из env; таймаут 10 с, fail-soft.KeywordCache(query, region, payload, fetched_at); отдача из кеша при возрасте ≤ 7 дн; при исчерпании квоты (HTTP 429/лимит) — кеш любой давности + флаг stale (EC-2).stream_chat(model, messages) → iterator, image(model, prompt) → urls; заголовок prompt-caching для статичной части.ai_context.build(article) → messages: [СИСТЕМНОЕ: SEO-гайд (константа ~800 слов, выжимка §11–12 спеки) + активные KnowledgeDoc по product + общие + часовой срез каталога (актор пишет в кеш: события по топ-10 городам, категории, 5 примеров)] + [СТАТЬЯ: h1, лид, план заголовков, метаданные, запросы, незакрытые пункты чек-листа].SEO_AI_MONTHLY_BUDGET_RUB; пороги 80/100/120% (EC-16); цена — из ответа OpenRouter (usage → руб по курсу-константе).generate: два шага по протоколу §4.4 (план → посекционно); каждая секция — блоки через валидатор B-1; по завершении — needs_review=true если доля ИИ-текста ≥ 50% (счётчик знаков).chat: SSE delta + proposal-диффы (модель просят отвечать структурно: JSON-блок в конце ответа, парсим fail-soft); история — ArticleChat.edit: 5 действий над фрагментом, быстрая модель.image: 2–4 варианта → tmp-хранение → accept перекладывает в S3 обычной картинкой (через seo/images.py).@transaction.atomic
def publish(article, actor, confirm_warnings=False):
blockers = check_blockers(article) # h1, author, cover, needs_review, blocks≠∅
if blockers: raise PublishBlocked(blockers) # → 422
warnings = check_warnings(article) # score<50, keywords=∅
if warnings and not confirm_warnings: raise NeedConfirm(warnings) # → 409
version = ArticleVersion.snapshot(article, actor)
article.status, article.updated_at = PUBLISHED, now()
article.published_at = article.published_at or now()
article.save(); audit(actor, "seo.publish", article, version.n)
transaction.on_commit(lambda: after_publish(article))
def after_publish(article): # всё fail-soft, ошибки в лог
revalidate([article.public_path, "/blog", "/sitemap.xml", "/blog/rss.xml"])
indexnow_ping.send(article.id) # актор: max_retries=10, exp backoff, до 24ч
publish_scheduled (@cron каждую минуту): созревшие scheduled → publish от имени «system», ошибки — в аудит + метка на статье.AllowAny, только published; product — обязательный параметр (витрина шлёт свой).seo.write.test_every_section_belongs_to_a_product и др.) — обязаны быть зелёными.POST /admin-api/seo/upload (multipart) → {file_id, url, w, h}. Образец в проекте есть: adminpanel/views/catalog.py::AdminEventCoverView.cover_id статьи или в file_id блока «картинка» — автосейв их уже принимает, контракт менять не нужно.cover_width/cover_height. Колонки есть, писать в них некому — из-за этого проверка балла №4 «обложка ≥ 1200×630» сейчас недостижима, и потолок балла 96, а не 100.ENDPOINT_RULES (раздел seo_articles, действие seo.write) — иначе fail-closed 403.07
| ID | Задача | Реализует | После | Дн. |
|---|---|---|---|---|
| S-1 | Каркас раздела | §7 спеки | B-11 | 1 |
| S-2 | Список статей | LS-1 | S-1, B-2 | 1.5 |
| S-3 | Каркас редактора + автосейв + лок | ED-1, ED-3 | S-1, B-2/3 | 2.5 |
| S-4 | Editor.js + кастомные блоки | ED-2 | S-3 | 4 |
| S-5 | SEO-панель | MT-1, SC-1 | S-3, B-4 | 2.5 |
| S-6 | Подбор запросов UI | KW-1 | S-3, B-5 | 1 |
| S-7 | ИИ-чат + правка выделенного | AI-2/3 | S-3, B-6 | 2.5 |
| S-8 | Генерация целиком + картинки | AI-1/4/5 | S-7 | 2 |
| S-9 | Публикация/версии/расписание UI | PB-1…4 | S-3, B-7 | 1.5 |
| S-10 | Вкладки БЗ/авторы/анкеты | KB-1 | S-1, B-10 | 1.5 |
{id:'seo', label:'SEO', short:'SEO', icon:'✎', hint:'Статьи и поисковый трафик'}.core.js::bindDelegation).#seo/articles работает; консоль без warnOnProductDrift.localStorage['seo_draft_'+id]; при открытии — если буфер новее server updated_at, диалог «восстановить локальные правки?».08
| ID | Задача | Реализует | После | Дн. |
|---|---|---|---|---|
| F-1 | Страницы блога + рендер блоков | BL-1 | B-8 | 3.5 |
| F-2 | Мета-обвязка (useBlogSeo) | MT-2 | F-1 | 1 |
| F-3 | Лента + карточка события | FD-1 | F-1 | 1.5 |
| F-4 | Sitemap/RSS/robots + revalidate | PB-1 | F-1 | 1 |
| F-5 | Счётчик-скрипт + Метрика | TR-1 | F-1, B-9 | 1.5 |
| F-6 | CWV-паспорт | BL-2, NF-1 | F-1…F-5 | 1.5 |
| F-7 | Runtime-мультидомен | §6 спеки | F-1 | 1 |
app/shared/api.ts (docker-сеть http://api:8000); 301 от API → navigateTo(redirect, 301); 410 → страница «статья удалена» со статусом 410; 404.'/blog/**': { swr: 300 }.SITE_ORIGIN + path (UTM срезаны), OG (og:image = абсолютный URL обложки 1200×630), twitter:card.NUXT_PUBLIC_INDEXABLE=0 (стенд) или предпросмотр./blog/feed/…; onMounted — тихий рефетч; пустой ответ — блок не рендерится (SSR тоже); клик карточки — трек + переход; event_card — тот же механизм на один id.useStorage('cache').removeItem(ключи путей) — точный формат ключа nitro зафиксировать тестом (РР-2).09
| ID | Задача | Реализует | После | Дн. |
|---|---|---|---|---|
| A2-1 | Сборщик Метрики + автоцели | AN-1 | этап 1, I-5 | 1.5 |
| A2-2 | Сборщик Вебмастера | AN-1 | этап 1, I-5 | 1.5 |
| A2-3 | Сборщик GSC | AN-1 | этап 1, I-5 | 1.5 |
| A2-4 | Admin analytics API | AN-1 | A2-1…3 | 2 |
| A2-5 | Вкладка «Аналитика» | AN-1 | A2-4 | 3 |
GET api-metrika.yandex.net/stat/v1/data — metrics ym:s:visits, ym:s:pageviews, ym:s:avgVisitDurationSeconds, ym:s:bounceRate, dimensions ym:s:startURL, ym:s:lastTrafficSource, filter startURL содержит /blog/; goals — отчёт по целям. Автоцели: Management API POST /management/v1/counter/{id}/goals (JS-события seo_read/seo_cta/seo_feed) — идемпотентно при го-лайве.GET /v4/user/{uid}/hosts; метрики запросов из API мониторинга запросов (indicators: SHOWS, CLICKS, CTR, POSITION; фильтр URL /blog/); квота переобхода — не трогаем.searchanalytics.query {dimensions:[query,page], фильтр page contains /blog/, rowLimit 25000, даты «позавчера» из-за задержки ~2 дня}; сервис-аккаунт из GSC_CREDENTIALS_JSON.10
Пакет I идёт параллельно всем волнам; жёсткая зависимость от домена — только у вехи го-лайва. При двух разработчиках B ∥ S/F (контракты §4 фиксируются в B-0).
B-0 → B-1 → B-2, B-3 ∥ I-1 — сделано 27.08.2026; осталось S-1 → S-2, S-3. Выход: статья создаётся, набирается абзацами, автосохраняется, лок работает.
S-4 (гейт по РР-1 в первые 2 дня) ∥ B-4 → S-5 ∥ B-5 → S-6. Выход: полный редактор с баллом и подбором запросов.
B-7, B-8 → F-1, F-2, F-3, F-4 → S-9 ∥ I-3. Выход: A-1, A-5, A-6, A-7 зелёные на стенде. ⭐ Веха го-лайв: как только куплен домен — I-6 (0.5 дня) в любой момент после этой волны.
B-6 → S-7 → S-8. Выход: сценарий S1 спеки целиком; A-2 зелёный.
B-9 + F-5 → F-6, F-7 ∥ B-10 + S-10 → B-11. Выход: DoD этапа 1 полностью.
A2-1…A2-3 ∥ → A2-4 → A2-5. Данные позиций пойдут через 2–4 недели после индексации — W6 не обязана следовать сразу за W5.
Сжатие сроков: SHOULD-куски (расписание в B-7/S-9 ≈ 1 д, картинки в B-6/S-8 ≈ 1.5 д, правка выделенного в S-7 ≈ 0.5 д) выпадают без каскада; COULD (лента анкет) не запланирована до закрытия O-7.
11
| Таблица | Ключевые индексы и ограничения |
|---|---|
seo_article | unique(product, slug); CHECK на product и status; индексы status, product, published_at desc; GIN по slug_history и tags. Колонка published — копия показываемых полей (АР-13). tags и slug_history — массивы, а не JSON: конвенция проекта (Event.interests), у JSON нет overlap |
seo_articleversion | unique(article, number); снапшот JSONB |
seo_author / seo_knowledgedoc / seo_showcaseprofile | unique slug у автора; is_active-индексы; у анкеты — consent_date, consent_basis NOT NULL |
seo_articlechat | FK article; messages JSONB |
seo_bloghit | индекс (article_id, occurred_at); без FK (горячий путь); ретеншн 180 дн актором |
seo_articlemetricdaily | unique(article, date, source); metrics JSONB |
seo_aispend | индекс (month, article); суммы месяца для бюджет-гейта |
seo_keywordcache | unique(query, region); fetched_at |
12
test_seo_models.py (валидатор блоков, санитайзер, slugify), test_seo_api.py (CRUD/автосейв/локи/запреты), test_seo_scoring.py (golden ×8, сумма весов, перф-бюджет), test_seo_publish.py (конвейер, 301/410, расписание), test_seo_public.py (blog-API, лента, EC-4), test_seo_track.py (счётчик, свёртка), test_seo_ai.py (бюджет, needs_review, деградация — все на моках), test_seo_rbac.py, test_seo_collectors.py (этап 2).local/smoke_admin_spa.py дополнить): раздел открывается → статья создаётся → блок каждого типа добавляется → балл пришёл → публикация до тоста → round-trip блоков без потерь.13
bun dev витрины + Redis/Postgres из compose; ИИ и Вордстат — по живым ключам или мокам (env-флаг).podushe (I-3). Приёмка всех критериев — здесь.worker — новый сервис (I-1); podushe — плюс env блога (I-3); podushe-public + site-блок Caddy — только на го-лайве (I-6).OPENROUTER_API_KEY SEO_AI_MODEL_WRITER / _FAST / _IMAGE
SEO_AI_MONTHLY_BUDGET_RUB WORDSTAT_OAUTH_TOKEN
METRIKA_TOKEN METRIKA_COUNTER_EVENTS / _DATING
WEBMASTER_TOKEN GSC_CREDENTIALS_JSON
INDEXNOW_KEY_EVENTS / _DATING BLOG_ORIGIN_EVENTS / _DATING
BLOG_REVALIDATE_SECRET SITE_PRODUCT · NUXT_PUBLIC_SITE_ORIGIN · NUXT_PUBLIC_METRIKA_ID · NUXT_PUBLIC_INDEXABLE
14
| Пакет | Состав | Дней |
|---|---|---|
| I · инфраструктура | I-1…I-6 (параллельно, не на критическом пути кроме I-6+домен для го-лайва) | 3.5 |
| B · бекенд | B-0…B-12 (B-1, B-2, B-3 сделаны) | 25.5 |
| S · админ-SPA | S-1…S-10 | 20 |
| F · витрина | F-1…F-7 | 11 |
| A2 · аналитика | A2-1…A2-5 | 9.5 |
| Итого | один фулстек, последовательно | ~68 дней (~14 нед) |
| двое (B ∥ S+F после B-0) | ~8–9 нед | |
| минус SHOULD-куски (−3 дн) и W6 отложена до данных | первая статья на стенде ~ конец W3 |
15