План реализации · компаньон функциональной спецификации v3

Реализация SEO-редакции

Рабочий план: контракты данных и API, архитектурные решения, 42 задачи с пошаговыми разборами, файлами, зависимостями и критериями «готово», волны, миграции, тест-план, деплой. Домен не блокирует разработку — только го-лайв.

Версия: v3 · 27.08.2026 Состояние: W1 бекенда сделана Спека: SEO-редакция v3 Оценки: дни одного фулстека, ±40%

01

Как читать план

Часть плана уже выполнена — сверьтесь, прежде чем начинать. 27.08.2026 сделана волна W1 бекенда: B-1, B-2, B-3, I-1 (ветка feature/seo-w1-backend, 150 тестов). Состояние раздела, десять решений, которые не надо отменять, и грабли для продолжающего — docs/internal/14-seo-section.md в репозитории; его читать раньше этого плана. Фактический контракт админ-API — backend/docs/SEO_ADMIN_API.md: §4.2 ниже устарел в десяти местах, все изменения в пользу спеки. Добавлены АР-13 и задача B-12.

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).
АР-6LLM — свой тонкий клиент 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 не трогаем.
АР-10Sitemap, 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

Контракты данных и API

§4.2 ниже устарел. Контракт админ-API менялся десять раз по ходу реализации — появились 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 пишутся против них параллельно.

4.1 · Схема блоков (формат Editor.js, хранится в 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. Порядок массива = порядок на странице.

4.2 · Админ-API статьи

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 → черновик из снапшота

4.3 · Чек-лист в ответе (контракт SEO-панели)

"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 (валидируется тестом).

4.4 · SSE-протокол ИИ

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

4.5 · Счётчик (публичный)

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 — молча пропустить.

4.6 · Публичное blog-API (для SSR витрины)

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

Пакет I — инфраструктура параллельно разработке

IDЗадачаРеализуетКогда нужнаДн.
I-1сделано Воркер Dramatiq в compose (АР-11)PB-3, AN-1W11
I-2Ключи OpenRouter + заявка Вордстат APIAI-*, KW-1к W20.5
I-3Дев-контур блога на стенде (АР-12)§2к W30.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веха после W30.5
I-1Воркер Dramatiq1 дн
docker-compose.demo.yml · local/entrypoint-worker.sh
  1. Сервис worker: образ api, entrypoint python manage.py rundramatiq --processes 1 --threads 4, depends_on db/redis healthy.
  2. Проверить, что dramatiq-crontab поднимает расписание (lock в Redis db 0).
  3. Smoke-актор seo.ping (@cron каждую минуту, пишет строку в лог) — убедиться, что крон реально тикает на стенде.
  4. Задокументировать в backend/docs/ADMIN_STAGE_DEPLOY.md.
Готово: в логах воркера — минутные тики ping; существующие 21 @cron-задача проекта начали исполняться (проверить одну по логу).
I-3Дев-контур блога на стенде0.5 дн
docker-compose.demo.yml · demo.env
  1. Стендовому podushe добавить env: SITE_PRODUCT=events, BLOG_REVALIDATE_SECRET.
  2. Django-стенду: BLOG_ORIGIN_EVENTS=https://dusha.uxbrain.ru/podushe — конвейер публикации зовёт revalidate сюда.
  3. Проверить: /podushe/blog отвечает за basic auth, noindex на месте (стенд остаётся закрытым).
Готово: опубликованная со стендовой админки статья открывается по dusha.uxbrain.ru/podushe/blog/<slug> ≤ 60 с.
I-6Го-лайв0.5 дн
Caddyfile · docker-compose.demo.yml · кабинеты
  1. Контейнер podushe-public: тот же образ, env боевого домена, NUXT_PUBLIC_INDEXABLE=1.
  2. Site-блок Caddy: домен → контейнер; без X-Robots-Tag, без basic auth, свой robots.txt/sitemap изнутри приложения.
  3. Переключить BLOG_ORIGIN_EVENTS на боевой домен; IndexNow-ключ положить по /{key}.txt.
  4. Кабинеты (I-5): подтвердить права, добавить sitemap, включить мониторинг запросов.
  5. Чек-лист DoD этапа 0 из спеки §24.
Готово: curl -I https://домен/blog — 200, без noindex-заголовков; robots.txt отдаёт sitemap; Вебмастер видит сайт без ошибок.

06

Пакет B — бекенд

IDЗадачаРеализуетПослеДн.
B-0Зафиксировать контракты §4 (ревью вдвоём)——0.5
B-1сделано Модели + миграции + валидатор блоков§8 спекиB-02
B-2сделано CRUD статей + автосейвLS-1, ED-1B-12
B-3сделано Блокировка (Redis)ED-3B-21
B-4Движок баллаSC-1B-14
B-5Вордстат-клиент + кешKW-1B-1, I-22
B-6LLM-прокси + контекст + бюджетAI-1…AI-5B-1, I-24
B-7Конвейер публикации + расписаниеPB-1…PB-4B-13
B-8Публичное blog-API + лентаBL-1, FD-1/2B-72
B-9Счётчик + свёрткаTR-1B-11.5
B-10CRUD базы знаний / авторов / анкетKB-1B-11
B-11RBAC + аудит + тестовый доборAC-1B-2…B-101.5
B-12Загрузка файловED-2, MT-2, EC-6B-21
B-1Модели + миграции + валидатор блоков2 дн
seo/models.py · seo/blocks.py · nikea/settings.py (INSTALLED_APPS)
  1. App seo; 9 моделей по §8 спеки, поля и индексы — §11 плана. У Article: public_path property (/blog/<slug>), domain property (по product из env).
  2. seo/blocks.py: validate(blocks) → blocks | BlockError(block_id, detail) по схеме §4.1; sanitize_inline(html) — whitelist b/i/a[href http|https], всё прочее — strip тегов, текст сохраняется.
  3. Утилита slugify_ru(h1): транслитерация ГОСТ-подобная, стоп-слова, ≤ 6 слов.
  4. Фикстура-фабрика статей для тестов (tests_dating_tz/factories_seo.py).
Готово: миграции применяются и откатываются; валидатор ловит: неизвестный тип, level=4, image без alt, script в абзаце (тест-кейсы по одному на правило).
B-2CRUD статей + автосейв2 дн
adminpanel/views/seo.py · adminpanel/urls.py · seo/serializers.py
  1. Список по контракту §4.2: фильтры/сортировки как в спеке §9; пагинация — существующий adminpanel/pagination.py.
  2. PATCH-автосейв: частичное обновление; на входе blocks → валидатор B-1; в ответе — score+checks (вызов движка B-4; до его готовности — заглушка score:null).
  3. Смена slug: если опубликована — старый в slug_history (append, dedupe).
  4. Смена product: 403 если published_at есть (EC-12).
  5. Дублирование (копия-черновик с suffix slug), удаление только draft.
Готово: интеграционные тесты на каждый фильтр списка, автосейв каждого поля, оба запрета (slug-история, product).
B-3Блокировка1 дн
seo/locks.py
  1. acquire(article, staff) / heartbeat / release / takeover на Redis (АР-7); TTL 120 с.
  2. PATCH без валидного лока → 409 (контракт §4.2); GET статьи отдаёт текущего владельца.
  3. takeover пишет в аудит-лог; прежний владелец узнаёт по 409 на своём следующем автосейве (UI — S-3).
Готово: тесты: захват/чужой PATCH/протухание TTL/перехват+аудит.
B-4Движок балла4 дн
seo/scoring/engine.py · seo/scoring/checks/*.py · seo/scoring/textprep.py · seo/scoring/px.py
  1. textprep.prepare(article) → Ctx: плоский текст из блоков, предложения (razdel), леммы (pymorphy3 + LRU 100k словоформ), дерево заголовков, ссылки, картинки, счёт слов. Готовится один раз на вызов — все 20 проверок читают Ctx.
  2. 20 модулей-проверок, по одному файлу: def run(ctx, cfg) → CheckResult(passed, partial, message, hint). Пороги — из settings.SEO_SCORE_CFG (веса, лимиты, словарь канцелярита).
  3. px.py: ширина строки в px по таблице ширин глифов (латиница+кириллица, Arial 20px desktop / 14px mobile); экспорт этой же таблицы в JSON для клиентского индикатора (S-5).
  4. Golden-тесты: 8 эталонных статей (в фикстурах) с ожидаемыми баллами и состоянием каждой проверки; тест «сумма весов = 100»; перф-тест: статья 3000 слов ≤ 300 мс (после прогрева кеша лемм).
  5. Подключить в ответ автосейва (B-2).
Готово: golden-тесты зелёные; перф-бюджет в CI; ручная проверка: правка title в админке меняет балл за ≤ 1 с (критерий A-3).
B-5Вордстат2 дн
seo/wordstat.py · seo/tasks.py · модель KeywordCache
  1. Клиент API: topRequests(phrase, regions), dynamics, regions; OAuth-токен из env; таймаут 10 с, fail-soft.
  2. Кеш: KeywordCache(query, region, payload, fetched_at); отдача из кеша при возрасте ≤ 7 дн; при исчерпании квоты (HTTP 429/лимит) — кеш любой давности + флаг stale (EC-2).
  3. Интент-эвристика: словарь коммерческих маркеров («купить», «цена», «заказать»…) → метка.
  4. Актор: месячный рефреш частотностей прикреплённых запросов (батчем, с учётом квоты).
Готово: моковые тесты клиента + кеша + stale-режима; ручной прогон с живым токеном на 3 запросах.
B-6LLM-прокси4 дн
seo/llm.py · seo/ai_context.py · seo/ai_views.py · модель AiSpend
  1. Клиент OpenRouter: stream_chat(model, messages) → iterator, image(model, prompt) → urls; заголовок prompt-caching для статичной части.
  2. ai_context.build(article) → messages: [СИСТЕМНОЕ: SEO-гайд (константа ~800 слов, выжимка §11–12 спеки) + активные KnowledgeDoc по product + общие + часовой срез каталога (актор пишет в кеш: события по топ-10 городам, категории, 5 примеров)] + [СТАТЬЯ: h1, лид, план заголовков, метаданные, запросы, незакрытые пункты чек-листа].
  3. Бюджет-гейт: сумма AiSpend за месяц против SEO_AI_MONTHLY_BUDGET_RUB; пороги 80/100/120% (EC-16); цена — из ответа OpenRouter (usage → руб по курсу-константе).
  4. generate: два шага по протоколу §4.4 (план → посекционно); каждая секция — блоки через валидатор B-1; по завершении — needs_review=true если доля ИИ-текста ≥ 50% (счётчик знаков).
  5. chat: SSE delta + proposal-диффы (модель просят отвечать структурно: JSON-блок в конце ответа, парсим fail-soft); история — ArticleChat.
  6. edit: 5 действий над фрагментом, быстрая модель.
  7. image: 2–4 варианта → tmp-хранение → accept перекладывает в S3 обычной картинкой (через seo/images.py).
  8. Деградация EC-1: любой сбой апстрима → SSE error, редактор живёт.
Готово: мок-тесты: бюджет-гейт по порогам, needs_review, невалидные блоки от модели отбрасываются с ошибкой секции; живой прогон: одна статья генерится на стенде от брифа до черновика.
B-7Конвейер публикации3 дн
seo/publish.py · seo/tasks.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ч
  1. unpublish → 410 (blog-API), выпадение из sitemap (revalidate), статус.
  2. restore: снапшот → черновик (без автопубликации).
  3. Актор publish_scheduled (@cron каждую минуту): созревшие scheduled → publish от имени «system», ошибки — в аудит + метка на статье.
  4. 301: смена slug уже пишет историю (B-2); отдаёт её blog-API (B-8).
Готово: тесты каждого шага + атомарность (мок revalidate падает → статус откатился? нет: revalidate после коммита, статус остаётся — статья доступна по TTL ≤ 5 мин, это осознанно, тест фиксирует); критерии A-1, A-6, A-7 — вручную на стенде (I-3).
B-8Публичное blog-API + лента2 дн
seo/public_views.py · nikea/urls.py · nginx-gateway-demo.conf (открыть путь)
  1. Ручки по §4.6, AllowAny, только published; product — обязательный параметр (витрина шлёт свой).
  2. Статья: 301 по slug_history (индекс-GIN), 410 для unpublished, related — 3 по пересечению tags.
  3. Лента событий: фильтр каталога (city, categories, ближайшие sessions в окне days, free_only) либо manual ids; сортировки; отдаёт карточки {id, title, cover, next_session, place, price}; пустой список — пустой массив (витрина скрывает блок).
  4. Лента анкет: ShowcaseProfile is_active, поля по спеке §15 — за флагом (COULD, включается позже).
  5. Кеш ответов ленты 60 с (Redis) — SSR не долбит каталог.
Готово: тесты 200/301/410/404, фильтры ленты, «событие ушло в архив — исчезло из ответа» (EC-4).
B-9Счётчик1.5 дн
seo/track_views.py · seo/tasks.py
  1. Endpoint §4.5: AllowAny, DRF-throttle 60/мин/IP, лимит батча 20, UA-ботофильтр (список подстрок), валидация типов — неизвестные молча пропускаются.
  2. Запись BlogHit bulk_create; никаких FK-обращений в горячем пути (article_id — UUID без проверки существования, чистка мусора при свёртке).
  3. Актор свёртки (@cron 30 4): BlogHit за вчера → ArticleMetricDaily(source=own): views, uniq_sessions, by_src, scroll-пороги, reads, time_median, cta, feed. Идемпотентно (upsert по unique).
  4. Актор-ретеншн: BlogHit старше 180 дн — удалить.
Готово: тесты троттлинга/ботофильтра/свёртки; нагрузочный смоук: 1000 батчей — без деградации API (ручной locust-скрипт).
B-10База знаний / авторы / анкеты1 дн
adminpanel/views/seo.py
  1. CRUD трёх справочников; авторы — деактивация вместо удаления (EC-13); анкеты — только роль Admin (проверка action).
  2. Лимит суммарного объёма активных KnowledgeDoc (символы, порог в конфиге) — 400 с перечнем «кого ужать».
Готово: тесты CRUD + запрет удаления автора + Admin-гейт анкет + лимит.
B-11RBAC + аудит + добор1.5 дн
adminpanel/rbac.py · tests_dating_tz/test_seo_rbac.py
  1. PRODUCTS += seo; SECTION_PRODUCT: seo_articles/seo_analytics/seo_kb/seo_authors → seo; ROLES: Admin, Content ← секции + action seo.write.
  2. ENDPOINT_RULES — запись на каждую ручку пакета B (иначе fail-closed 403); публичные blog/track — вне admin-api, их не касается.
  3. Аудит: спец-события (takeover, review-done, смена slug, restore) через существующий middleware + ручные записи.
  4. Прогнать инвариант-тесты (test_every_section_belongs_to_a_product и др.) — обязаны быть зелёными.
Готово: роль Support-L1 не видит раздел и получает 403 на все ручки (тест); Content публикует; аудит-лог содержит все спец-события (тест).
B-12Загрузка файлов1 дн
adminpanel/views/seo.py · seo/images.py
Задачи не было в плане v2, и без неё редактор наполовину нерабочий. Менеджеру неоткуда взять ни обложку статьи, ни картинку в текст: автосейв принимает готовый ключ S3, а положить туда файл нечем. Отсутствие обложки — блокер публикации (§16), значит без этой задачи не публикуется ни одна статья.
  1. POST /admin-api/seo/upload (multipart) → {file_id, url, w, h}. Образец в проекте есть: adminpanel/views/catalog.py::AdminEventCoverView.
  2. Проверки на входе: тип (jpeg/png/webp), размер ≤ 10 МБ (EC-6), реальные размеры картинки. Текст отказа — из §22 спеки дословно.
  3. Сжатие в WebP и ресайзы (§10). Pillow в проекте уже стоит.
  4. Ответ кладётся в cover_id статьи или в file_id блока «картинка» — автосейв их уже принимает, контракт менять не нужно.
  5. Записать cover_width/cover_height. Колонки есть, писать в них некому — из-за этого проверка балла №4 «обложка ≥ 1200×630» сейчас недостижима, и потолок балла 96, а не 100.
  6. Зарегистрировать ручку в ENDPOINT_RULES (раздел seo_articles, действие seo.write) — иначе fail-closed 403.
Готово: менеджер ставит обложку из редактора; файл больше 10 МБ отклоняется с текстом §22; размеры записаны и проверка балла №4 проходит; загруженная картинка открывается на витрине.

07

Пакет S — админ-SPA

IDЗадачаРеализуетПослеДн.
S-1Каркас раздела§7 спекиB-111
S-2Список статейLS-1S-1, B-21.5
S-3Каркас редактора + автосейв + локED-1, ED-3S-1, B-2/32.5
S-4Editor.js + кастомные блокиED-2S-34
S-5SEO-панельMT-1, SC-1S-3, B-42.5
S-6Подбор запросов UIKW-1S-3, B-51
S-7ИИ-чат + правка выделенногоAI-2/3S-3, B-62.5
S-8Генерация целиком + картинкиAI-1/4/5S-72
S-9Публикация/версии/расписание UIPB-1…4S-3, B-71.5
S-10Вкладки БЗ/авторы/анкетыKB-1S-1, B-101.5
S-1Каркас раздела1 дн
assets/core.js · assets/admin.js · assets/sections/seo.js
  1. PRODUCTS += {id:'seo', label:'SEO', short:'SEO', icon:'✎', hint:'Статьи и поисковый трафик'}.
  2. SECTIONS: 4 записи (product:'seo', group:'Редакция'/'Данные', slug: articles/analytics/kb/authors, count: () ⇒ counts.seo_articles).
  3. registerLoaders/registerViews в admin.js; секции — заглушки с emptyState.
  4. Помнить CSP: никаких inline-обработчиков — только data-act делегирование (core.js::bindDelegation).
Готово: рельс показывает SEO для Admin/Content и прячет для остальных; deep-link #seo/articles работает; консоль без warnOnProductDrift.
S-3Каркас редактора2.5 дн
assets/sections/seo-editor.js (новый route-режим: редактор занимает #main целиком)
  1. Layout: CSS-grid 3 колонки (полотно 1fr / панель 320px / чат 300px сворачиваемый), моб. — вкладки.
  2. Автосейв: debounce 3 с после изменения + принудительный каждые 5 с при активности; очередь из одного запроса (следующий ждёт); индикатор «Сохранено/Сохранение…/Нет связи».
  3. Локальный буфер EC-8: несохранённый payload — в localStorage['seo_draft_'+id]; при открытии — если буфер новее server updated_at, диалог «восстановить локальные правки?».
  4. Лок: acquire при входе, heartbeat 30 с, release на уход (pagehide + go()); 409 → плашка read-only + «Всё равно редактировать» (takeover); после takeover у прежнего — модал с копией несохранённого (EC-9).
  5. Шапка: статус, предпросмотр (ссылка на blog-API-предпросмотр с токеном), кнопка публикации (S-9), меню «⋯».
Готово: сценарий S5 спеки (двое в статье) проходит вручную вдвоём; обрыв сети (devtools offline) не теряет ни символа.
S-4Editor.js + кастомные блоки4 дн · риск РР-1
assets/vendor/editorjs/ · assets/sections/seo-blocks.js
  1. Вендорить: editorjs core + header, list, quote, image, delimiter (официальные тулзы); проверить работу под CSP self.
  2. День 1–2 — прототип двух самых рискованных блоков: feed (форма настроек §15 спеки + предпросмотр карточек через blog-API) и cta. Гейт: если API Editor.js упирается — эскалация к запасному плану АР-2 (решение до конца W2).
  3. Остальные кастомные: event_card (поиск по каталогу через существующий admin-API событий), faq (пары полей), aside (select вида + текст).
  4. Image-tool: аплоад через существующий S3-механизм админки (multipart, без ручного Content-Type — практика №5 админки); поле alt обязательное (блок с пустым alt подсвечен).
  5. Конвертация: Editor.js OutputData ↔ схема §4.1 (тонкий маппер, версия схемы).
  6. Paste-очистка из Word/Docs: конфиг sanitizer Editor.js + тест руками на реальном документе.
Готово: статья со всеми 11 типами блоков создаётся, сохраняется, перезагружается без потерь (round-trip тест в смоуке); вставка из Google Docs даёт чистые блоки.
S-5SEO-панель2.5 дн
assets/sections/seo-panel.js
  1. Балл-кольцо (conic-gradient) + зоны цветом; чек-лист из ответа автосейва (§4.3): группы, раскрытие hint, клик → скролл к anchor.
  2. Title/description: инпуты с живым пиксельным индикатором (таблица глифов из B-4, экспортированная в JSON) + предпросмотр сниппета (desktop/mobile табы) + кнопки «Предложить» (быстрые ИИ-ручки B-6, варианты списком).
  3. Slug: авто из H1 (пока черновик), ручное редактирование, предупреждение после публикации.
  4. Обложка: аплоад + предпросмотр 1200×630 + alt; автор — select активных; рубрики — токены.
  5. Запросы: чипы primary/secondary (полный UI подбора — S-6).
Готово: критерий A-3 (правка title → балл за ≤ 1 с); пиксельный индикатор совпадает с серверным расчётом на 20 тест-строках.
S-7ИИ-чат + правка выделенного2.5 дн
assets/sections/seo-ai.js
  1. SSE-клиент (fetch + ReadableStream — EventSource не умеет POST); рендер delta-текста, спиннер, стоп-кнопка (abort).
  2. proposal-события → карточка диффа (простой построчный дифф: свой мини-дифф ~50 строк, без библиотек) с «Применить/Отклонить»; применить = PATCH соответствующего поля.
  3. Быстрые кнопки; история чата грузится из ArticleChat.
  4. Правка выделенного: selectionchange в полотне → floating-панель 5 действий → /ai/edit → дифф.
  5. Индикатор бюджета (из ответов usage); состояния EC-1/EC-16 — плашки.
Готово: чат стримит, применённый дифф попадает в блоки и балл пересчитывается; обрыв стрима — корректная ошибка без зависшего спиннера.
S-8Генерация целиком + картинки2 дн
assets/sections/seo-ai.js
  1. Мастер: бриф (тема/пожелания/запрос из Вордстата) → план (редактируемый список секций, перегенерация) → прогресс по секциям (стоп сохраняет готовое) → переход в редактор с плашкой «нужна вычитка».
  2. Кнопка «Вычитано» (review-done) — снимает плашку, разблокирует публикацию.
  3. Картинки: в image-блоке и обложке кнопка «Сгенерировать» → варианты сеткой → accept.
Готово: сценарий S1 спеки проходит от брифа до публикации; A-2 зелёный (публикация без «Вычитано» невозможна).
S-2 · S-6 · S-9 · S-10Список · Запросы · Публикация · Справочники5.5 дн суммарно
assets/sections/seo.js · seo-keywords.js · seo-editor.js · seo-kb.js
  1. S-2: таблица по спеке §9 (все столбцы/фильтры/действия/состояния); паттерны админки: panel + table-wrap + pagerBar + drawer не нужен (редактор — отдельный экран).
  2. S-6: модал подбора: строка поиска + регион; таблица запросов (частотность, динамика-спарклайн, интент-метка); чекбоксы прикрепления; stale-плашка (EC-2).
  3. S-9: публикация: 422 → список блокеров; 409 → confirm с предупреждениями (EC-10); расписание (datetime-local, EC-14); версии: список, дифф-просмотр (рендер двух снапшотов рядом), restore; смена slug — предупреждение EC-11.
  4. S-10: три вкладки по паттерну «panel.create + drawer» (эталон settings.js): БЗ (markdown textarea + предпросмотр объёма), авторы (фото-аплоад, деактивация), анкеты (Admin-only, поле согласия обязательное).
Готово: смоук Playwright проходит все четыре экрана (§12).

08

Пакет F — витрина

IDЗадачаРеализуетПослеДн.
F-1Страницы блога + рендер блоковBL-1B-83.5
F-2Мета-обвязка (useBlogSeo)MT-2F-11
F-3Лента + карточка событияFD-1F-11.5
F-4Sitemap/RSS/robots + revalidatePB-1F-11
F-5Счётчик-скрипт + МетрикаTR-1F-1, B-91.5
F-6CWV-паспортBL-2, NF-1F-1…F-51.5
F-7Runtime-мультидомен§6 спекиF-11
F-1Страницы блога + рендер блоков3.5 дн
web-events/app/pages/blog/{index,[slug],t/[tag],authors/[slug]}.vue · app/components/blog/*
  1. Компонент на тип блока: BlockParagraph (v-html после серверной санитизации — единственное место), BlockHeader (якорь), BlockImage (figure, width-варианты), BlockList, BlockQuote, BlockAside, BlockFaq, BlockCta, BlockEventCard, BlockFeed (F-3), BlockDelimiter; диспетчер ArticleBody.vue.
  2. Содержание из H2 (≥ 3), «читайте также» из related, блок автора, даты, время чтения (слова/180).
  3. SSR-фетчи через существующий app/shared/api.ts (docker-сеть http://api:8000); 301 от API → navigateTo(redirect, 301); 410 → страница «статья удалена» со статусом 410; 404.
  4. routeRules: '/blog/**': { swr: 300 }.
  5. Рубрики/авторы/пагинация по спеке §17.
Готово: снапшот-тест рендера статьи со всеми типами блоков; curl без JS отдаёт полный контент (SSR), 301/410 — правильными статусами.
F-2Мета-обвязка1 дн
app/composables/useBlogSeo.ts
  1. useHead: title (fallback H1+бренд), description, canonical = SITE_ORIGIN + path (UTM срезаны), OG (og:image = абсолютный URL обложки 1200×630), twitter:card.
  2. JSON-LD Article (headline, image[3 кропа], author{name,url}, datePublished/Modified, publisher) + BreadcrumbList — useHead script type=application/ld+json.
  3. noindex: если NUXT_PUBLIC_INDEXABLE=0 (стенд) или предпросмотр.
Готово: HTML страницы проходит Google Rich Results Test (вставкой кода) и валидатор Вебмастера без ошибок Article/Breadcrumb.
F-3 · F-4 · F-5Лента · Sitemap/RSS · Счётчик4 дн суммарно
components/blog/FeedBlock.vue · server/routes/{sitemap.xml,blog/rss.xml,robots.txt,api/_revalidate}.ts · app/plugins/blog-track.client.ts
  1. F-3: FeedBlock: SSR-фетч /blog/feed/…; onMounted — тихий рефетч; пустой ответ — блок не рендерится (SSR тоже); клик карточки — трек + переход; event_card — тот же механизм на один id.
  2. F-4: sitemap (index: articles + tags + authors, lastmod), RSS 20 статей, robots (allow + sitemap-ссылка); все — defineCachedEventHandler 600 с; _revalidate: проверка x-secret, useStorage('cache').removeItem(ключи путей) — точный формат ключа nitro зафиксировать тестом (РР-2).
  3. F-5: плагин только для /blog/**: session uuid (sessionStorage), пассивные слушатели scroll (пороги один раз), visibility-таймер активных секунд, read-условие, буфер → flush каждые 15 с и sendBeacon на pagehide (§4.5); классификатор referer; Метрика: подключение тега по env-id + ym('reachGoal') для read/cta/feed.
Готово: A-5 (лента живая, клики считаются) и A-8 (полная цепочка счётчика ≤ 1 мин до админки) — на стенде; sitemap валиден в валидаторе.
F-6 · F-7CWV-паспорт · Runtime-мультидомен2.5 дн суммарно
nuxt.config.ts · seo/images.py · docker-compose.demo.yml
  1. F-6: ресайзы обложек при загрузке (seo/images.py: 400/800/1600 WebP + оригинал), srcset/sizes, fetchpriority=high на обложке, lazy ниже фолда, width/height всюду, шрифты витрины — preload; Lighthouse CI (локально) на статью: все CWV «хорошо», бюджет в тест-скрипте.
  2. F-7: вынести из build-time в runtimeConfig: SITE_PRODUCT, SITE_ORIGIN, METRIKA_ID, INDEXABLE; тема бренда по product (CSS-переменные); проверить два контейнера с разными env из одного образа.
Готово: Lighthouse: perf ≥ 90, CWV зелёные; один образ поднимается как events- и dating-витрина сменой env.

09

Пакет A2 — аналитика (этап 2)

IDЗадачаРеализуетПослеДн.
A2-1Сборщик Метрики + автоцелиAN-1этап 1, I-51.5
A2-2Сборщик ВебмастераAN-1этап 1, I-51.5
A2-3Сборщик GSCAN-1этап 1, I-51.5
A2-4Admin analytics APIAN-1A2-1…32
A2-5Вкладка «Аналитика»AN-1A2-43
A2-1…A2-3Сборщики: конкретные вызовы4.5 дн
seo/collectors/{metrika,webmaster,gsc}.py · seo/tasks.py
  1. Метрика: 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) — идемпотентно при го-лайве.
  2. Вебмастер: host-id из GET /v4/user/{uid}/hosts; метрики запросов из API мониторинга запросов (indicators: SHOWS, CLICKS, CTR, POSITION; фильтр URL /blog/); квота переобхода — не трогаем.
  3. GSC: searchanalytics.query {dimensions:[query,page], фильтр page contains /blog/, rowLimit 25000, даты «позавчера» из-за задержки ~2 дня}; сервис-аккаунт из GSC_CREDENTIALS_JSON.
  4. Все трое: ночные акторы, upsert в ArticleMetricDaily по unique(article, date, source) — идемпотентны, повторный прогон безопасен; fail-soft (EC-15) + ручной перезапуск ручкой в админке.
  5. Маппинг URL → article: по slug из пути, с учётом slug_history.
Готово: мок-тесты маппинга и upsert; после го-лайва — 3 подряд успешные ночи (DoD этапа 2).
A2-4 · A2-5API + вкладка5 дн
adminpanel/views/seo_analytics.py · assets/sections/seo-analytics.js
  1. API: summary (тайлы за период + топ-5 статей по конверсии + топ-10 запросов), список статей с метриками (агрегация Daily за период, сортировки), карточка (все ряды §19 спеки: воронка, серия по дням + даты публикаций из версий, запросы Я/G, источники, поведение, cta/feed по блокам).
  2. UI: тайлы, таблица со спарклайнами (inline-SVG, без библиотек), карточка с виджетами; подсказка «позиция 5–15»; ссылки в Метрику/Вебвизор с параметром фильтра URL.
  3. Определения метрик — строго словарь §19 спеки (никаких своих формул в UI).
Готово: A-8 полный; вкладка открывается ≤ 1 с из локальных данных (NF-2-подобный бюджет).

10

Волны и го-лайв

Пакет I идёт параллельно всем волнам; жёсткая зависимость от домена — только у вехи го-лайва. При двух разработчиках B ∥ S/F (контракты §4 фиксируются в B-0).

W1

Скелет бекенд сделан

B-0 → B-1 → B-2, B-3 ∥ I-1 — сделано 27.08.2026; осталось S-1 → S-2, S-3. Выход: статья создаётся, набирается абзацами, автосохраняется, лок работает.

W2

Редактор целиком

S-4 (гейт по РР-1 в первые 2 дня) ∥ B-4 → S-5 ∥ B-5 → S-6. Выход: полный редактор с баллом и подбором запросов.

W3

Публикация и витрина (на стенде)

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 дня) в любой момент после этой волны.

W4

ИИ

B-6 → S-7 → S-8. Выход: сценарий S1 спеки целиком; A-2 зелёный.

W5

Добор этапа 1

B-9 + F-5 → F-6, F-7 ∥ B-10 + S-10 → B-11. Выход: DoD этапа 1 полностью.

W6

Аналитика (после го-лайва)

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_articleunique(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_articleversionunique(article, number); снапшот JSONB
seo_author / seo_knowledgedoc / seo_showcaseprofileunique slug у автора; is_active-индексы; у анкеты — consent_date, consent_basis NOT NULL
seo_articlechatFK article; messages JSONB
seo_bloghitиндекс (article_id, occurred_at); без FK (горячий путь); ретеншн 180 дн актором
seo_articlemetricdailyunique(article, date, source); metrics JSONB
seo_aispendиндекс (month, article); суммы месяца для бюджет-гейта
seo_keywordcacheunique(query, region); fetched_at

12

Тест-план

13

Деплой и дев-контур

Дев-контур (без домена, АР-12)

Изменения compose / Caddy

Env (полный список)

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

Порядок выката и откат

  1. Миграции (обратимы, никого чужого не трогают) → api+worker (раздел скрыт RBAC — риск нулевой) → админ-SPA (volume: копирование файлов, откат = git checkout) → витрина стенда (I-3) → …разработка/приёмка… → го-лайв (I-6; откат = убрать site-блок Caddy).
  2. После каждого шага — смоук соответствующего пакета; после го-лайва — чек-лист DoD этапа 0+1.

14

Сводная оценка

ПакетСоставДней
I · инфраструктураI-1…I-6 (параллельно, не на критическом пути кроме I-6+домен для го-лайва)3.5
B · бекендB-0…B-12 (B-1, B-2, B-3 сделаны)25.5
S · админ-SPAS-1…S-1020
F · витринаF-1…F-711
A2 · аналитикаA2-1…A2-59.5
Итогоодин фулстек, последовательно~68 дней (~14 нед)
двое (B ∥ S+F после B-0)~8–9 нед
минус SHOULD-куски (−3 дн) и W6 отложена до данныхпервая статья на стенде ~ конец W3

15

Риски реализации

РР-1 · Кастомные блоки Editor.js (S-4)Самая неизведанная часть. Митигация: прототип feed+cta в первые 2 дня W2; гейт: если API упирается — запасной план АР-2 (свой минимальный набор блоков) принимается не позже конца W2.
РР-2 · Сброс nitro-кеша (F-4)Формат ключей кеша зависит от версии Nitro. Митигация: формат зафиксировать тестом; если сброс нестабилен — swr для /blog/** уменьшить до 60 с: A-1 (≤ 60 с) выполняется и так, ценой чуть большей нагрузки.
РР-3 · Внешние API (B-5, A2-*)Квоты, OAuth, задержки. Митигация: все клиенты fail-soft с кешем (EC-2/EC-15), сборщики идемпотентны (unique-upsert), ручной перезапуск из админки.
РР-4 · Производительность балла (B-4)20 проверок + лемматизация 3000 слов ≤ 300 мс. Митигация: Ctx готовится один раз, LRU-кеш словоформ, перф-тест с бюджетом в CI; запасной ход — леммы инкрементально по изменённым блокам.
РР-5 · Editor.js под CSPВендорные тулзы могут тянуть inline-стили/воркеры. Митигация: проверка под боевым CSP в первый день S-4; в крайнем случае — точечное послабление style-src для /ops (не script-src).