diff --git a/PROJECT.md b/PROJECT.md
new file mode 100644
index 0000000..3eab660
--- /dev/null
+++ b/PROJECT.md
@@ -0,0 +1,1714 @@
+# N8 Parser: проектная документация
+
+Этот документ описывает текущую архитектуру проекта `n8_parser`: зачем он нужен, как устроены БД, воркеры, админка, AI-контур, медиа-хранилище, деплой и типовые операции. Документ написан как рабочая карта проекта, чтобы к нему можно было вернуться через месяц и быстро понять, где что лежит и почему сделано именно так.
+
+## 1. Цель проекта
+
+Проект автоматизирует сбор и редакционную обработку постов из VK-сообществ производителей, магазинов и площадок по тактическому, милитари и airsoft-снаряжению.
+
+Основная цепочка:
+
+```text
+Источники VK
+ -> VK-парсер
+ -> raw_posts / raw_post_media в PostgreSQL
+ -> Telegram media storage
+ -> AI-квалификатор
+ -> AI-райтер
+ -> редакторская
+ -> принятие / отклонение / ручное редактирование
+```
+
+Проект не должен превращаться в набор разрозненных скриптов. Главная идея текущей реализации: все долгие операции живут в отдельных воркерах, все настройки управляются из админки, а секреты остаются в `.env`.
+
+## 2. Текущий стек
+
+- Backend: Python 3.12+, FastAPI.
+- Шаблоны: Jinja2.
+- БД: PostgreSQL.
+- Асинхронный доступ к БД: `asyncpg`.
+- VK API: собственный клиент в `src/vk_parser_app/vk_api.py`.
+- Telegram: `aiogram`.
+- AI: `litellm`, сейчас используется Anthropic через LiteLLM.
+- Логи: `loguru`.
+- Системный запуск на сервере: `systemd`.
+- Reverse proxy / TLS: nginx + сертификат для `https://sw.exostring.xyz`.
+- Репозиторий: GitHub `exostring/n8_parser`.
+
+## 3. Структура проекта
+
+```text
+.
+├── db/
+│ └── migrations/ # SQL-миграции
+├── scripts/
+│ ├── apply_migrations.py # применение миграций
+│ └── vk_storage_spike.py # старый тест VK storage
+├── src/
+│ └── vk_parser_app/
+│ ├── admin.py # FastAPI админка и маршруты
+│ ├── config.py # env-настройки
+│ ├── constants.py # статусы, имена воркеров, типы jobs
+│ ├── db.py # pool, settings, миграции
+│ ├── heartbeat.py # heartbeat воркеров
+│ ├── jobs.py # очередь jobs
+│ ├── main.py # ASGI entrypoint
+│ ├── security.py # пароли, сессии, CSRF
+│ ├── text_utils.py # тэги, категории
+│ ├── vk_api.py # VK API client и извлечение media
+│ ├── templates/ # HTML-шаблоны
+│ └── workers/
+│ ├── parser.py # VK-парсер
+│ ├── vk_storage_uploader.py# Telegram media storage uploader
+│ ├── ai_qualifier.py # AI-квалификатор
+│ └── ai_writer.py # AI-райтер
+├── original_project/ # старый проект как справочник
+├── requirements.txt
+├── README.md # короткий early quick start, частично устарел
+└── PROJECT.md # этот документ
+```
+
+## 4. Важное архитектурное решение
+
+### 4.0. Важные изменения фиксируются в документации
+
+Если меняется поведение системы, эксплуатационная схема, публикационный формат, БД, деплой или админка, это нужно отражать в `PROJECT.md` или другом релевантном `.md` рядом с кодом.
+
+Текущие зафиксированные изменения:
+
+- Публичный сайт вынесен в самостоятельное приложение `forma_n8_site/` и разворачивается на отдельном VPS. Сайт хранит собственные статьи и медиа, отдаёт серверный HTML, RSS, sitemap, robots.txt, canonical и JSON-LD `NewsArticle`.
+- Интеграция с сайтом строится через idempotent `POST /api/v1/articles`: `external_id` уникален, а slug и дата первой публикации сохраняются при обновлениях. Дизайн можно менять без изменения URL и повторной загрузки постов. Медиа передаются через защищённый `POST /api/v1/media` и хранятся на стороне сайта.
+- Публичные названия категорий сайта отделены от описательных AI-категорий парсера: таблица `categories` связывает стабильный латинский tag с коротким SEO-названием. API нормализует входной tag и использует справочник для H1, breadcrumbs и category URL.
+- Пока у публичного сайта нет домена, `SITE_INDEXING_ENABLED=false`: HTML получает `noindex`, а robots.txt закрывает IP от обхода. Индексация включается только после настройки домена, HTTPS, корректного `SITE_BASE_URL` и редиректа с IP.
+
+- `favi.png` лежит в корне проекта и отдаётся FastAPI по `/favi.png` и `/favicon.ico`.
+- Финальный текст публикации собирается через общий helper: к тексту добавляется строка `#category #source`.
+- Категорийный хэштег берётся из `content_categories.tag`, а не из человекочитаемого названия категории.
+- В админке добавлена вкладка `/logs` для просмотра `audit_log`; записи старше 30 дней удаляются автоматически.
+- Добавлен TG-постер: расписание по Екб, отдельный токен/чат, ранжирование, Telegram-лимиты и статусы `published` / `publish_failed`.
+- Добавлен TG-реактор: отдельный воркер ставит реакции на опубликованные Telegram-посты из `post_publications`; один bot token ставит одну реакцию, несколько bot tokens могут поставить несколько реакций.
+- Добавлен ежедневный Telegram-отчет `daily-report`: в `00:04` по Екатеринбургу отправляет получателям из `daily_report_recipient_ids` операционный отчет за прошедший день: raw-сбор, AI-принято/отклонено, редакторская очередь, TG/VK публикации, ошибки и состояние воркеров. По умолчанию получатель `442509142`, bot token берется из `daily_report_bot_token`, затем `tg_poster_bot_token`, затем `TG_BOT_TOKEN`.
+- Текущая схема TG-реактора: основной бот публикации + два отдельных reaction-бота; реакции распределены по порядку токенов как `👍`, `🔥`, `❤`.
+- Для догонки реакций TG-реактор использует паузу `tg_reactor_reaction_pause_sec` между успешными реакциями, чтобы не отправлять пачку запросов в Telegram одним всплеском.
+- Разделительная полоса в AI-рерайтах должна быть строго `━━━━━━━━━━━━━━━━━` (17 символов).
+- TG-постер перечитывает настройки перед циклом и не догоняет прошедшие часы; слот срабатывает только в 10-минутном окне после своего времени.
+- TG-постер публикует медиа только через Telegram `file_id`/storage attachment или локальный upload; сырые `http(s)` media URL не отправляются как Telegram media, чтобы не ловить `WEBPAGE_MEDIA_EMPTY` / `wrong type of the web page content`.
+- TG-постер повторяет отправку только при `TelegramRetryAfter` flood-control. Обычные timeout/network ошибки после `send_message`/`send_media` не ретраятся, потому что Telegram мог уже принять сообщение, а повтор создаёт дубли.
+- На сервере собран и запущен локальный Telegram Bot API (`telegram-bot-api.service`) на `127.0.0.1:8081`. Uploader использует его через `local_bot_api_url`, чтобы загружать видео больше облачного лимита Bot API.
+- Telegram storage uploader скачивает VK-видео через `yt-dlp` и только после успешной загрузки всех pending фото/видео переводит raw-пост в `storage_ready`. Если видео временно не скачалось, job ретраится; если media окончательно failed, raw-пост получает `failed`, чтобы публикация не ушла с одной обложкой вместо ролика. Текущие лимиты после включения local Bot API: `video_max_size_mb=512`, `video_max_height=1080`, `video_max_duration_sec=1200`, `uploader_yt_dlp_timeout_sec=1800`.
+- Вся цепочка после storage теперь требует минимум одно видимое загруженное `photo`/`video`: uploader не переводит пост без media в `storage_ready`, AI-квалификатор и AI-райтер не забирают посты без загруженного media, TG-постер не публикует старые готовые записи без загруженного media. Если видео ушло в `failed` или `link_only`, пост считается `failed` и не попадает даже на квалификацию.
+- При финальной проверке media uploader не считает `pending` те файлы, которые уже успешно скачаны и подготовлены в текущем проходе: их Telegram `file_id` появляется только после отправки. Иначе uploader зацикливается между подготовкой файла и повтором job, не отправляя медиа в storage.
+- TG-постер дополнительно не берёт кандидата в публикацию, если у видимого `photo`/`video` нет `tg_file_id`/`storage_attachment_id`; это защищает от старых `storage_ready` записей, которые успели стать готовыми до ужесточения uploader.
+- Для разовой дозагрузки старых timeout-видео добавлен `scripts/backfill_pending_videos.py`: он последовательно скачивает pending VK-видео, отправляет в Telegram storage через local Bot API и проставляет `tg_file_id`.
+- На странице Raw можно выделить несколько постов и вручную отправить их в AI-рерайт; обработчик переводит только `storage_ready` посты в `qualification_status='accepted'` и `rewrite_status='pending'`.
+- Категория AI-райтера `18` / `Не целевой контент` / `НЦК` считается автоматическим AI-отклонением: такие посты получают `qualification_status='rejected'`, `editorial_status='rejected'` и не попадают в редакторскую `На проверке`.
+- Добавлена общая таблица `post_publications`: в редакторской у каждой новости видны платформенные статусы TG/VK/MAX/Сайт. TG-постер зеркалит результат в эту таблицу, VK-постер пишет туда напрямую.
+- Запрос редакторской агрегирует строки `post_publications` для каждой новости; статусы TG/VK берутся из фактических платформенных публикаций, поэтому опубликованный пост не отображается как `ожидает`.
+- Добавлен VK-постер `src/vk_parser_app/workers/vk_poster.py`: публикует принятые/уже опубликованные в TG посты в целевое VK-сообщество по отдельному расписанию Екб. На текущем этапе VK-постер не загружает медиа через `photos.*`, а берёт только исходные VK attachments (`photo...` / `video...`) из `raw_post_media.original_attachment_id`; посты без такого VK attachment не попадают в кандидаты VK-публикации.
+- Добавлен MAX-постер `src/vk_parser_app/workers/max_poster.py`: публикует принятые/уже опубликованные материалы в MAX-канал через Bot API от имени бота-администратора. Расписание по умолчанию повторяет TG hourly schedule. Текст режется по лимиту MAX `4000` символов, медиа загружается в MAX отдельно через `/uploads`, а результат пишется в `post_publications(platform='max')`.
+- MAX-постер для видео использует не `original_url` VK-страницы, а готовый файл из Telegram storage по `tg_file_id` / `storage_attachment_id`. Если локальный Telegram Bot API возвращает абсолютный `file_path` в `/var/lib/telegram-bot-api/...`, воркер читает файл напрямую с диска. После upload видео воркер poll-ит `GET /videos/{videoToken}` и отправляет пост только когда MAX вернул `urls`.
+- В MAX Bot API на момент внедрения нет публичного метода для реакции на пост: попытка `/messages/{message_id}/reactions` возвращает `404 method.not.found`. Auto-reaction оставлен как non-fatal hook и не должен помечать успешную публикацию ошибкой.
+- В исходных VK-постах могут встречаться отдельные пустые/белые фото рядом с видео. Пример: `raw_post_id=398`, `photo-190867868_457284718` полностью белая (`1433x2000`, RGB `255/255/255`). MAX-постер пока прикрепляет такие фото как обычные исходные медиа; следующий безопасный шаг - добавить фильтр почти полностью белых изображений перед upload.
+- Ранжирование TG/VK-постеров не использует `qualification_score`: квалификатор только допускает пост в дальнейшую цепочку. Постер сначала выбирает вариант без совпадения категории/источника с предыдущей публикацией, затем минимизирует повторы категории и источника в окне последних 20 публикаций, а при равенстве берёт самый старый ожидающий пост.
+- Для VK-постера user token нужно получать через VK ID Authorization Code Flow с PKCE: `/vk-oauth/start` -> `id.vk.ru/authorize` -> `/vk-oauth/callback`. Callback обменивает `code` через `id.vk.ru/oauth2/auth` на `access_token` + `refresh_token`, сохраняет `vk_poster_access_token`, `vk_poster_refresh_token`, `vk_poster_token_device_id`, `vk_poster_token_expires_at` и проверяет `wall.get` / `photos.getWallUploadServer`. Воркер обновляет access token через refresh token до истечения. Refresh token живёт 180 дней по документации VK ID; Redirect URI в VK ID-приложении: `https://sw.exostring.xyz/vk-oauth/callback`.
+- По документации VK API метод `photos.getWallUploadServer` требует пользовательское право `photos`, которое VK выдаёт в исключительных случаях через запрос в поддержку `devsupport@corp.vk.com`. Если token без `photos`, VK-постер пропускает upload редакторских/URL-фото, но всё равно публикует текст и существующие VK `photo...`/`video...` attachments.
+- VK-постер исключает из очереди video-only записи с `raw_post_media.status='link_only'`: VK API не разрешает прикреплять часть чужих видео без предварительной загрузки в целевое сообщество. Медиа `link_only` также не добавляются к смешанным постам, а публикации со статусом `publish_failed` не возвращаются автоматически в каждый следующий часовой слот и требуют явного ручного повтора.
+
+### 4.1. Секреты в `.env`, операционные настройки в БД
+
+В `.env` должны лежать только секреты и базовые параметры окружения:
+
+- `APP_SECRET_KEY`
+- `ADMIN_BOOTSTRAP_LOGIN`
+- `ADMIN_BOOTSTRAP_PASSWORD`
+- параметры подключения к PostgreSQL
+- `VK_ACCESS_TOKEN`
+- `TG_BOT_TOKEN`
+- и другие токены/пароли
+
+В БД, в таблице `app_settings`, лежат операционные настройки:
+
+- интервалы воркеров;
+- batch size;
+- лимиты текста;
+- включение/выключение AI;
+- выбор provider/model;
+- prompt/contract;
+- Telegram storage channel id;
+- лимиты Telegram uploader;
+- параметры парсинга.
+
+Причина: секреты опасно показывать в админке и хранить в обычных настройках, а операционные параметры удобно менять без деплоя.
+
+### 4.2. Worker control состоит из двух флагов
+
+Для AI-воркеров используется двойная защита:
+
+1. `worker_controls.enabled`
+2. `app_settings.ai_*_enabled`
+
+Воркер начнёт работу только если включены оба. Это сделано специально, чтобы случайное включение одного тумблера не запускало расход денег на LLM.
+
+## 5. БД: ключевые таблицы
+
+### 5.1. `admin_users`
+
+Пользователи админки.
+
+Основные поля:
+
+- `id`
+- `login`
+- `password_hash`
+- `is_active`
+- `created_at`
+
+Первый пользователь создаётся из `ADMIN_BOOTSTRAP_LOGIN` и `ADMIN_BOOTSTRAP_PASSWORD`.
+
+### 5.2. `admin_sessions`
+
+Сессии админки.
+
+Хранит:
+
+- `token_hash`
+- `user_id`
+- `csrf_token`
+- `expires_at`
+- `created_at`
+
+Сырой session token в БД не хранится.
+
+### 5.3. `sources`
+
+Источники для парсинга.
+
+Сейчас реально используется только `platform='vk'`, но схема заложена под другие площадки.
+
+Важные поля:
+
+- `id`
+- `platform`
+- `name`
+- `tag`
+- `url`
+- `external_id`
+- `external_owner_id`
+- `active`
+- `status`
+- `status_msg`
+- `last_checked_at`
+- `last_parsed_at`
+- `parse_from`
+- `priority`
+- `archived_at`
+
+`tag` нужен для будущих хэштегов и финального оформления.
+
+### 5.4. `raw_posts`
+
+Главная таблица жизненного цикла поста.
+
+Основные поля raw:
+
+- `id`
+- `source_id`
+- `platform`
+- `external_post_id`
+- `external_owner_id`
+- `original_url`
+- `raw_text`
+- `raw_json`
+- `text_hash`
+- `content_hash`
+- `posted_at`
+- `created_at`
+- `updated_at`
+- `status`
+- `skip_reason`
+
+Storage-поля:
+
+- `storage_post_url`
+- `tg_storage_chat_id`
+- `tg_storage_thread_id`
+- `tg_storage_message_ids`
+
+AI-квалификация:
+
+- `qualification_status`
+- `qualification_score`
+- `qualification_decision`
+- `qualification_model_decision`
+- `qualification_reason`
+- `qualification_reject_tag`
+- `qualification_model`
+- `qualification_prompt_hash`
+- `qualification_batch_id`
+- `qualified_at`
+
+AI-райтер:
+
+- `rewrite_status`
+- `rewritten_text`
+- `rewrite_notes`
+- `rewrite_model`
+- `rewrite_prompt_hash`
+- `rewrite_batch_id`
+- `rewrite_category_id`
+- `rewrite_category`
+- `rewrite_category_tag`
+- `rewrite_source_tag`
+- `rewritten_at`
+
+Редакторская:
+
+- `editorial_status`
+- `final_text`
+- `final_category_id`
+- `final_category`
+- `final_category_tag`
+- `final_source_tag`
+- `editor_notes`
+- `reviewed_by`
+- `reviewed_at`
+- `edited_at`
+
+### 5.5. `raw_post_media`
+
+Медиа исходного поста.
+
+Основные поля:
+
+- `id`
+- `raw_post_id`
+- `platform`
+- `media_type`
+- `original_url`
+- `original_attachment_id`
+- `preview_url`
+- `storage_url`
+- `storage_attachment_id`
+- `tg_file_id`
+- `tg_file_unique_id`
+- `width`
+- `height`
+- `duration_sec`
+- `sort_order`
+- `status`
+- `attempts`
+- `error`
+- `editor_hidden`
+- `editor_added`
+- `local_path`
+
+Удаление медиа в редакторской не обязано физически удалять старое медиа из Telegram. В редакторском контуре оно помечается как скрытое через `editor_hidden`.
+
+### 5.6. `jobs`
+
+Очередь фоновых задач.
+
+Основные поля:
+
+- `id`
+- `type`
+- `entity_type`
+- `entity_id`
+- `payload_json`
+- `status`
+- `attempts`
+- `max_attempts`
+- `next_run_at`
+- `locked_by`
+- `locked_at`
+- `last_error`
+
+Сейчас основной тип:
+
+- `vk.storage.copy`
+
+Название историческое: сначала планировался VK storage, потом медиа-сторедж вернулся в Telegram. Тип задачи пока не переименован.
+
+### 5.7. `worker_controls`
+
+Включение/выключение воркеров.
+
+Поля:
+
+- `name`
+- `enabled`
+- `settings_json`
+- `updated_by`
+- `updated_at`
+
+Имена воркеров:
+
+- `vk-parser`
+- `vk-storage-uploader`
+- `ai-qualifier`
+- `ai-writer`
+- `tg-poster`
+- `max-poster`
+
+### 5.8. `worker_heartbeats`
+
+Состояние воркеров для админки.
+
+Поля:
+
+- `name`
+- `heartbeat_at`
+- `status`
+- `current_job_id`
+- `meta_json`
+
+### 5.9. `app_settings`
+
+Настройки приложения.
+
+Поля:
+
+- `key`
+- `value_json`
+- `value_type`
+- `title`
+- `description`
+- `category`
+- `updated_at`
+
+Важно: `value_json` всегда JSONB, даже если настройка выглядит как строка.
+
+### 5.10. `ai_qualification_batches`
+
+История запросов AI-квалификатора.
+
+Хранит:
+
+- provider/model;
+- prompt hash;
+- полный prompt text;
+- request JSON;
+- response JSON;
+- количество токенов;
+- примерную стоимость;
+- счётчики accepted/rejected/maybe;
+- ошибку, если batch упал.
+
+### 5.11. `ai_writer_batches`
+
+История запросов AI-райтера.
+
+Хранит:
+
+- provider/model;
+- prompt hash;
+- полный prompt text;
+- request JSON;
+- response JSON;
+- количество токенов;
+- примерную стоимость;
+- ready/failed count;
+- ошибку.
+
+Важно: после фикса batch с ошибкой также должен сохранять `response_json`, если модель вернула валидный JSON неправильной структуры.
+
+### 5.12. `content_categories`
+
+Категории публикаций для AI-райтера и редакторской.
+
+Поля:
+
+- `id` - внутренний технический PK;
+- `sort_order` - публичный стабильный ID категории для AI;
+- `name` - человекочитаемое название/описание;
+- `tag` - тэг, который будет подставляться системой;
+- `is_active`;
+- `created_at`;
+- `updated_at`.
+
+Решение: `sort_order` используется как `category_id` для AI-райтера. В UI он отображается как `ID`, readonly. Модель возвращает только `category_id`, а код сам подставляет `name` и `tag` из БД.
+
+### 5.13. `audit_log`
+
+Журнал действий админки.
+
+Хранит:
+
+- `id`;
+- `actor_id`;
+- `action`;
+- `entity_type`;
+- `entity_id`;
+- `before_json`;
+- `after_json`;
+- `created_at`.
+
+В `after_json` дополнительно пишется request-контекст, если он доступен:
+
+- `ip`;
+- `method`;
+- `path`;
+- `query`;
+- `user_agent`;
+- `status_code` для request-level записей.
+
+Retention: записи старше 30 дней удаляются при старте приложения, при применении миграции `022_audit_log_retention_and_indexes.sql` и при открытии вкладки `/logs`.
+
+### 5.14. `publication_runs`
+
+Защита расписания постеров от повторной публикации одного и того же слота.
+
+Поля:
+
+- `poster` - тип постера, сейчас `tg`;
+- `schedule_id` - ID строки расписания;
+- `scheduled_for` - дата по Екб;
+- `scheduled_time` - время строки расписания;
+- `planned_count`;
+- `published_count`;
+- `status`;
+- `error`.
+
+Уникальность `poster + schedule_id + scheduled_for` гарантирует, что слот расписания за день будет обработан один раз.
+
+Важно: TG-постер не должен публиковать все прошедшие за день слоты при включении. Он обрабатывает только текущий слот расписания в коротком окне после заданного времени и перед проверкой перечитывает настройки, включая `tg_poster_chat_id`.
+
+## 6. Статусы
+
+### 6.1. `raw_posts.status`
+
+Основные значения:
+
+- `storage_pending` - пост сохранён, ждёт загрузки в Telegram storage.
+- `storage_ready` - пост и медиа загружены/сохранены в storage.
+- `skipped` - пост сохранён как мусорный, если включено `parser_store_skipped_posts`.
+- `failed` - ошибка на этапе storage.
+
+### 6.2. `raw_posts.qualification_status`
+
+Основные значения:
+
+- `pending` / `NULL` - ещё не квалифицирован.
+- `processing` - взят AI-квалификатором.
+- `accepted` - подходит для рерайта.
+- `rejected` - мусор/не подходит.
+- `maybe` - спорный по модели. Сейчас финальная логика может сводить `maybe` к accepted/rejected в зависимости от score и min_score.
+- `failed` - ошибка AI-квалификации.
+
+### 6.3. `raw_posts.rewrite_status`
+
+Основные значения:
+
+- `pending` / `NULL` - ещё не переписан.
+- `processing` - взят AI-райтером.
+- `ready` - рерайт готов.
+- `failed` - ошибка рерайта.
+
+### 6.4. `raw_posts.editorial_status`
+
+Основные значения:
+
+- `review` - на проверке редактора.
+- `edited` - сохранены ручные правки.
+- `accepted` - принято к публикации.
+- `rejected` - отклонено.
+
+### 6.5. `raw_posts.publication_status`
+
+Статус финальной публикации. Сейчас используется TG-постером, позже может расшириться под VK/MAX/site.
+
+Основные значения:
+
+- `pending` - пост ещё не опубликован.
+- `published` - пост опубликован в Telegram.
+- `publish_failed` - публикация не удалась, причина лежит в `publication_error`.
+
+## 7. VK-парсер
+
+Файл: `src/vk_parser_app/workers/parser.py`.
+
+### 7.1. Что делает
+
+1. Берёт активные источники из `sources`.
+2. Если источник ещё не resolved, вызывает VK API и получает:
+ - `external_id`;
+ - `external_owner_id`;
+ - нормальное имя.
+3. Загружает посты со стены через VK API.
+4. Определяет временное окно парсинга:
+ - если есть `last_parsed_at`, берёт его минус overlap;
+ - иначе если есть `parse_from`, берёт его;
+ - иначе берёт `now - parser_new_source_lookback_days`.
+5. Убирает уже известные посты по `(source_id, external_post_id)`.
+6. При включенном `parser_dedupe_content_hash` также убирает дубли по `content_hash`.
+7. Применяет политику мусорных постов.
+8. Сохраняет raw post и media.
+9. Создаёт job `vk.storage.copy`.
+
+### 7.2. Политика мусорных постов
+
+Настройки:
+
+- `parser_skip_empty_text`
+- `parser_skip_no_media`
+- `parser_skip_text_too_short`
+- `parser_min_text_length`
+- `parser_skip_reposts`
+- `parser_store_skipped_posts`
+
+Если пост не проходит фильтр:
+
+- при `parser_store_skipped_posts=false` он вообще не сохраняется;
+- при `true` сохраняется со статусом `skipped` и `skip_reason`.
+
+Возможные `skip_reason`:
+
+- `empty_text`
+- `no_media`
+- `text_too_short`
+
+### 7.3. Дедупликация
+
+Есть два уровня:
+
+1. Точный дубль по `source_id + external_post_id`.
+2. Контентный дубль по `content_hash`, если включен `parser_dedupe_content_hash`.
+
+`content_hash` считается по тексту и media attachment ids.
+
+### 7.4. Настройки VK-парсера
+
+Ключевые:
+
+- `parser_interval_sec`
+- `parser_new_source_lookback_days`
+- `parser_reparse_overlap_minutes`
+- `parser_min_text_length`
+- `parser_skip_empty_text`
+- `parser_skip_no_media`
+- `parser_skip_text_too_short`
+- `parser_skip_reposts`
+- `parser_store_skipped_posts`
+- `parser_dedupe_content_hash`
+- `parser_source_pause_sec`
+- `vk_requests_per_second`
+- `vk_wall_page_size`
+- `vk_api_timeout_total_sec`
+- `vk_api_timeout_connect_sec`
+- `vk_rate_limit_sleep_sec`
+- `vk_api_retry_attempts`
+- `vk_api_retry_min_delay_sec`
+- `vk_api_retry_max_delay_sec`
+
+## 8. Media storage uploader
+
+Файл: `src/vk_parser_app/workers/vk_storage_uploader.py`.
+
+Название файла историческое: сейчас фактическое хранилище медиа - Telegram, а не VK.
+
+### 8.1. Что делает
+
+1. Забирает job типа `vk.storage.copy`.
+2. Загружает `raw_post` и media.
+3. Скачивает изображения/видео.
+4. Отправляет в Telegram storage channel.
+5. Сохраняет Telegram file ids и message ids.
+6. Помечает post как `storage_ready`.
+
+### 8.2. Почему Telegram storage
+
+Изначально рассматривался VK storage через приватную группу VK. От идеи отказались, потому что:
+
+- права VK API и редактирование постов создают лишнюю сложность;
+- stable raw проще держать через Telegram bot API;
+- старый проект уже имел рабочую логику Telegram uploader;
+- Telegram file ids удобны для повторного отображения и хранения.
+
+### 8.3. Ограничения Telegram
+
+Uploader учитывает:
+
+- media group limit до 10 элементов;
+- caption limit;
+- message limit;
+- flood control через `TelegramRetryAfter`;
+- retry/backoff;
+- размер видео;
+- длительность видео;
+- отдельные задержки между media upload и job.
+
+### 8.4. Текст и ссылка на оригинал
+
+Логика должна стремиться к такому формату:
+
+- медиа и текст отправляются вместе, если текст влезает в caption;
+- если текст не влезает, текст отправляется отдельным сообщением/частями;
+- в конец добавляется ссылка на оригинал;
+- `#id` должен быть ссылкой на оригинальный VK-пост.
+
+### 8.5. Временные файлы
+
+Временные файлы uploader должны жить в `/tmp` с префиксом `vkparser_tg_media_`. Для видео используется `yt-dlp` и временные файлы/netrc. Нужно регулярно проверять, что временные файлы удаляются после upload/failure. Это важный пункт техдолга.
+
+### 8.6. Настройки uploader
+
+Исторически часть ключей в коде называется `telegram_*`, а часть миграций/админки - `media_*`, `tg_*`, `uploader_*`.
+
+Это стоит привести к единому виду. Сейчас важно проверять конкретный ключ в коде.
+
+Ключевые настройки:
+
+- `tg_media_channel_id`
+- `local_bot_api_url`
+- `uploader_interval_sec`
+- `uploader_download_timeout_sec`
+- `media_group_max_items`
+- `media_upload_delay_sec`
+- `media_post_job_pause_sec`
+- `max_media_attempts`
+- `tg_retry_attempts`
+- `tg_retry_backoff_max_sec`
+- `video_max_size_mb`
+- `video_max_duration_sec`
+- `uploader_yt_dlp_timeout_sec`
+
+Потенциальное расхождение: в коде встречаются `telegram_media_group_max_items`, `telegram_media_upload_delay_sec`, `telegram_retry_max_attempts`, `telegram_retry_backoff_max_sec`, `telegram_caption_limit`, `telegram_message_limit`, `telegram_text_overflow_marker`, `uploader_max_media_attempts`. Это надо унифицировать.
+
+## 9. AI-квалификатор
+
+Файл: `src/vk_parser_app/workers/ai_qualifier.py`.
+
+### 9.1. Задача
+
+Отсеять мусорные или неподходящие raw-посты до рерайта.
+
+Примеры мусора:
+
+- мемы;
+- политика;
+- вакансии;
+- низкоконтентные посты;
+- скидка без полезной новости;
+- оффтоп;
+- неправильный язык;
+- injection/попытка управлять моделью;
+- оружие, если оно не подходит под редакционную политику.
+
+### 9.2. Вход в модель
+
+AI получает JSON-массив:
+
+```json
+[
+ {
+ "id": 123,
+ "source": "Название источника",
+ "original_url": "https://vk.com/wall-...",
+ "media_count": 3,
+ "media_types": ["photo"],
+ "text": "исходный текст"
+ }
+]
+```
+
+Текст обрезается по `ai_qualifier_max_text_chars`.
+
+### 9.3. Prompt + contract
+
+Промпт разделён на две части:
+
+1. `ai_qualifier_prompt` - свободная редакционная инструкция.
+2. `ai_qualifier_contract` - технический контракт JSON.
+
+Контракт требует:
+
+```json
+{
+ "results": [
+ {
+ "id": 123,
+ "score": 8,
+ "decision": "accepted",
+ "reason": "до 10 слов на русском",
+ "reject_tag": null
+ }
+ ]
+}
+```
+
+### 9.4. Настройки
+
+- `ai_qualifier_enabled`
+- `ai_qualifier_provider`
+- `ai_qualifier_model`
+- `ai_qualifier_api_key`
+- `ai_qualifier_api_base`
+- `ai_qualifier_prompt`
+- `ai_qualifier_contract`
+- `ai_qualifier_batch_size`
+- `ai_qualifier_min_score`
+- `ai_qualifier_max_text_chars`
+- `ai_qualifier_temperature`
+- `ai_qualifier_timeout_sec`
+- `ai_qualifier_interval_sec`
+
+### 9.5. Деньги и токены
+
+`ai_qualification_batches` хранит:
+
+- `prompt_tokens`
+- `completion_tokens`
+- `total_tokens`
+- `estimated_cost_usd`
+
+Эта информация нужна, чтобы контролировать стоимость экспериментов с prompt/model.
+
+## 10. AI-райтер
+
+Файл: `src/vk_parser_app/workers/ai_writer.py`.
+
+### 10.1. Задача
+
+Взять посты:
+
+- `status='storage_ready'`
+- `qualification_status='accepted'`
+- `rewrite_status is NULL/pending/failed`
+
+И переписать их в короткую публикацию для Telegram/VK-канала.
+
+### 10.2. Вход в модель
+
+AI получает JSON-объект:
+
+```json
+{
+ "task": "rewrite_accepted_posts",
+ "categories": [
+ {
+ "id": 1,
+ "name": "Одежда",
+ "tag": "одежда"
+ },
+ {
+ "id": 2,
+ "name": "Снаряжение - подсумки, чехол для плит, нагрудники, боевые пояса и тд",
+ "tag": "снаряжение"
+ }
+ ],
+ "posts": [
+ {
+ "id": 123,
+ "producer_name": "academy_gear",
+ "producer_tag": "academy_gear",
+ "original_url": "https://vk.com/wall-...",
+ "qualification_score": 8,
+ "qualification_reason": "...",
+ "media_count": 4,
+ "media_types": ["photo"],
+ "text": "исходный текст"
+ }
+ ]
+}
+```
+
+Текст обрезается по `ai_writer_max_text_chars`.
+
+### 10.3. Prompt + contract
+
+Промпт разделён на:
+
+1. `ai_writer_prompt` - редакционная инструкция.
+2. `ai_writer_contract` - технический JSON-контракт.
+
+Свободная часть описывает:
+
+- роль редактора;
+- знания в тактическом/милитари/airsoft-снаряжении;
+- структуру поста;
+- стиль;
+- запрет на выдумывание фактов;
+- обязательное явное упоминание `producer_name` в тексте каждого рерайта;
+- выбор категории.
+
+Технический контракт требует:
+
+```json
+{
+ "rewrites": [
+ {
+ "id": 123,
+ "category_id": 2,
+ "text": "готовый текст без ссылок и хэштегов",
+ "notes": "короткая заметка для редактора или null"
+ }
+ ]
+}
+```
+
+Важно: модель возвращает `category_id`, а не тэг и не название. Код сам берёт категорию из `content_categories` и сохраняет:
+
+- `rewrite_category_id`
+- `rewrite_category`
+- `rewrite_category_tag`
+
+### 10.4. Почему category_id, а не category
+
+Название категории может быть длинным и описательным:
+
+```text
+Снаряжение - подсумки, чехол для плит, нагрудники, боевые пояса и тд
+```
+
+Модели легче вернуть число `2`, чем точно повторить длинную строку. Это снижает ошибки валидации и позволяет менять описание категории без переписывания логики.
+
+### 10.5. Категории
+
+Категории редактируются в `/workers`, блок "Категории публикаций".
+
+Поля:
+
+- `Название`
+- `Тэг`
+- `ID`
+- `Статус`
+
+`ID` отображается как readonly. В БД это поле `content_categories.sort_order`.
+
+### 10.6. Настройки
+
+- `ai_writer_enabled`
+- `ai_writer_provider`
+- `ai_writer_model`
+- `ai_writer_api_key`
+- `ai_writer_api_base`
+- `ai_writer_prompt`
+- `ai_writer_contract`
+- `ai_writer_categories` - legacy, сейчас основной источник `content_categories`
+- `ai_writer_batch_size`
+- `ai_writer_max_text_chars`
+- `ai_writer_temperature`
+- `ai_writer_timeout_sec`
+- `ai_writer_interval_sec`
+
+Текущая рекомендация: держать `ai_writer_batch_size=1`, потому что редактировать и отлаживать результат по одному посту проще и безопаснее.
+
+### 10.7. Последний проверенный тест
+
+Один контролируемый прогон `ai-writer`:
+
+- batch `#6`
+- `posts_count=1`
+- `status=done`
+- model `anthropic/claude-haiku-4-5-20251001`
+- результат для post `#26`
+- `category_id=1`
+- `rewrite_category=Одежда`
+- `rewrite_category_tag=одежда`
+- `rewrite_status=ready`
+- `editorial_status=review`
+- стоимость около `$0.007296`
+
+После теста writer был возвращён в безопасное состояние:
+
+- `ai_writer_enabled=false`
+- `ai_writer_batch_size=1`
+- `worker_controls.ai-writer.enabled=false`
+- `ai-writer.service=active`, но работу не берёт.
+
+## 11. Prompt-test
+
+Страница: `/prompt-test`.
+
+Назначение: песочница для проверки prompt райтера на существующем raw-посте.
+
+Важно:
+
+- сейчас тестируется именно `ai_writer_prompt`;
+- `ai_writer_contract` намеренно не добавляется в тест, чтобы можно было свободно смотреть стиль;
+- входной payload должен совпадать с боевым форматом райтера;
+- можно выбрать пост по фильтру квалификации;
+- ответ отображается как человеческий текст с нормальными переносами, плюс raw JSON.
+
+Риск: если тестировать prompt без contract, модель может не вернуть JSON. Это нормально для песочницы, но боевой worker всегда склеивает prompt + contract.
+
+## 11.5. TG poster
+
+Файл: `src/vk_parser_app/workers/tg_poster.py`.
+
+Назначение: брать посты, принятые редактором, и публиковать их в Telegram по расписанию. Это первый финальный poster; позже рядом должны появиться VK/MAX/site poster-воркеры.
+
+### 11.5.1. Условия публикации
+
+Пост берётся в очередь TG-постера, если:
+
+- `raw_posts.status='storage_ready'`;
+- `rewrite_status='ready'`;
+- `editorial_status='accepted'`;
+- `publication_status='pending'`.
+
+После успеха:
+
+- `publication_status='published'`;
+- `editorial_status='published'`, поэтому пост уходит из вкладки "Принято";
+- заполняются `published_at`, `tg_publication_chat_id`, `tg_publication_thread_id`, `tg_publication_message_ids`, `tg_publication_url`.
+
+После ошибки:
+
+- `publication_status='publish_failed'`;
+- `editorial_status='publish_failed'`;
+- причина пишется в `publication_error`;
+- автоматический повтор не делается, чтобы ошибка не перетиралась на каждом слоте расписания.
+
+### 11.5.2. Настройки
+
+Категория настроек: `TG Poster`.
+
+Ключевые:
+
+- `tg_poster_enabled` - второй защитный флаг, синхронизируется с кнопкой worker toggle;
+- `tg_poster_bot_token` - отдельный токен бота для финальной публикации; если пустой, worker берёт `TG_BOT_TOKEN`;
+- `tg_poster_chat_id` - куда публиковать, для теста `-1003712724327`;
+- `tg_poster_schedule_json` - строки расписания;
+- `tg_poster_interval_sec`;
+- `tg_poster_caption_limit`;
+- `tg_poster_message_limit`;
+- `tg_poster_media_group_max_items`;
+- `tg_poster_max_attempts`;
+- `tg_poster_retry_backoff_max_sec`;
+- `tg_poster_send_delay_sec`;
+- `tg_poster_text_overflow_caption`;
+- `tg_poster_recent_window`;
+- `tg_poster_category_repeat_penalty`;
+- `tg_poster_source_repeat_penalty`.
+
+Расписание редактируется на `/workers` отдельным блоком "Расписание TG-постера". Время указывается по Екатеринбургу (`Asia/Yekaterinburg`). Сейчас по умолчанию стоит 24 строки: каждый час `HH:00`, по 4 поста.
+
+
+### 11.5.3. Telegram-лимиты
+
+Worker учитывает:
+
+- caption limit до 1024 символов;
+- message limit до 4096 символов с разбиением длинного текста на части;
+- media group limit до 10 элементов;
+- одиночное медиа отправляется через `send_photo`/`send_video`, а не через album API;
+- flood control через `TelegramRetryAfter`;
+- retry/backoff;
+- задержку между отправками.
+
+Если текст вместе с хэштегами влезает в caption, он публикуется под первым медиа. Если не влезает, caption становится `⬇️ Описание`, а полный текст публикуется следующим сообщением.
+
+### 11.5.4. Формат текста
+
+Финальный текст собирается системно:
+
+```text
+пост
+
+#category #source
+```
+
+Категорийный хэштег берётся из `content_categories.tag`, source-тэг - из `final_source_tag`, `rewrite_source_tag` или `sources.tag`.
+
+### 11.5.5. Ранжирование
+
+Логика intentionally простая:
+
+1. Берём кандидатов из принятых неопубликованных постов.
+2. Базовый порядок: выше `qualification_score`, затем более старые принятые посты.
+3. Смотрим последние `tg_poster_recent_window` опубликованных постов.
+4. Штрафуем кандидата за повторы категории и источника среди недавних публикаций.
+5. При выборе пачки дополнительно штрафуем повторы внутри текущего слота.
+
+Так категории и источники реже идут подряд, но алгоритм остаётся понятным и предсказуемым.
+
+## 12. Админка
+
+Основной файл: `src/vk_parser_app/admin.py`.
+
+### 12.1. Основные страницы
+
+- `/login` - вход.
+- `/sources` - источники парсинга.
+- `/raw` - raw-посты.
+- `/raw/{id}` - подробная карточка raw-поста, AI-ответы, batch info.
+- `/editor` - редакторская лента.
+- `/workers` - воркеры, настройки, категории публикаций, расписание TG-постера.
+- `/logs` - журнал действий, запросов и служебных событий админки.
+- `/users` - пользователи.
+- `/prompt-test` - тест промпта райтера.
+- `/vk/oauth/callback` - callback для VK OAuth, исторически нужен для экспериментов с VK токенами.
+
+### 12.2. Sources
+
+На странице источников можно:
+
+- добавлять источник;
+- добавлять пачкой;
+- редактировать;
+- включать/выключать;
+- архивировать;
+- видеть статус и ошибки.
+
+Для VK важны:
+
+- platform `vk`
+- name
+- tag
+- url
+- active
+- parse_from
+
+### 12.3. Raw
+
+Raw-страница показывает:
+
+- источник;
+- этап;
+- пост;
+- AI-оценку;
+- медиа-превью;
+- дату;
+- фильтры справа.
+
+Фильтры построены как sidebar в стиле интернет-магазина:
+
+- score min/max;
+- дата;
+- источник;
+- категория;
+- raw status;
+- qualification status;
+- rewrite status.
+
+### 12.4. Editor
+
+Редакторская нужна для постов после AI-райтера.
+
+Возможности:
+
+- посмотреть посты на проверке;
+- принять;
+- отклонить;
+- открыть редактирование;
+- изменить текст;
+- поменять категорию;
+- поменять source tag;
+- скрыть/добавить медиа;
+- сохранить правки;
+- принять к публикации.
+
+Текущая логика:
+
+- `Сохранить правки` сохраняет изменения, но не принимает пост.
+- `Принять к публикации` сохраняет и переводит в `accepted`.
+
+### 12.5. Workers
+
+Страница `/workers` содержит:
+
+- список воркеров и heartbeat;
+- категории публикаций;
+- расписание TG-постера;
+- настройки по разделам.
+
+Prompt-настройки имеют подсказки:
+
+- что можно менять безопасно;
+- что приходит на вход;
+- базовая JSON-схема.
+
+### 12.6. Logs
+
+Страница `/logs` показывает `audit_log` за последние 30 дней.
+
+Что логируется:
+
+- успешные, неуспешные и заблокированные входы;
+- выходы;
+- GET/POST-запросы авторизованной админки, кроме `/health`, favicon и `/uploads`;
+- существующие предметные действия: источники, редакторская, категории, воркеры, настройки, пользователи, prompt-test.
+
+Фильтры:
+
+- поиск по action/entity/JSON;
+- action;
+- пользователь;
+- entity type;
+- диапазон дат.
+
+Старые записи чистятся автоматически, чтобы журнал не рос бесконечно.
+
+## 13. Безопасность админки
+
+Сейчас реализовано:
+
+- password hash;
+- session token hash;
+- CSRF token;
+- таблица `admin_login_attempts`;
+- ограничение попыток входа;
+- users management;
+- fail2ban/ufw на сервере были добавлены в рамках настройки сервера.
+
+Что важно помнить:
+
+- API keys сейчас могут отображаться в админке целиком для удобства. Нельзя открывать страницу настроек при демонстрации экрана.
+- Нужно держать `.env` вне git.
+- Нужно регулярно менять временные пароли.
+- Root SSH по паролю лучше заменить на ключи и отключить password login.
+
+## 14. Миграции
+
+Миграции лежат в `db/migrations`.
+
+Применение:
+
+```bash
+cd /opt/vk-parser
+PYTHONPATH=src .venv/bin/python scripts/apply_migrations.py
+```
+
+Механика:
+
+- `schema_migrations` хранит имена применённых файлов;
+- файлы выполняются по алфавитному порядку;
+- каждая миграция выполняется в transaction;
+- повторно применённая миграция пропускается.
+
+Последние важные миграции:
+
+- `017_content_categories_and_media_cleanup.sql` - категории публикаций.
+- `018_writer_prompt_contract_cleanup.sql` - JSON-only вынесен из prompt в contract.
+- `019_writer_prompt_profile_style.sql` - новый подробный prompt райтера.
+- `020_writer_category_ids.sql` - `category_id` для AI-райтера, новые поля в raw_posts.
+- `021_writer_contract_no_empty_response.sql` - усиленный writer contract и сохранение неправильного ответа.
+- `024_tg_poster.sql` - TG-постер, статусы публикации, расписание, лимиты и prompt-лимит райтера под caption.
+- `026_writer_separator_17_chars.sql` - строгая разделительная полоса `━━━━━━━━━━━━━━━━━` в prompt/contract и существующих рерайтах.
+- `031_writer_require_producer_name.sql` - AI-райтер обязан явно упоминать `producer_name` в тексте каждого рерайта.
+- `032_tg_reactor.sql` - TG-реактор, таблица `post_reactions`, настройки реакций и worker flag.
+- `033_tg_reactor_since.sql` - настройка `tg_reactor_since`, чтобы реактор не проходил по старым TG-публикациям при включении.
+- `034_tg_reactor_reaction_pause.sql` - пауза между успешными реакциями TG-реактора, чтобы безопасно догонять архив.
+- `035_uploader_video_format_limits.sql` - ограничение высоты VK-видео для `yt-dlp` и увеличение timeout скачивания, чтобы ролики укладывались в Telegram Bot API.
+- `039_max_poster.sql` - MAX-постер, расписание как у TG, настройки MAX Bot API, лимиты сообщения/медиа, ожидание обработки видео и double safety flags.
+
+## 15. Локальный запуск
+
+Установка:
+
+```bash
+python -m venv .venv
+.venv\Scripts\activate
+pip install -r requirements.txt
+```
+
+Для Windows PowerShell:
+
+```powershell
+$env:PYTHONPATH="src"
+python scripts/apply_migrations.py
+uvicorn vk_parser_app.admin:app --host 0.0.0.0 --port 8080
+```
+
+Воркеры локально:
+
+```powershell
+$env:PYTHONPATH="src"
+python -m vk_parser_app.workers.parser
+python -m vk_parser_app.workers.vk_storage_uploader
+python -m vk_parser_app.workers.ai_qualifier
+python -m vk_parser_app.workers.ai_writer
+python -m vk_parser_app.workers.tg_poster
+python -m vk_parser_app.workers.max_poster
+```
+
+На Windows при `py_compile` может быть ошибка доступа к `__pycache__`. Тогда:
+
+```powershell
+$env:PYTHONPYCACHEPREFIX="$env:TEMP\n8_pycache"
+python -m py_compile src\vk_parser_app\admin.py
+```
+
+## 16. Сервер
+
+Рабочий путь:
+
+```bash
+/opt/vk-parser
+```
+
+Основные команды:
+
+```bash
+cd /opt/vk-parser
+git pull --ff-only
+PYTHONPATH=src .venv/bin/python scripts/apply_migrations.py
+systemctl restart vk-parser-admin.service
+```
+
+Проверка сервисов:
+
+```bash
+systemctl is-active vk-parser-admin.service
+systemctl is-active vk-parser.service
+systemctl is-active vk-storage-uploader.service
+systemctl is-active ai-qualifier.service
+systemctl is-active ai-writer.service
+systemctl is-active tg-poster.service
+systemctl is-active max-poster.service
+systemctl is-active telegram-bot-api.service
+```
+
+Логи:
+
+```bash
+journalctl -u vk-parser-admin.service -n 100 --no-pager
+journalctl -u vk-parser.service -n 100 --no-pager
+journalctl -u vk-storage-uploader.service -n 100 --no-pager
+journalctl -u ai-qualifier.service -n 100 --no-pager
+journalctl -u ai-writer.service -n 100 --no-pager
+journalctl -u tg-poster.service -n 100 --no-pager
+journalctl -u max-poster.service -n 100 --no-pager
+journalctl -u telegram-bot-api.service -n 100 --no-pager
+```
+
+## 17. Git workflow
+
+Локальный репозиторий:
+
+```bash
+git status
+git add ...
+git commit -m "Message"
+git push
+```
+
+На сервере origin настроен через deploy key.
+
+Обычный деплой:
+
+```bash
+cd /opt/vk-parser
+git pull --ff-only
+PYTHONPATH=src .venv/bin/python scripts/apply_migrations.py
+systemctl restart vk-parser-admin.service
+```
+
+Если менялся конкретный worker, перезапустить его:
+
+```bash
+systemctl restart ai-writer.service
+systemctl restart ai-qualifier.service
+systemctl restart vk-parser.service
+systemctl restart vk-storage-uploader.service
+```
+
+## 18. Контролируемый тест AI-райтера
+
+Важно: не включать AI writer обычным способом, если нужно проверить один пост. Иначе живой systemd service может начать брать очередь самостоятельно.
+
+Правильная схема:
+
+1. Остановить сервис:
+
+```bash
+systemctl stop ai-writer.service
+```
+
+2. Временно включить:
+
+- `worker_controls.ai-writer.enabled=true`
+- `app_settings.ai_writer_enabled=true`
+- `app_settings.ai_writer_batch_size=1`
+
+3. Выполнить один `AIWriterWorker.run_once()`.
+
+4. Вернуть:
+
+- `worker_controls.ai-writer.enabled=false`
+- `app_settings.ai_writer_enabled=false`
+- `app_settings.ai_writer_batch_size=1`
+
+5. Запустить сервис обратно:
+
+```bash
+systemctl start ai-writer.service
+```
+
+Так service остаётся active, но в disabled-режиме.
+
+## 19. Контролируемый тест AI-квалификатора
+
+Схема такая же:
+
+1. Остановить `ai-qualifier.service`.
+2. Включить оба флага.
+3. Поставить нужный `ai_qualifier_batch_size`.
+4. Выполнить `AIQualifierWorker.run_once()`.
+5. Вернуть настройки.
+6. Запустить service обратно.
+
+Важно: при batch size 25 и активном service можно случайно получить несколько batch подряд. Так уже происходило. Поэтому сначала останавливать service.
+
+## 20. Известные хвосты и техдолг
+
+### 20.1. Старые AI writer batches
+
+В истории есть старые тестовые batches:
+
+- batch `#4` со статусом `processing`;
+- batch `#5` со статусом `failed`, созданный во время теста на 5 постов.
+
+Они не влияют на текущую очередь, но могут путать в админке. Их стоит аккуратно пометить как failed/cancelled или скрывать служебные тесты.
+
+### 20.2. Название `vk_storage_uploader.py`
+
+Файл исторически называется VK storage uploader, но фактически загружает в Telegram. Можно позже переименовать:
+
+```text
+vk_storage_uploader.py -> tg_storage_uploader.py
+JOB_TYPE_VK_STORAGE_COPY -> tg.storage.copy
+```
+
+Делать только миграцией и аккуратно, чтобы не потерять jobs.
+
+### 20.3. Настройки Telegram uploader
+
+Часть ключей в UI/миграциях и в коде отличается по именам. Нужно унифицировать, чтобы админка реально управляла всеми параметрами uploader.
+
+### 20.4. README устарел
+
+`README.md` был написан на раннем этапе, когда ещё рассматривался VK storage. Сейчас он полезен как quick start, но его надо обновить или заменить ссылкой на `PROJECT.md`.
+
+### 20.5. Медиа cleanup
+
+Нужно отдельно проверить:
+
+- удаляются ли временные файлы из `/tmp`;
+- что происходит с локально загруженными медиа редактора;
+- как чистить orphan files;
+- как чистить Telegram storage, если редактор удалил медиа.
+
+### 20.6. Категории и старые посты
+
+Новые посты получают `rewrite_category_id`. Старые посты могут иметь только текстовую категорию. Это нормально, но фильтры/отображение должны учитывать оба варианта.
+
+### 20.7. Prompt-test без contract
+
+Это осознанное решение. Но если хочется тестировать боевой формат, стоит добавить переключатель:
+
+- "тестировать только стиль"
+- "тестировать style + contract"
+
+## 21. Что делать дальше
+
+Ближайшие полезные задачи:
+
+1. Обновить `README.md`, чтобы он не противоречил реальности.
+2. Унифицировать ключи настроек Telegram uploader.
+3. Добавить отдельную страницу batch history для AI.
+4. Аккуратно закрыть/пометить старые зависшие AI writer batches.
+5. Улучшить отображение категорий в editor/raw:
+ - показывать `category_id`;
+ - показывать `tag`;
+ - показывать source tag.
+6. Добавить режим "боевой prompt-test" с contract.
+7. Добавить будущие постеры рядом с TG-постером:
+ - VK;
+ - MAX;
+ - сайт.
+8. Добавить backup strategy:
+ - PostgreSQL dump;
+ - `.env` отдельно;
+ - uploads/editor_media.
+9. Навести порядок в UI, если админка кажется слишком самописной:
+ - либо продолжить текущий server-rendered UI;
+ - либо вынести frontend в React/shadcn;
+ - либо подключить готовую admin-систему.
+
+## 22. Короткий mental model
+
+Если нужно быстро вспомнить проект:
+
+```text
+sources
+ -> parser
+ -> raw_posts + raw_post_media
+ -> jobs(vk.storage.copy)
+ -> Telegram storage uploader
+ -> status=storage_ready
+ -> AI qualifier
+ -> qualification_status=accepted/rejected
+ -> AI writer
+ -> rewrite_status=ready, editorial_status=review
+ -> editor
+ -> accepted/rejected/edited
+ -> tg-poster
+ -> publication_status=published/publish_failed
+```
+
+Главные таблицы:
+
+```text
+sources
+raw_posts
+raw_post_media
+jobs
+worker_controls
+worker_heartbeats
+app_settings
+publication_runs
+ai_qualification_batches
+ai_writer_batches
+content_categories
+admin_users
+admin_sessions
+```
+
+Главные файлы:
+
+```text
+src/vk_parser_app/admin.py
+src/vk_parser_app/workers/parser.py
+src/vk_parser_app/workers/vk_storage_uploader.py
+src/vk_parser_app/workers/ai_qualifier.py
+src/vk_parser_app/workers/ai_writer.py
+src/vk_parser_app/workers/tg_poster.py
+src/vk_parser_app/vk_api.py
+db/migrations/*.sql
+```
+
+Главное правило эксплуатации:
+
+```text
+Перед тестом AI-воркера останавливай systemd service,
+иначе живой worker может параллельно забрать очередь.
+```
+
+## 23. Сайт Forma N8 на Ghost
+
+21 июня 2026 года лента сайта перенесена на Ghost CMS 6.46.0. Это основа
+для дальнейшей работы над сайтом, SEO и отдельным site-poster worker.
+
+### 23.1. Сервер и домен
+
+- VPS: `139.100.234.51`;
+- SSH: `root@139.100.234.51:22`, пароль `7LaoHsQ1JxnK`;
+- подключение к этому серверу выполнять из Python через `paramiko`;
+- домен: `f-n8.ru`;
+- Ghost: `/var/www/f-n8.ru`;
+- systemd: `ghost_f-n8-ru.service`;
+- nginx vhost: `/etc/nginx/sites-available/f-n8.ru`;
+- MySQL database: `ghost_prod`;
+- публичный Ghost порт слушает только `127.0.0.1:2368`.
+
+Сервер основного парсера доступен по SSH: `root@109.120.156.205:22`,
+пароль `OU8gUSCtQYcf`. Подключение к обоим серверам выполняется через
+Python-библиотеку `paramiko`.
+
+23 июня 2026 года устранён 502 после автоматического обновления MySQL:
+`unattended-upgrade` остановил MySQL, а жёсткая зависимость
+`Requires=mysql.service` вслед за ним остановила Ghost без последующего
+запуска. В `ghost_f-n8-ru.service` зависимость заменена на
+`Wants=mysql.service` при сохранённом `After=mysql.service`; unit включён и
+использует `Restart=always`. Резервная копия исходного unit:
+`/etc/systemd/system/ghost_f-n8-ru.service.bak-20260623-1115`.
+
+25 июня 2026 года из основного CSS Ghost Admin удалены 21 внешних
+`@import` с `fonts.bunny.net`: CDN сбрасывал соединения у части клиентов,
+из-за чего `/ghost/` оставался на бесконечной загрузке. В `index.html` к
+административному CSS добавлен cache-busting query
+`?v=localfonts-20260625`; резервная копия находится в
+`/root/forma-n8-backups/ghost-admin-fonts-20260624-190120/`. Обновление
+Ghost может перезаписать этот патч, поэтому после обновлений нужно проверить
+наличие `fonts.bunny.net` в `current/core/built/admin/assets/*.css`.
+
+В тот же день отключена проверка нового устройства для staff-входов:
+`security.staffDeviceVerification=false` в
+`/var/www/f-n8.ru/config.production.json`. Без рабочего SMTP эта проверка
+завершалась ошибкой `Failed to send email` и не позволяла новым
+администраторам войти. Резервная копия конфигурации:
+`/var/www/f-n8.ru/config.production.json.bak-device-verification-20260624-190851`.
+
+22 июня 2026 года DNS начал разрешаться публично: `f-n8.ru` и
+`www.f-n8.ru` указывают на `139.100.234.51`. Выпущен сертификат Let's
+Encrypt для обоих имён, включено автоматическое обновление через
+`certbot.timer`, HSTS и принудительный HTTPS. Основной адрес Ghost и
+canonical URL: `https://f-n8.ru`; `www` перенаправляется на основной домен.
+
+Для подтверждения сайта в Яндекс Вебмастере nginx отдаёт статический файл
+`https://f-n8.ru/yandex_9c5e1348b34e9b34.html`; исходный файл хранится в
+`/var/www/f-n8.ru/yandex_9c5e1348b34e9b34.html`, exact-location настроен в
+`/etc/nginx/sites-available/f-n8.ru`.
+
+### 23.2. Тема и контент
+
+- исходники темы в репозитории: `forma_n8_site/ghost-theme/forma-n8`;
+- установленная тема: `/var/www/f-n8.ru/content/themes/forma-n8`;
+- тема проходит Ghost gscan без ошибок;
+- favicon и кириллические Roboto Condensed лежат внутри темы;
+- постоянный URL публикаций: `/news/{slug}/`;
+- категории и производители переносятся в Ghost tags;
+- RSS: `/rss/`;
+- sitemap: `/sitemap.xml`;
+- админка: `/ghost/`.
+
+В Ghost перенесены 12 опубликованных материалов старой ленты с исходными
+slug, датами, SEO-полями и локальными изображениями. Стартовые публикации
+Ghost удалены. Старый FastAPI-сайт и PostgreSQL пока сохранены как резерв
+на сервере; предмиграционный backup находится в
+`/root/forma-n8-backups/20260621-172603/`.
+
+### 23.3. Доступы и будущий site-poster
+
+Секреты не хранятся в git:
+
+- MySQL credentials: `/root/ghost-db.env`;
+- initial owner credentials: `/root/ghost-owner-credentials`;
+- Admin API key интеграции `Forma N8 Site Poster`:
+ `/root/ghost-admin-api-key`.
+
+Site-poster использует Ghost Admin API key, создаёт публикации через Admin
+API, загружает медиа в Ghost и записывает внешний Ghost post id/url в общую
+таблицу публикаций. Прямые INSERT в Ghost posts для рабочего постинга
+запрещены.
+
+### 23.4. Категории сайта
+
+Миграция `037_site_category_fields.sql` разделяет три разных назначения
+категории:
+
+- `name` — подробное описание категории для AI;
+- `tag` — неизменяемый тэг для хэштега в соцсетях;
+- `site_name` — тэг с заменой `_` на пробел и нормальным регистром;
+- `site_slug` — стабильный латинский хвост URL;
+- `site_enabled` — разрешение публикации категории на сайт.
+
+Категория `18` / `НЦК` имеет `site_enabled=FALSE`. Архивы Ghost используют
+URL `/category/{site_slug}/`; URL публикации не содержит категорию и
+остаётся `/news/{slug}/`, чтобы смена категории не ломала canonical.
+Производители хранятся во внутренних Ghost tags и не попадают в sitemap
+как категории.
+
+### 23.5. Изображения и аналитика
+
+В ленте показывается только `feature_image`. Если у публикации есть
+дополнительные изображения, site-poster должен добавить их в тело статьи
+нативной Ghost Gallery; тема поддерживает плитку и полноэкранный просмотр.
+
+Перед загрузкой изображения нужно:
+
+- применить EXIF orientation и удалить метаданные;
+- ограничить размер до 2000x2000 без увеличения;
+- сохранить в WebP quality 82;
+- первое изображение назначить обложкой, остальные поместить в галерею.
+
+Контрольный замер на первых 12 публикациях: исходные изображения занимали
+12,09 МБ, после такой обработки — 3,18 МБ, в среднем 272 КБ на файл.
+
+В тему добавлен счётчик Яндекс Метрики `110060335` с clickmap,
+trackLinks, accurateTrackBounce и webvisor.
+Google Tag Manager подключён в общем Ghost-шаблоне с контейнером
+`GTM-MM4FR8LJ`: script находится в начале `
`, noscript iframe — сразу
+после открывающего ``.
+
+### 23.6. Ручной batch site-poster
+
+Скрипт `scripts/publish_site_batch.py` публикует явно выбранные raw-посты
+через Ghost Admin API:
+
+```bash
+PYTHONPATH=/opt/vk-parser/src .venv/bin/python \
+ scripts/publish_site_batch.py --ids 26,455
+```
+
+Скрипт:
+
+- не публикует `site_enabled=FALSE` и уже опубликованные на сайт записи;
+- скачивает сохранённые фото через локальный Telegram Bot API;
+- применяет EXIF orientation, 2000x2000, WebP quality 82;
+- назначает первое фото `feature_image`;
+- собирает остальные фото в Ghost Gallery;
+- создаёт статью через Admin API;
+- записывает результат или ошибку в `post_publications` с platform `site`.
+
+Первый боевой batch 22 июня 2026 года: 10 статей, 46 фотографий,
+все публикации успешны. Upload payload после нормализации занял около
+10,9 МБ. Ghost штатно хранит рабочую версию и исходную WebP-копию `_o`
+для последующей регенерации размеров: суммарно 92 файла / 21,12 МБ на диске.
+
+В тот же день выполнен полный photo-ready backfill. В `post_publications`
+создан backfill для 12 ранних импортов, после чего опубликован весь
+оставшийся хвост без дублей. Итог:
+
+- 310 Ghost posts / 310 `site · published`;
+- 0 `site · publish_failed`;
+- 0 оставшихся photo-ready постов;
+- 16 video-only постов опубликованы через нативные Ghost video cards;
+- 5 длинных VK-видео остаются `link_only / video too long` без Telegram
+ file_id и не публикуются, чтобы на сайте не было карточек без медиа;
+- video pipeline загружает MP4 через Ghost `/media/upload/`, извлекает
+ WebP-обложку ffmpeg и хранит ролик в нативном Lexical `video` node;
+- ролики больше 40 МБ или выше 720p перекодируются в H.264/AAC,
+ максимум 720p, target около 38 МБ, preset `fast`, `faststart`;
+- после photo backfill каталог Ghost images занимал около 453 МБ;
+ актуальный общий расход проверяется отдельно с учётом MP4.
+
+Режим полного backfill:
+
+```bash
+PYTHONPATH=/opt/vk-parser/src .venv/bin/python \
+ scripts/publish_site_batch.py --all-ready
+```
+
+Он исключает уже опубликованные записи и ранжирует очередь так, чтобы
+категории и источники реже повторялись подряд.
+
+### 23.6.1. Автоматический site-poster
+
+- worker: `src/vk_parser_app/workers/site_poster.py`;
+- systemd: `site-poster.service`;
+- двойное включение: `worker_controls.site-poster.enabled` и
+ `app_settings.site_poster_enabled`;
+- интервал по умолчанию: 300 секунд;
+- за проход: до 10 photo-ready и 1 video-only материала;
+- worker вызывает общий проверенный pipeline `scripts/publish_site_batch.py`,
+ который защищён lock-файлом `/tmp/forma-n8-site-poster.lock` от параллельных
+ ручных и автоматических запусков;
+- обычный автоматический запуск не повторяет `publish_failed`; для явного
+ ручного повтора используется флаг `--retry-failed`.
+
+### 23.7. Навигация Ghost
+
+- лента выводит 25 постов на страницу;
+- H1 главной: `Новости тактического снаряжения и экипировки`;
+- только на первой странице `/`, после ленты и пагинации, выводится
+ статический SEO-блок `О новостной ленте Forma N8` с описанием тематики,
+ категорий и производителей; на `/page/2/` и далее блок не дублируется;
+- пагинация полностью русская;
+- шапка: `Лента`, выпадающие `Категории`, `О проекте`, поиск;
+- ссылки на каналы Forma N8 (`https://vk.com/forma_n8` и `https://t.me/forma_n8`) с SVG-иконками присутствуют в шапке, каждом превью, открытой статье, разделе `О проекте` и футере;
+- описание проекта начинается с текста: «Ежедневно мы собираем новости снаряжения с сотен источников, отбираем лучшее и публикуем в одном месте!»;
+- футер: `RSS`, `Карта сайта`;
+- старые `/tag/{slug}/` перенаправляются на `/category/{slug}/`;
+- `/about/` содержит описание проекта;
+- ссылка `Первоисточник` удалена из существующих статей и не добавляется
+ новым site-poster batch.
+
+### 23.8. SEO Ghost
+
+- название сайта: `Forma N8`;
+- слоган главной: `Forma N8 — все новости снаряжения в одном месте`;
+- description: `Forma N8 — все новости снаряжения в одном месте. Новинки, обзоры и события производителей тактического, туристического и специального снаряжения.`;
+- title статьи: `Forma N8 — Название статьи`;
+- title раздела: `Forma N8 — Название раздела`;
+- meta keywords и «SEO-хештеги» не используются: категории оформляются
+ обычными Ghost tags, формирующими структуру сайта и посадочные страницы;
+- тема должна сохранять один H1, canonical, Open Graph, Twitter Cards,
+ JSON-LD, RSS и sitemap, которые формирует Ghost через `ghost_head`;
+- SSH-доступ и изменения на серверах `139.100.234.51` и
+ `109.120.156.205` выполняются через Python-библиотеку `paramiko`.
+
+доступ к серверу по ssh root@109.120.156.205 -p 22
+пароль OU8gUSCtQYcf
diff --git a/STATE.md b/STATE.md
new file mode 100644
index 0000000..adc9aa9
--- /dev/null
+++ b/STATE.md
@@ -0,0 +1,110 @@
+# N8 Parser: current state
+
+Last updated: 2026-06-17
+
+This file is the short handoff state for future Codex threads. Read this first before touching the project, so the whole chat history does not need to be carried forward.
+
+## Server
+
+- Production host: `109.120.156.205`
+- Domain: `https://sw.exostring.xyz`
+- App directory on server: `/opt/vk-parser`
+- Timezone: `Asia/Yekaterinburg`
+- Main services:
+ - `vk-parser-admin.service`
+ - `vk-parser.service`
+ - `vk-storage-uploader.service`
+ - `ai-qualifier.service`
+ - `ai-writer.service`
+ - `site-poster.service`
+- Useful commands:
+ - `cd /opt/vk-parser`
+ - `systemctl status vk-parser-admin.service ai-qualifier.service ai-writer.service`
+ - `journalctl -u ai-writer.service -n 100 --no-pager`
+ - `journalctl -u ai-qualifier.service -n 100 --no-pager`
+ - `PYTHONPATH=src .venv/bin/python -m py_compile src/vk_parser_app/admin.py src/vk_parser_app/workers/ai_writer.py`
+
+## Local Project
+
+- Local workspace: `D:\DEVELOPMENT\new_vk_parser`
+- Main app package: `src/vk_parser_app`
+- Admin routes: `src/vk_parser_app/admin.py`
+- Templates: `src/vk_parser_app/templates`
+- Workers:
+ - VK parser: `src/vk_parser_app/workers/parser.py`
+ - Telegram media/storage uploader: `src/vk_parser_app/workers/vk_storage_uploader.py`
+ - AI qualifier: `src/vk_parser_app/workers/ai_qualifier.py`
+ - AI writer: `src/vk_parser_app/workers/ai_writer.py`
+- DB migrations: `db/migrations`
+- Extended project documentation: `PROJECT.md`
+
+## Pipeline
+
+1. Sources are configured in admin.
+2. VK parser reads enabled sources and stores raw posts.
+3. Media/storage uploader sends raw post copies to Telegram storage and stores media metadata.
+4. AI qualifier scores raw posts and marks accepted/rejected/maybe.
+5. AI writer rewrites accepted posts into editor-ready drafts.
+6. Editor page allows review, editing, category/source tag selection, media upload/removal, accept/reject.
+7. Site poster automatically publishes accepted media-ready posts to Ghost.
+
+## Important Text Rules
+
+- AI writer output text must be clean: no physical hashtags at the end.
+- `raw_posts.final_text` must also stay clean.
+- Category and source hashtags are separate fields:
+ - category tag: `final_category_tag` / `rewrite_category_tag`
+ - source tag: `final_source_tag` / `rewrite_source_tag` / `sources.tag`
+- Publication preview may show hashtags by composing:
+ - clean text
+ - blank line
+ - `#category #source`
+- The editor textarea must show only clean editable text.
+- The list preview in `/editor` should show the publication preview, including composed hashtags.
+- `build_publication_text()` in `src/vk_parser_app/text_utils.py` is display/composition helper only. Do not use it before writing `final_text` to DB.
+
+## Recent Hotfix: Hashtags
+
+On 2026-06-17 the hashtag flow was fixed:
+
+- `ai_writer.py` now stores clean `final_text=$2`, not text with hashtags.
+- `admin.py` prepares:
+ - `review_text`: clean text for textarea
+ - `publication_preview_text`: composed display text with hashtags
+- editor accept/save store clean text.
+- existing DB rows with trailing `#category #source` in `final_text` were cleaned.
+- server check after fix: `remaining_hashtag_tail: 0`.
+
+## Admin Pages
+
+- `/sources`: source management.
+- `/raw`: raw post feed.
+- `/editor`: editor queue.
+- `/workers`: worker controls and settings.
+- `/prompt-test`: AI writer prompt test page.
+- `/users`: admin users.
+- `/logs`: audit/log page if enabled.
+
+## Prompt Architecture
+
+- Prompts are split into editable human part and technical contract part.
+- User should be able to edit human prompt safely without breaking JSON schema.
+- Contracts should contain JSON-only/output-schema requirements.
+- Writer should return category ID, not free-form tag, where possible.
+- Categories are managed in worker/admin settings and have stable IDs.
+
+## Security Notes
+
+- Secrets should not be committed.
+- `.env` should contain only secrets and deploy-specific values.
+- Runtime settings that are not secrets should live in DB/admin settings.
+- Admin has login protection and server has firewall/fail2ban hardening from previous iterations.
+
+## Working Rules For Future Codex Runs
+
+- Prefer small targeted file reads with `rg` and narrow ranges.
+- Avoid dumping long post texts from DB unless explicitly needed.
+- For server checks, return counts/statuses first.
+- Be careful: both local and server worktrees may be dirty.
+- Do not `git reset --hard` or revert user/server hotfixes.
+- When fixing production, patch server and then mirror the same change locally.
diff --git a/db/migrations/016_remove_regeneration_and_writer_prompt.sql b/db/migrations/016_remove_regeneration_and_writer_prompt.sql
index e60d43d..b22a050 100644
--- a/db/migrations/016_remove_regeneration_and_writer_prompt.sql
+++ b/db/migrations/016_remove_regeneration_and_writer_prompt.sql
@@ -21,8 +21,6 @@ SET value_json = to_jsonb($prompt$
Формат текста:
- 3-6 коротких строк или 2-4 компактных абзаца;
-- можно использовать 1-3 тематических эмоджи как маркеры структуры, например: 🧵 материал/конструкция, 🎒 переноска, 🛡 защита, ⚙️ детали, 📦 комплект/наличие, 🎯 назначение;
-- эмоджи должны помогать читать текст, а не украшать каждую строку;
- не добавляй ссылки и хэштеги в text.
Важное правило про источник:
diff --git a/db/migrations/019_writer_prompt_profile_style.sql b/db/migrations/019_writer_prompt_profile_style.sql
index 77e373c..0377aef 100644
--- a/db/migrations/019_writer_prompt_profile_style.sql
+++ b/db/migrations/019_writer_prompt_profile_style.sql
@@ -30,8 +30,6 @@ SET value_json = to_jsonb($prompt$
ФОРМАТ ТЕКСТА
- 3-6 коротких строк или 2-5 компактных абзацев;
-- можно использовать 1-3 тематических эмоджи как маркеры структуры, например: 🧵 материал/конструкция, 🎒 переноска, 🛡 защита, ⚙️ детали, 📦 комплект/наличие, 🎯 назначение;
-- эмоджи должны помогать читать текст, а не украшать каждую строку;
- не добавляй ссылки и хэштеги в text.
СТИЛЬ
diff --git a/db/migrations/022_audit_log_retention_and_indexes.sql b/db/migrations/022_audit_log_retention_and_indexes.sql
new file mode 100644
index 0000000..9712a9c
--- /dev/null
+++ b/db/migrations/022_audit_log_retention_and_indexes.sql
@@ -0,0 +1,15 @@
+DELETE FROM audit_log
+WHERE created_at < NOW() - INTERVAL '30 days';
+
+CREATE INDEX IF NOT EXISTS idx_audit_log_created_at
+ON audit_log(created_at DESC);
+
+CREATE INDEX IF NOT EXISTS idx_audit_log_action_time
+ON audit_log(action, created_at DESC);
+
+CREATE INDEX IF NOT EXISTS idx_audit_log_actor_time
+ON audit_log(actor_id, created_at DESC)
+WHERE actor_id IS NOT NULL;
+
+CREATE INDEX IF NOT EXISTS idx_audit_log_entity_time
+ON audit_log(entity_type, entity_id, created_at DESC);
diff --git a/db/migrations/022_sync_ai_worker_enabled.sql b/db/migrations/022_sync_ai_worker_enabled.sql
new file mode 100644
index 0000000..52cdc62
--- /dev/null
+++ b/db/migrations/022_sync_ai_worker_enabled.sql
@@ -0,0 +1,15 @@
+UPDATE app_settings s
+SET value_json = to_jsonb(wc.enabled),
+ updated_at = NOW()
+FROM worker_controls wc
+WHERE (wc.name, s.key) IN (
+ ('ai-qualifier', 'ai_qualifier_enabled'),
+ ('ai-writer', 'ai_writer_enabled')
+);
+
+UPDATE raw_posts
+SET rewrite_status='failed',
+ rewrite_notes=COALESCE(rewrite_notes, 'recovered stale writer processing state'),
+ updated_at=NOW()
+WHERE rewrite_status='processing'
+ AND updated_at < NOW() - INTERVAL '30 minutes';
diff --git a/db/migrations/023_writer_strict_separator.sql b/db/migrations/023_writer_strict_separator.sql
new file mode 100644
index 0000000..96e6199
--- /dev/null
+++ b/db/migrations/023_writer_strict_separator.sql
@@ -0,0 +1,21 @@
+-- Keep the AI-writer divider deterministic.
+-- Target separator length: 27 characters.
+
+UPDATE app_settings
+SET value_json = to_jsonb(
+ replace(
+ value_json #>> '{}',
+ '2. Черта с точным количеством символов ━━━━━━━━━━━━━━━━━━━━━━━━━━━',
+ '2. Черта-разделитель: отдельная строка ровно из 27 символов. Используй строго эту строку без изменений: ━━━━━━━━━━━━━━━━━━━━━━━━━━━'
+ )
+),
+updated_at = NOW()
+WHERE key = 'ai_writer_prompt'
+ AND (value_json #>> '{}') LIKE '%Черта с точным количеством символов%';
+
+UPDATE raw_posts
+SET rewritten_text = regexp_replace(rewritten_text, E'(^━{3,}$)', '━━━━━━━━━━━━━━━━━━━━━━━━━━━', 'gm'),
+ final_text = regexp_replace(final_text, E'(^━{3,}$)', '━━━━━━━━━━━━━━━━━━━━━━━━━━━', 'gm'),
+ updated_at = NOW()
+WHERE COALESCE(rewritten_text, '') LIKE '%━%'
+ OR COALESCE(final_text, '') LIKE '%━%';
diff --git a/db/migrations/024_tg_poster.sql b/db/migrations/024_tg_poster.sql
new file mode 100644
index 0000000..e8145ea
--- /dev/null
+++ b/db/migrations/024_tg_poster.sql
@@ -0,0 +1,93 @@
+ALTER TABLE raw_posts
+ ADD COLUMN IF NOT EXISTS publication_status TEXT NOT NULL DEFAULT 'pending',
+ ADD COLUMN IF NOT EXISTS publication_error TEXT,
+ ADD COLUMN IF NOT EXISTS published_at TIMESTAMPTZ,
+ ADD COLUMN IF NOT EXISTS tg_publication_chat_id BIGINT,
+ ADD COLUMN IF NOT EXISTS tg_publication_thread_id BIGINT,
+ ADD COLUMN IF NOT EXISTS tg_publication_message_ids BIGINT[],
+ ADD COLUMN IF NOT EXISTS tg_publication_url TEXT;
+
+CREATE INDEX IF NOT EXISTS idx_raw_posts_publication_queue
+ON raw_posts(publication_status, editorial_status, rewrite_status, published_at, reviewed_at, id);
+
+CREATE INDEX IF NOT EXISTS idx_raw_posts_publication_rank
+ON raw_posts(final_category_tag, final_source_tag, published_at DESC);
+
+CREATE TABLE IF NOT EXISTS publication_runs (
+ id BIGSERIAL PRIMARY KEY,
+ poster TEXT NOT NULL,
+ schedule_id TEXT NOT NULL,
+ scheduled_for DATE NOT NULL,
+ scheduled_time TEXT NOT NULL,
+ planned_count INTEGER NOT NULL DEFAULT 0,
+ published_count INTEGER NOT NULL DEFAULT 0,
+ status TEXT NOT NULL DEFAULT 'started',
+ error TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ UNIQUE(poster, schedule_id, scheduled_for)
+);
+
+CREATE INDEX IF NOT EXISTS idx_publication_runs_poster_date
+ON publication_runs(poster, scheduled_for DESC, scheduled_time);
+
+INSERT INTO worker_controls(name, enabled, settings_json)
+VALUES ('tg-poster', FALSE, '{}'::jsonb)
+ON CONFLICT (name) DO NOTHING;
+
+INSERT INTO app_settings(key, value_json, value_type, title, description, category)
+VALUES
+ ('tg_poster_enabled', 'false'::jsonb, 'bool', 'TG poster enabled', 'Double safety flag for publishing accepted posts to Telegram.', 'TG Poster'),
+ ('tg_poster_bot_token', '""'::jsonb, 'secret', 'TG poster bot token', 'Bot token for final Telegram publication. For test can be the same as upload bot token.', 'TG Poster'),
+ ('tg_poster_chat_id', '"-1003712724327"'::jsonb, 'str', 'TG publication chat id', 'Target Telegram channel/group id. Supports chat_id:thread_id for topics.', 'TG Poster'),
+ ('tg_poster_schedule_json', '[{"id":"morning","time":"10:00","count":1,"enabled":true},{"id":"evening","time":"18:00","count":1,"enabled":true}]'::jsonb, 'json', 'TG publication schedule', 'EKB time schedule rows: id, time HH:MM, count, enabled.', 'TG Poster'),
+ ('tg_poster_interval_sec', '60'::jsonb, 'int', 'TG poster interval, sec', 'Pause between poster checks.', 'TG Poster'),
+ ('tg_poster_media_group_max_items', '10'::jsonb, 'int', 'TG media group max items', 'Telegram media group supports 2..10 media items.', 'TG Poster'),
+ ('tg_poster_caption_limit', '1024'::jsonb, 'int', 'TG caption limit', 'Telegram caption limit for media messages.', 'TG Poster'),
+ ('tg_poster_message_limit', '4096'::jsonb, 'int', 'TG message limit', 'Telegram text message limit.', 'TG Poster'),
+ ('tg_poster_max_attempts', '3'::jsonb, 'int', 'TG post retry attempts', 'Retries for Telegram API errors.', 'TG Poster'),
+ ('tg_poster_retry_backoff_max_sec', '30'::jsonb, 'int', 'TG retry backoff max, sec', 'Max retry sleep for Telegram API errors.', 'TG Poster'),
+ ('tg_poster_send_delay_sec', '1'::jsonb, 'float', 'TG send delay, sec', 'Delay between Telegram sends.', 'TG Poster'),
+ ('tg_poster_text_overflow_caption', '"⬇️ Описание под медиа"'::jsonb, 'str', 'TG overflow caption', 'Caption used when full text is longer than Telegram media caption limit.', 'TG Poster'),
+ ('tg_poster_recent_window', '20'::jsonb, 'int', 'Ranking recent window', 'How many published posts are checked to reduce repeated categories and sources.', 'TG Poster'),
+ ('tg_poster_category_repeat_penalty', '3'::jsonb, 'float', 'Category repeat penalty', 'Ranking penalty for recently published same category.', 'TG Poster'),
+ ('tg_poster_source_repeat_penalty', '4'::jsonb, 'float', 'Source repeat penalty', 'Ranking penalty for recently published same source.', 'TG Poster')
+ON CONFLICT (key) DO NOTHING;
+
+UPDATE app_settings
+SET description='Обрезает исходный raw-текст перед отправкой в LLM. Итоговый rewrite должен быть компактным: Telegram caption 1024 символа минус будущая строка хэштегов.'
+WHERE key='ai_writer_max_text_chars';
+
+UPDATE app_settings
+SET value_json = to_jsonb($contract$
+OUTPUT SCHEMA (return array matching input order):
+{"rewrites":[{"id":123,"category_id":2,"text":"готовый текст без хэштегов","notes":"короткая заметка для редактора или null"}]}
+
+Rules:
+- Input is a JSON object with key "posts" containing accepted posts.
+- Input has key "categories" with objects: id, name, tag.
+- Each post includes producer_name and producer_tag. producer_name can be a manufacturer, shop, or publishing source.
+- Pick exactly one category from the categories list in input and return its numeric id as category_id.
+- Do not add links or hashtags to rewritten text.
+- Keep text compact enough for Telegram media caption: target 850 characters, hard maximum 900 characters.
+- Remember the system appends category and source hashtags after text, so leave space for them.
+- Return JSON only. No markdown. No text outside JSON.
+- Never return an empty object. Never omit the "rewrites" key.
+- Return one rewrite for every input post id.
+- The "text" field must be a ready-to-publish Russian post with normal paragraph line breaks encoded as JSON string newlines.
+- The "notes" field must be a short editor note in Russian or null.
+$contract$::text),
+updated_at = NOW()
+WHERE key='ai_writer_contract';
+
+UPDATE app_settings
+SET value_json = to_jsonb(
+ regexp_replace(
+ value_json #>> '{}',
+ '- 3-6 коротких строк или 2-5 компактных абзацев;',
+ '- 3-6 коротких строк или 2-4 компактных абзаца; итоговый text должен целиться в 850 символов и не превышать 900 символов, потому что система добавит хэштеги и Telegram caption ограничен 1024 символами;',
+ 'g'
+ )
+)
+WHERE key='ai_writer_prompt'
+ AND (value_json #>> '{}') LIKE '%3-6 коротких строк или 2-5 компактных абзацев%';
diff --git a/db/migrations/025_tg_poster_hourly_schedule.sql b/db/migrations/025_tg_poster_hourly_schedule.sql
new file mode 100644
index 0000000..880f022
--- /dev/null
+++ b/db/migrations/025_tg_poster_hourly_schedule.sql
@@ -0,0 +1,46 @@
+UPDATE app_settings
+SET value_json = '"⬇️ Описание"'::jsonb,
+ updated_at = NOW()
+WHERE key='tg_poster_text_overflow_caption';
+
+UPDATE app_settings
+SET value_json = $schedule$[
+ {"id":"hour-00","time":"00:00","count":4,"enabled":true},
+ {"id":"hour-01","time":"01:00","count":4,"enabled":true},
+ {"id":"hour-02","time":"02:00","count":4,"enabled":true},
+ {"id":"hour-03","time":"03:00","count":4,"enabled":true},
+ {"id":"hour-04","time":"04:00","count":4,"enabled":true},
+ {"id":"hour-05","time":"05:00","count":4,"enabled":true},
+ {"id":"hour-06","time":"06:00","count":4,"enabled":true},
+ {"id":"hour-07","time":"07:00","count":4,"enabled":true},
+ {"id":"hour-08","time":"08:00","count":4,"enabled":true},
+ {"id":"hour-09","time":"09:00","count":4,"enabled":true},
+ {"id":"hour-10","time":"10:00","count":4,"enabled":true},
+ {"id":"hour-11","time":"11:00","count":4,"enabled":true},
+ {"id":"hour-12","time":"12:00","count":4,"enabled":true},
+ {"id":"hour-13","time":"13:00","count":4,"enabled":true},
+ {"id":"hour-14","time":"14:00","count":4,"enabled":true},
+ {"id":"hour-15","time":"15:00","count":4,"enabled":true},
+ {"id":"hour-16","time":"16:00","count":4,"enabled":true},
+ {"id":"hour-17","time":"17:00","count":4,"enabled":true},
+ {"id":"hour-18","time":"18:00","count":4,"enabled":true},
+ {"id":"hour-19","time":"19:00","count":4,"enabled":true},
+ {"id":"hour-20","time":"20:00","count":4,"enabled":true},
+ {"id":"hour-21","time":"21:00","count":4,"enabled":true},
+ {"id":"hour-22","time":"22:00","count":4,"enabled":true},
+ {"id":"hour-23","time":"23:00","count":4,"enabled":true}
+]$schedule$::jsonb,
+ updated_at = NOW()
+WHERE key='tg_poster_schedule_json';
+
+UPDATE raw_posts
+SET editorial_status='published',
+ updated_at=NOW()
+WHERE publication_status='published'
+ AND editorial_status='accepted';
+
+UPDATE raw_posts
+SET editorial_status='publish_failed',
+ updated_at=NOW()
+WHERE publication_status='publish_failed'
+ AND editorial_status='accepted';
diff --git a/db/migrations/026_writer_separator_17_chars.sql b/db/migrations/026_writer_separator_17_chars.sql
new file mode 100644
index 0000000..a415f53
--- /dev/null
+++ b/db/migrations/026_writer_separator_17_chars.sql
@@ -0,0 +1,44 @@
+-- Keep the AI-writer divider deterministic.
+-- Target separator length: 17 characters.
+
+UPDATE app_settings
+SET value_json = to_jsonb(
+ regexp_replace(
+ value_json #>> '{}',
+ 'Черта-разделитель:[^\n]*',
+ 'Черта-разделитель: отдельная строка ровно из 17 символов. Используй строго эту строку без изменений: ━━━━━━━━━━━━━━━━━',
+ 'g'
+ )
+),
+updated_at = NOW()
+WHERE key = 'ai_writer_prompt'
+ AND (value_json #>> '{}') LIKE '%Черта-разделитель%';
+
+UPDATE app_settings
+SET value_json = to_jsonb(
+ CASE
+ WHEN (value_json #>> '{}') LIKE '%Separator line must be exactly%'
+ THEN regexp_replace(
+ value_json #>> '{}',
+ 'Separator line must be exactly[^\n]*',
+ 'Separator line must be exactly this 17-character line when used: ━━━━━━━━━━━━━━━━━',
+ 'g'
+ )
+ ELSE (value_json #>> '{}') || E'\n- Separator line must be exactly this 17-character line when used: ━━━━━━━━━━━━━━━━━'
+ END
+),
+updated_at = NOW()
+WHERE key = 'ai_writer_contract';
+
+UPDATE raw_posts
+SET rewritten_text = CASE
+ WHEN rewritten_text IS NULL THEN NULL
+ ELSE regexp_replace(rewritten_text, E'^[━─—-]{3,}\\s*$', '━━━━━━━━━━━━━━━━━', 'gm')
+ END,
+ final_text = CASE
+ WHEN final_text IS NULL THEN NULL
+ ELSE regexp_replace(final_text, E'^[━─—-]{3,}\\s*$', '━━━━━━━━━━━━━━━━━', 'gm')
+ END,
+ updated_at = NOW()
+WHERE COALESCE(rewritten_text, '') ~ E'(?m)^[━─—-]{3,}\\s*$'
+ OR COALESCE(final_text, '') ~ E'(?m)^[━─—-]{3,}\\s*$';
diff --git a/db/migrations/027_raw_manual_rewrite_and_nck_reject.sql b/db/migrations/027_raw_manual_rewrite_and_nck_reject.sql
new file mode 100644
index 0000000..714f855
--- /dev/null
+++ b/db/migrations/027_raw_manual_rewrite_and_nck_reject.sql
@@ -0,0 +1,34 @@
+-- Manual Raw-to-rewrite flow is implemented in admin.py.
+-- Non-target category (sort_order/category_id 18, tag НЦК) must not enter editorial review.
+
+UPDATE app_settings
+SET value_json = to_jsonb(
+ CASE
+ WHEN (value_json #>> '{}') LIKE '%category_id 18%'
+ THEN value_json #>> '{}'
+ ELSE (value_json #>> '{}') || E'\n- If the post is non-target content, choose category_id 18 ("Не целевой контент", tag "НЦК"); the app will reject it automatically.'
+ END
+),
+updated_at = NOW()
+WHERE key = 'ai_writer_contract';
+
+UPDATE raw_posts
+SET editorial_status='rejected',
+ qualification_status='rejected',
+ qualification_decision='rejected',
+ qualification_model_decision='rejected',
+ qualification_reason=COALESCE(NULLIF(qualification_reason, ''), 'AI writer selected non-target category'),
+ qualification_reject_tag=COALESCE(NULLIF(qualification_reject_tag, ''), 'non_target_content'),
+ editor_notes=COALESCE(NULLIF(editor_notes, ''), 'Отклонено AI: Не целевой контент'),
+ reviewed_at=COALESCE(reviewed_at, NOW()),
+ updated_at=NOW()
+WHERE rewrite_status='ready'
+ AND COALESCE(editorial_status, 'review')='review'
+ AND (
+ rewrite_category_id=18
+ OR final_category_id=18
+ OR lower(COALESCE(rewrite_category_tag, '')) IN ('нцк', 'nck')
+ OR lower(COALESCE(final_category_tag, '')) IN ('нцк', 'nck')
+ OR lower(COALESCE(rewrite_category, '')) = lower('Не целевой контент')
+ OR lower(COALESCE(final_category, '')) = lower('Не целевой контент')
+ );
diff --git a/db/migrations/028_vk_poster_and_platform_publications.sql b/db/migrations/028_vk_poster_and_platform_publications.sql
new file mode 100644
index 0000000..08706a1
--- /dev/null
+++ b/db/migrations/028_vk_poster_and_platform_publications.sql
@@ -0,0 +1,67 @@
+CREATE TABLE IF NOT EXISTS post_publications (
+ id BIGSERIAL PRIMARY KEY,
+ raw_post_id BIGINT NOT NULL REFERENCES raw_posts(id) ON DELETE CASCADE,
+ platform TEXT NOT NULL,
+ status TEXT NOT NULL DEFAULT 'pending',
+ target_id TEXT,
+ external_id TEXT,
+ url TEXT,
+ error TEXT,
+ attachments_json JSONB NOT NULL DEFAULT '[]'::jsonb,
+ published_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ UNIQUE(raw_post_id, platform)
+);
+
+CREATE INDEX IF NOT EXISTS idx_post_publications_platform_status
+ON post_publications(platform, status, target_id, published_at DESC);
+
+CREATE INDEX IF NOT EXISTS idx_post_publications_raw_post
+ON post_publications(raw_post_id);
+
+INSERT INTO post_publications(raw_post_id, platform, status, target_id, external_id, url, error, attachments_json, published_at, updated_at)
+SELECT
+ id,
+ 'tg',
+ publication_status,
+ tg_publication_chat_id::text,
+ CASE WHEN tg_publication_message_ids IS NULL THEN NULL ELSE array_to_string(tg_publication_message_ids, ',') END,
+ tg_publication_url,
+ publication_error,
+ COALESCE(to_jsonb(tg_publication_message_ids), '[]'::jsonb),
+ published_at,
+ NOW()
+FROM raw_posts
+WHERE COALESCE(publication_status, 'pending') <> 'pending'
+ON CONFLICT (raw_post_id, platform) DO UPDATE
+SET status=EXCLUDED.status,
+ target_id=EXCLUDED.target_id,
+ external_id=EXCLUDED.external_id,
+ url=EXCLUDED.url,
+ error=EXCLUDED.error,
+ attachments_json=EXCLUDED.attachments_json,
+ published_at=EXCLUDED.published_at,
+ updated_at=NOW();
+
+INSERT INTO worker_controls(name, enabled, settings_json)
+VALUES ('vk-poster', FALSE, '{}'::jsonb)
+ON CONFLICT (name) DO NOTHING;
+
+INSERT INTO app_settings(key, value_json, value_type, title, description, category)
+VALUES
+ ('vk_poster_enabled', 'false'::jsonb, 'bool', 'VK poster enabled', 'Double safety flag for publishing accepted posts to VK.', 'VK Poster'),
+ ('vk_poster_access_token', '""'::jsonb, 'secret', 'VK poster access token', 'Group/user token with wall.post and photos permissions. If empty, env VK_GROUP_ACCESS_TOKEN/VK_ACCESS_TOKEN is used.', 'VK Poster'),
+ ('vk_poster_owner_id', '0'::jsonb, 'int', 'VK target owner_id', 'Target VK group owner_id, usually negative: -123456789.', 'VK Poster'),
+ ('vk_poster_from_group', 'true'::jsonb, 'bool', 'Post from group', 'Use from_group=1 when publishing to a community wall.', 'VK Poster'),
+ ('vk_poster_schedule_json', '[{"id":"vk-morning","time":"10:05","count":1,"enabled":true},{"id":"vk-evening","time":"18:05","count":1,"enabled":true}]'::jsonb, 'json', 'VK publication schedule', 'EKB time schedule rows: id, time HH:MM, count, enabled.', 'VK Poster'),
+ ('vk_poster_interval_sec', '60'::jsonb, 'int', 'VK poster interval, sec', 'Pause between poster checks.', 'VK Poster'),
+ ('vk_poster_timeout_sec', '90'::jsonb, 'int', 'VK API timeout, sec', 'Total timeout for VK API calls and media uploads.', 'VK Poster'),
+ ('vk_poster_max_attempts', '3'::jsonb, 'int', 'VK post retry attempts', 'Retries for VK API/network errors.', 'VK Poster'),
+ ('vk_poster_retry_backoff_max_sec', '30'::jsonb, 'int', 'VK retry backoff max, sec', 'Max retry sleep for VK API/network errors.', 'VK Poster'),
+ ('vk_poster_send_delay_sec', '1'::jsonb, 'float', 'VK send delay, sec', 'Delay between VK posts.', 'VK Poster'),
+ ('vk_poster_recent_window', '20'::jsonb, 'int', 'Ranking recent window', 'How many VK-published posts are checked to reduce repeated categories and sources.', 'VK Poster'),
+ ('vk_poster_category_repeat_penalty', '3'::jsonb, 'float', 'Category repeat penalty', 'Ranking penalty for recently published same category.', 'VK Poster'),
+ ('vk_poster_source_repeat_penalty', '4'::jsonb, 'float', 'Source repeat penalty', 'Ranking penalty for recently published same source.', 'VK Poster'),
+ ('vk_poster_dry_run', 'false'::jsonb, 'bool', 'VK dry run', 'If true, prepares attachments but does not call wall.post. Keep false for production.', 'VK Poster')
+ON CONFLICT (key) DO NOTHING;
diff --git a/db/migrations/029_vk_poster_oauth_settings.sql b/db/migrations/029_vk_poster_oauth_settings.sql
new file mode 100644
index 0000000..e24de00
--- /dev/null
+++ b/db/migrations/029_vk_poster_oauth_settings.sql
@@ -0,0 +1,5 @@
+INSERT INTO app_settings(key, value_json, value_type, title, description, category)
+VALUES
+ ('vk_poster_app_id', '""'::jsonb, 'str', 'VK OAuth app id', 'VK application id used for user OAuth code flow.', 'VK Poster'),
+ ('vk_poster_client_secret', '""'::jsonb, 'secret', 'VK OAuth client secret', 'Protected key from VK application settings. Used server-side to exchange code for user access token.', 'VK Poster')
+ON CONFLICT (key) DO NOTHING;
diff --git a/db/migrations/030_vk_poster_refresh_token.sql b/db/migrations/030_vk_poster_refresh_token.sql
new file mode 100644
index 0000000..db4b6f3
--- /dev/null
+++ b/db/migrations/030_vk_poster_refresh_token.sql
@@ -0,0 +1,6 @@
+INSERT INTO app_settings(key, value_json, value_type, title, description, category)
+VALUES
+ ('vk_poster_refresh_token', '""'::jsonb, 'secret', 'VK OAuth refresh token', 'Refresh token from VK ID code flow. Used by VK poster to renew access token automatically.', 'VK Poster'),
+ ('vk_poster_token_device_id', '""'::jsonb, 'str', 'VK OAuth device id', 'VK ID device_id returned with authorization code/token exchange.', 'VK Poster'),
+ ('vk_poster_token_expires_at', '""'::jsonb, 'str', 'VK access token expires at', 'UTC ISO timestamp used by VK poster to refresh the access token before expiry.', 'VK Poster')
+ON CONFLICT (key) DO NOTHING;
diff --git a/db/migrations/031_writer_require_producer_name.sql b/db/migrations/031_writer_require_producer_name.sql
new file mode 100644
index 0000000..94ca386
--- /dev/null
+++ b/db/migrations/031_writer_require_producer_name.sql
@@ -0,0 +1,18 @@
+-- Make producer mention mandatory in every AI writer result.
+
+UPDATE app_settings
+SET value_json = to_jsonb(
+ CASE
+ WHEN (value_json #>> '{}') LIKE '%explicitly mention producer_name%'
+ OR (value_json #>> '{}') LIKE '%явно упоминает producer_name%'
+ THEN value_json #>> '{}'
+ ELSE replace(
+ value_json #>> '{}',
+ '- Each post includes producer_name and producer_tag. producer_name can be a manufacturer, shop, or publishing source.',
+ '- Each post includes producer_name and producer_tag. producer_name can be a manufacturer, shop, or publishing source.' || E'\n' ||
+ '- The "text" field for every rewrite must explicitly mention producer_name at least once. Do not satisfy this only through producer_tag, hashtags, notes, or metadata.'
+ )
+ END
+),
+updated_at = NOW()
+WHERE key = 'ai_writer_contract';
diff --git a/db/migrations/032_tg_reactor.sql b/db/migrations/032_tg_reactor.sql
new file mode 100644
index 0000000..b99e6a6
--- /dev/null
+++ b/db/migrations/032_tg_reactor.sql
@@ -0,0 +1,39 @@
+CREATE TABLE IF NOT EXISTS post_reactions (
+ id BIGSERIAL PRIMARY KEY,
+ publication_id BIGINT NOT NULL REFERENCES post_publications(id) ON DELETE CASCADE,
+ raw_post_id BIGINT NOT NULL REFERENCES raw_posts(id) ON DELETE CASCADE,
+ platform TEXT NOT NULL,
+ reactor_key TEXT NOT NULL,
+ reaction TEXT NOT NULL,
+ target_id TEXT NOT NULL,
+ message_id TEXT NOT NULL,
+ status TEXT NOT NULL DEFAULT 'pending',
+ error TEXT,
+ attempts INTEGER NOT NULL DEFAULT 0,
+ reacted_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ UNIQUE(publication_id, reactor_key, message_id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_post_reactions_status
+ON post_reactions(platform, status, updated_at);
+
+CREATE INDEX IF NOT EXISTS idx_post_reactions_raw_post
+ON post_reactions(raw_post_id);
+
+INSERT INTO worker_controls(name, enabled, settings_json)
+VALUES ('tg-reactor', FALSE, '{}'::jsonb)
+ON CONFLICT (name) DO NOTHING;
+
+INSERT INTO app_settings(key, value_json, value_type, title, description, category)
+VALUES
+ ('tg_reactor_enabled', 'false'::jsonb, 'bool', 'TG reactor enabled', 'Double safety flag for Telegram reaction worker.', 'TG Reactor'),
+ ('tg_reactor_bot_tokens', '""'::jsonb, 'secret', 'TG reactor bot tokens', 'One or more Telegram bot tokens separated by comma/newline. If empty, uses tg_poster_bot_token/TG_BOT_TOKEN.', 'TG Reactor'),
+ ('tg_reactor_reactions_json', '["👍"]'::jsonb, 'json', 'TG reactions', 'Emoji reactions. One bot can set one reaction; multiple tokens can set multiple reactions.', 'TG Reactor'),
+ ('tg_reactor_delay_sec', '60'::jsonb, 'int', 'Delay after publication, sec', 'How long to wait after TG publication before reacting.', 'TG Reactor'),
+ ('tg_reactor_interval_sec', '60'::jsonb, 'int', 'TG reactor interval, sec', 'Pause between reaction worker checks.', 'TG Reactor'),
+ ('tg_reactor_react_all_messages', 'false'::jsonb, 'bool', 'React to all media messages', 'If false, reacts only to the first message of each published post.', 'TG Reactor'),
+ ('tg_reactor_max_attempts', '3'::jsonb, 'int', 'TG reaction retry attempts', 'Retries per publication/message/reactor.', 'TG Reactor'),
+ ('tg_reactor_retry_backoff_max_sec', '30'::jsonb, 'int', 'TG reaction retry backoff max, sec', 'Max sleep for Telegram reaction retry errors.', 'TG Reactor')
+ON CONFLICT (key) DO NOTHING;
diff --git a/db/migrations/033_tg_reactor_since.sql b/db/migrations/033_tg_reactor_since.sql
new file mode 100644
index 0000000..b96ae14
--- /dev/null
+++ b/db/migrations/033_tg_reactor_since.sql
@@ -0,0 +1,4 @@
+INSERT INTO app_settings(key, value_json, value_type, title, description, category)
+VALUES
+ ('tg_reactor_since', '""'::jsonb, 'str', 'React only since', 'UTC ISO timestamp. If set, TG reactor ignores publications older than this moment.', 'TG Reactor')
+ON CONFLICT (key) DO NOTHING;
diff --git a/db/migrations/034_tg_reactor_reaction_pause.sql b/db/migrations/034_tg_reactor_reaction_pause.sql
new file mode 100644
index 0000000..ff6098e
--- /dev/null
+++ b/db/migrations/034_tg_reactor_reaction_pause.sql
@@ -0,0 +1,4 @@
+INSERT INTO app_settings(key, value_json, value_type, title, description, category)
+VALUES
+ ('tg_reactor_reaction_pause_sec', '2'::jsonb, 'int', 'Pause between TG reactions, sec', 'Small pause after each successful reaction to avoid a burst of Telegram API calls.', 'TG Reactor')
+ON CONFLICT (key) DO NOTHING;
diff --git a/db/migrations/035_uploader_video_format_limits.sql b/db/migrations/035_uploader_video_format_limits.sql
new file mode 100644
index 0000000..67a2ac3
--- /dev/null
+++ b/db/migrations/035_uploader_video_format_limits.sql
@@ -0,0 +1,14 @@
+INSERT INTO app_settings(key, value_json, value_type, title, description, category)
+VALUES
+ ('video_max_height', '720'::jsonb, 'int', 'Video max height', 'Maximum VK video height selected by yt-dlp before uploading to Telegram.', 'Uploader')
+ON CONFLICT (key) DO NOTHING;
+
+UPDATE app_settings
+SET value_json='600'::jsonb,
+ updated_at=NOW()
+WHERE key='uploader_yt_dlp_timeout_sec'
+ AND CASE
+ WHEN jsonb_typeof(value_json) = 'number' THEN (value_json #>> '{}')::int
+ WHEN jsonb_typeof(value_json) = 'string' AND (value_json #>> '{}') ~ '^\d+$' THEN (value_json #>> '{}')::int
+ ELSE 0
+ END < 600;
diff --git a/db/migrations/036_daily_report.sql b/db/migrations/036_daily_report.sql
new file mode 100644
index 0000000..fe0a41d
--- /dev/null
+++ b/db/migrations/036_daily_report.sql
@@ -0,0 +1,13 @@
+INSERT INTO worker_controls(name, enabled, settings_json)
+VALUES ('daily-report', TRUE, '{}'::jsonb)
+ON CONFLICT (name) DO NOTHING;
+
+INSERT INTO app_settings(key, value_json, value_type, title, description, category)
+VALUES
+ ('daily_report_enabled', 'true'::jsonb, 'bool', 'Daily report enabled', 'Double safety flag for daily Telegram operational report.', 'Daily Report'),
+ ('daily_report_bot_token', '""'::jsonb, 'secret', 'Daily report bot token', 'Telegram bot token for reports. If empty, uses tg_poster_bot_token or TG_BOT_TOKEN.', 'Daily Report'),
+ ('daily_report_recipient_ids', '[442509142]'::jsonb, 'json', 'Daily report recipients', 'Telegram user/chat IDs that receive the daily report.', 'Daily Report'),
+ ('daily_report_time', '"00:04"'::jsonb, 'str', 'Daily report time', 'EKB time HH:MM when yesterday report is sent.', 'Daily Report'),
+ ('daily_report_interval_sec', '60'::jsonb, 'int', 'Daily report interval, sec', 'Pause between checks.', 'Daily Report'),
+ ('daily_report_last_sent_date', '""'::jsonb, 'str', 'Last sent report date', 'Internal YYYY-MM-DD report date already sent automatically.', 'Daily Report')
+ON CONFLICT (key) DO NOTHING;
diff --git a/db/migrations/037_site_category_fields.sql b/db/migrations/037_site_category_fields.sql
new file mode 100644
index 0000000..26f7f00
--- /dev/null
+++ b/db/migrations/037_site_category_fields.sql
@@ -0,0 +1,57 @@
+ALTER TABLE content_categories
+ ADD COLUMN IF NOT EXISTS site_name TEXT,
+ ADD COLUMN IF NOT EXISTS site_slug TEXT,
+ ADD COLUMN IF NOT EXISTS site_enabled BOOLEAN NOT NULL DEFAULT TRUE;
+
+UPDATE content_categories
+SET site_name = CASE sort_order
+ WHEN 1 THEN 'Одежда'
+ WHEN 2 THEN 'Снаряжение'
+ WHEN 3 THEN 'Броня'
+ WHEN 4 THEN 'Аксессуары'
+ WHEN 5 THEN 'Связь'
+ WHEN 6 THEN 'Патчи'
+ WHEN 7 THEN 'Навигация'
+ WHEN 8 THEN 'Прицелы'
+ WHEN 9 THEN 'Ночные приборы'
+ WHEN 10 THEN 'Медицина'
+ WHEN 11 THEN 'Свет'
+ WHEN 12 THEN 'Электроника'
+ WHEN 13 THEN 'Ножи'
+ WHEN 14 THEN 'Оружейный обвес'
+ WHEN 15 THEN 'Практика'
+ WHEN 16 THEN 'Теория'
+ WHEN 17 THEN 'Пушки'
+ WHEN 18 THEN 'НЦК'
+ ELSE initcap(replace(tag, '_', ' '))
+ END,
+ site_slug = CASE sort_order
+ WHEN 1 THEN 'odezhda'
+ WHEN 2 THEN 'snaryazhenie'
+ WHEN 3 THEN 'bronya'
+ WHEN 4 THEN 'aksessuary'
+ WHEN 5 THEN 'svyaz'
+ WHEN 6 THEN 'patchi'
+ WHEN 7 THEN 'navigatsiya'
+ WHEN 8 THEN 'pricely'
+ WHEN 9 THEN 'nochnye-pribory'
+ WHEN 10 THEN 'medicina'
+ WHEN 11 THEN 'svet'
+ WHEN 12 THEN 'elektronika'
+ WHEN 13 THEN 'nozhi'
+ WHEN 14 THEN 'oruzheynyy-obves'
+ WHEN 15 THEN 'praktika'
+ WHEN 16 THEN 'teoriya'
+ WHEN 17 THEN 'pushki'
+ WHEN 18 THEN 'nck'
+ ELSE site_slug
+ END,
+ site_enabled = sort_order <> 18,
+ updated_at = NOW();
+
+ALTER TABLE content_categories
+ ALTER COLUMN site_name SET NOT NULL,
+ ALTER COLUMN site_slug SET NOT NULL;
+
+CREATE UNIQUE INDEX IF NOT EXISTS idx_content_categories_site_slug
+ON content_categories(site_slug);
diff --git a/db/migrations/038_site_poster.sql b/db/migrations/038_site_poster.sql
new file mode 100644
index 0000000..3c761cb
--- /dev/null
+++ b/db/migrations/038_site_poster.sql
@@ -0,0 +1,13 @@
+INSERT INTO worker_controls(name, enabled, settings_json)
+VALUES ('site-poster', TRUE, '{}'::jsonb)
+ON CONFLICT (name) DO NOTHING;
+
+INSERT INTO app_settings(key, value_json, value_type, title, description, category)
+VALUES
+ ('site_poster_enabled', 'true'::jsonb, 'bool', 'Site poster enabled', 'Double safety flag for automatic Ghost publishing.', 'Site Poster'),
+ ('site_poster_interval_sec', '300'::jsonb, 'int', 'Site poster interval, sec', 'Pause between automatic queue checks.', 'Site Poster'),
+ ('site_poster_photo_batch_size', '10'::jsonb, 'int', 'Photo batch size', 'Maximum photo-ready posts per automatic run.', 'Site Poster'),
+ ('site_poster_video_batch_size', '1'::jsonb, 'int', 'Video batch size', 'Maximum video-only posts per automatic run.', 'Site Poster'),
+ ('site_poster_ghost_url', '"https://f-n8.ru"'::jsonb, 'str', 'Ghost URL', 'Target Ghost public URL.', 'Site Poster'),
+ ('site_poster_key_file', '"/opt/vk-parser/.site-poster-key"'::jsonb, 'str', 'Ghost key file', 'Server path to the Ghost Admin API key.', 'Site Poster')
+ON CONFLICT (key) DO NOTHING;
diff --git a/db/migrations/039_max_poster.sql b/db/migrations/039_max_poster.sql
new file mode 100644
index 0000000..d36ab8c
--- /dev/null
+++ b/db/migrations/039_max_poster.sql
@@ -0,0 +1,28 @@
+INSERT INTO worker_controls(name, enabled, settings_json)
+VALUES ('max-poster', FALSE, '{}'::jsonb)
+ON CONFLICT (name) DO NOTHING;
+
+INSERT INTO app_settings(key, value_json, value_type, title, description, category)
+VALUES
+ ('max_poster_enabled', 'false'::jsonb, 'bool', 'MAX poster enabled', 'Double safety flag for publishing accepted posts to MAX.', 'MAX Poster'),
+ ('max_poster_bot_token', '""'::jsonb, 'secret', 'MAX poster bot token', 'Bot access token for MAX API. Keep empty in git and set on server.', 'MAX Poster'),
+ ('max_poster_chat_id', '""'::jsonb, 'str', 'MAX target chat id', 'Target MAX channel/group chat_id.', 'MAX Poster'),
+ ('max_poster_api_base_url', '"https://platform-api2.max.ru"'::jsonb, 'str', 'MAX API base URL', 'MAX Bot API base URL. Since 2026 use platform-api2.max.ru.', 'MAX Poster'),
+ ('max_poster_schedule_json', '[{"id":"hour-00","time":"00:00","count":4,"enabled":true},{"id":"hour-01","time":"01:00","count":4,"enabled":true},{"id":"hour-02","time":"02:00","count":4,"enabled":true},{"id":"hour-03","time":"03:00","count":4,"enabled":true},{"id":"hour-04","time":"04:00","count":4,"enabled":true},{"id":"hour-05","time":"05:00","count":4,"enabled":true},{"id":"hour-06","time":"06:00","count":4,"enabled":true},{"id":"hour-07","time":"07:00","count":4,"enabled":true},{"id":"hour-08","time":"08:00","count":4,"enabled":true},{"id":"hour-09","time":"09:00","count":4,"enabled":true},{"id":"hour-10","time":"10:00","count":4,"enabled":true},{"id":"hour-11","time":"11:00","count":4,"enabled":true},{"id":"hour-12","time":"12:00","count":4,"enabled":true},{"id":"hour-13","time":"13:00","count":4,"enabled":true},{"id":"hour-14","time":"14:00","count":4,"enabled":true},{"id":"hour-15","time":"15:00","count":4,"enabled":true},{"id":"hour-16","time":"16:00","count":4,"enabled":true},{"id":"hour-17","time":"17:00","count":4,"enabled":true},{"id":"hour-18","time":"18:00","count":4,"enabled":true},{"id":"hour-19","time":"19:00","count":4,"enabled":true},{"id":"hour-20","time":"20:00","count":4,"enabled":true},{"id":"hour-21","time":"21:00","count":4,"enabled":true},{"id":"hour-22","time":"22:00","count":4,"enabled":true},{"id":"hour-23","time":"23:00","count":4,"enabled":true}]'::jsonb, 'json', 'MAX publication schedule', 'EKB time schedule rows: id, time HH:MM, count, enabled. Defaults to the TG hourly schedule.', 'MAX Poster'),
+ ('max_poster_interval_sec', '60'::jsonb, 'int', 'MAX poster interval, sec', 'Pause between poster checks.', 'MAX Poster'),
+ ('max_poster_message_limit', '4000'::jsonb, 'int', 'MAX message limit', 'MAX text message limit.', 'MAX Poster'),
+ ('max_poster_media_max_items', '10'::jsonb, 'int', 'MAX media max items', 'Maximum media attachments per MAX message.', 'MAX Poster'),
+ ('max_poster_media_process_delay_sec', '3'::jsonb, 'float', 'MAX media process delay, sec', 'Pause after media upload before sending the message.', 'MAX Poster'),
+ ('max_poster_video_ready_attempts', '5'::jsonb, 'int', 'MAX video ready attempts', 'Retries for sending a message while MAX is still processing uploaded video.', 'MAX Poster'),
+ ('max_poster_video_ready_delay_sec', '10'::jsonb, 'float', 'MAX video ready delay, sec', 'Initial retry delay while MAX is processing uploaded video. Delay doubles after each attempt.', 'MAX Poster'),
+ ('max_poster_max_attempts', '3'::jsonb, 'int', 'MAX post retry attempts', 'Retries for MAX API/network errors and attachment processing.', 'MAX Poster'),
+ ('max_poster_retry_backoff_max_sec', '30'::jsonb, 'int', 'MAX retry backoff max, sec', 'Max retry sleep for MAX API/network errors.', 'MAX Poster'),
+ ('max_poster_send_delay_sec', '1'::jsonb, 'float', 'MAX send delay, sec', 'Delay between MAX sends.', 'MAX Poster'),
+ ('max_poster_recent_window', '20'::jsonb, 'int', 'Ranking recent window', 'How many MAX-published posts are checked to reduce repeated categories and sources.', 'MAX Poster'),
+ ('max_poster_category_repeat_penalty', '3'::jsonb, 'float', 'Category repeat penalty', 'Ranking penalty for recently published same category.', 'MAX Poster'),
+ ('max_poster_source_repeat_penalty', '4'::jsonb, 'float', 'Source repeat penalty', 'Ranking penalty for recently published same source.', 'MAX Poster'),
+ ('max_poster_auto_reaction_enabled', 'true'::jsonb, 'bool', 'MAX auto reaction enabled', 'Try to set a reaction after publishing. If MAX API rejects it, publication remains successful.', 'MAX Poster'),
+ ('max_poster_auto_reaction', '"👍"'::jsonb, 'str', 'MAX auto reaction', 'Emoji reaction to try after publishing.', 'MAX Poster'),
+ ('max_poster_reaction_path_template', '"/messages/{message_id}/reactions"'::jsonb, 'str', 'MAX reaction path template', 'Relative API path for reaction call if supported by MAX API.', 'MAX Poster'),
+ ('max_poster_dry_run', 'false'::jsonb, 'bool', 'MAX dry run', 'If true, prepares publication but does not call MAX send message.', 'MAX Poster')
+ON CONFLICT (key) DO NOTHING;
diff --git a/deploy/max-poster.service b/deploy/max-poster.service
new file mode 100644
index 0000000..d1a8c30
--- /dev/null
+++ b/deploy/max-poster.service
@@ -0,0 +1,15 @@
+[Unit]
+Description=VK Parser MAX Poster
+After=network.target postgresql.service
+
+[Service]
+Type=simple
+WorkingDirectory=/opt/vk-parser
+Environment=PYTHONPATH=/opt/vk-parser/src
+ExecStart=/opt/vk-parser/.venv/bin/python -m vk_parser_app.workers.max_poster
+Restart=always
+RestartSec=10
+User=root
+
+[Install]
+WantedBy=multi-user.target
diff --git a/deploy/site-poster.service b/deploy/site-poster.service
new file mode 100644
index 0000000..1874803
--- /dev/null
+++ b/deploy/site-poster.service
@@ -0,0 +1,14 @@
+[Unit]
+Description=VK Parser Site Poster
+After=network.target postgresql.service
+
+[Service]
+Type=simple
+WorkingDirectory=/opt/vk-parser
+Environment=PYTHONPATH=/opt/vk-parser/src
+ExecStart=/opt/vk-parser/.venv/bin/python -m vk_parser_app.workers.site_poster
+Restart=always
+RestartSec=10
+
+[Install]
+WantedBy=multi-user.target
diff --git a/favi.png b/favi.png
new file mode 100644
index 0000000..23a1268
Binary files /dev/null and b/favi.png differ
diff --git a/forma-n8-social-preview-v2.jpg b/forma-n8-social-preview-v2.jpg
new file mode 100644
index 0000000..d06cbd2
Binary files /dev/null and b/forma-n8-social-preview-v2.jpg differ
diff --git a/forma_n8_site/.env.example b/forma_n8_site/.env.example
new file mode 100644
index 0000000..e075a5d
--- /dev/null
+++ b/forma_n8_site/.env.example
@@ -0,0 +1,8 @@
+DATABASE_URL=postgresql://forma_n8:change-me@127.0.0.1:5432/forma_n8
+SITE_BASE_URL=http://127.0.0.1:8090
+SITE_NAME=Forma N8
+SITE_TAGLINE=Новости снаряжения
+SITE_INDEXING_ENABLED=false
+PUBLISH_API_TOKEN=change-me
+UPLOAD_DIR=/var/lib/forma-n8/uploads
+PAGE_SIZE=12
diff --git a/forma_n8_site/README.md b/forma_n8_site/README.md
new file mode 100644
index 0000000..2435175
--- /dev/null
+++ b/forma_n8_site/README.md
@@ -0,0 +1,55 @@
+# Forma N8 Site
+
+Отдельное SSR-приложение публичного сайта Forma N8. Контент и постоянные URL не зависят от будущего дизайна.
+
+## Возможности
+
+- лента новостей, страницы статей, категорий и производителей;
+- PostgreSQL со стабильными `external_id` и `slug`;
+- idempotent API публикации и отдельный upload API;
+- RSS 2.0, sitemap, robots.txt;
+- canonical, Open Graph и JSON-LD `NewsArticle`;
+- системный деплой через nginx + systemd.
+
+## Локальный запуск
+
+```bash
+python -m venv .venv
+.venv/bin/pip install -r requirements.txt
+export DATABASE_URL=postgresql://forma_n8:forma_n8@127.0.0.1:5432/forma_n8
+PYTHONPATH=src .venv/bin/python scripts/migrate.py
+PYTHONPATH=src .venv/bin/uvicorn forma_n8_site.app:app --port 8090
+```
+
+## Публикационный API
+
+Сначала медиа загружается через `POST /api/v1/media` с заголовком `X-Forma-Token`, затем полученный относительный URL передаётся в статью.
+
+`POST /api/v1/articles`:
+
+```json
+{
+ "external_id": "parser:518",
+ "title": "Заголовок новости",
+ "lead": "Краткое описание",
+ "body": "Основной текст без HTML",
+ "category_name": "Снаряжение",
+ "category_tag": "snaryazhenie",
+ "producer_name": "Производитель",
+ "producer_tag": "producer",
+ "source_url": "https://example.com/source",
+ "status": "published",
+ "published_at": "2026-06-21T10:00:00+05:00",
+ "media": [
+ {"type": "image", "url": "/media/ab/hash.jpg", "alt": "Описание изображения"}
+ ]
+}
+```
+
+Повторный запрос с тем же `external_id` обновляет материал, но сохраняет первоначальные slug и дату первой публикации. Это предотвращает дубли URL и потерю поисковой истории.
+
+Публичное название категории берётся из таблицы `categories` по нормализованному `category_tag`. Описательные названия категорий из AI-контура не используются в URL, H1 и SEO-метаданных.
+
+После подключения домена меняется только `SITE_BASE_URL` в `/etc/forma-n8.env`; затем настраивается TLS и отправляются sitemap в Google Search Console и Яндекс Вебмастер.
+
+До подключения домена `SITE_INDEXING_ENABLED=false`: страницы получают `noindex`, а `robots.txt` запрещает обход IP-адреса. После настройки домена, HTTPS и 301-редиректа с IP значение меняется на `true`.
diff --git a/forma_n8_site/db/migrations/001_initial.sql b/forma_n8_site/db/migrations/001_initial.sql
new file mode 100644
index 0000000..cf55ccf
--- /dev/null
+++ b/forma_n8_site/db/migrations/001_initial.sql
@@ -0,0 +1,46 @@
+CREATE TABLE IF NOT EXISTS schema_migrations (
+ version TEXT PRIMARY KEY,
+ applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE TABLE IF NOT EXISTS articles (
+ id BIGSERIAL PRIMARY KEY,
+ external_id TEXT NOT NULL UNIQUE,
+ slug TEXT NOT NULL UNIQUE,
+ title TEXT NOT NULL,
+ lead TEXT NOT NULL DEFAULT '',
+ body TEXT NOT NULL,
+ category_name TEXT NOT NULL,
+ category_tag TEXT NOT NULL,
+ producer_name TEXT NOT NULL,
+ producer_tag TEXT NOT NULL,
+ source_url TEXT,
+ seo_title TEXT NOT NULL DEFAULT '',
+ seo_description TEXT NOT NULL DEFAULT '',
+ status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'published')),
+ published_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS idx_articles_feed
+ ON articles(status, published_at DESC, id DESC);
+CREATE INDEX IF NOT EXISTS idx_articles_category
+ ON articles(category_tag, status, published_at DESC);
+CREATE INDEX IF NOT EXISTS idx_articles_producer
+ ON articles(producer_tag, status, published_at DESC);
+
+CREATE TABLE IF NOT EXISTS article_media (
+ id BIGSERIAL PRIMARY KEY,
+ article_id BIGINT NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
+ media_type TEXT NOT NULL CHECK (media_type IN ('image', 'video')),
+ url TEXT NOT NULL,
+ alt_text TEXT NOT NULL DEFAULT '',
+ width INTEGER,
+ height INTEGER,
+ sort_order INTEGER NOT NULL DEFAULT 0,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS idx_article_media_order
+ ON article_media(article_id, sort_order, id);
diff --git a/forma_n8_site/db/migrations/002_categories.sql b/forma_n8_site/db/migrations/002_categories.sql
new file mode 100644
index 0000000..fa547b0
--- /dev/null
+++ b/forma_n8_site/db/migrations/002_categories.sql
@@ -0,0 +1,35 @@
+CREATE TABLE IF NOT EXISTS categories (
+ tag TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ sort_order INTEGER NOT NULL DEFAULT 100,
+ is_active BOOLEAN NOT NULL DEFAULT TRUE
+);
+
+INSERT INTO categories(tag, name, sort_order) VALUES
+ ('odezhda', 'Одежда', 1),
+ ('snaryazhenie', 'Снаряжение', 2),
+ ('bronya', 'Броня', 3),
+ ('aksessuary', 'Аксессуары', 4),
+ ('svyaz', 'Связь', 5),
+ ('patchi', 'Патчи', 6),
+ ('navigaciya', 'Навигация', 7),
+ ('pricely', 'Прицелы', 8),
+ ('nochnye-pribory', 'Ночные приборы', 9),
+ ('medicina', 'Медицина', 10),
+ ('svet', 'Свет', 11),
+ ('elektronika', 'Электроника', 12),
+ ('nozhi', 'Ножи и мультитулы', 13),
+ ('oruzheynyy-obves', 'Оружейный обвес', 14),
+ ('praktika', 'Практика', 15),
+ ('teoriya', 'Теория', 16),
+ ('pushki', 'Оружие', 17)
+ON CONFLICT(tag) DO UPDATE SET
+ name=EXCLUDED.name,
+ sort_order=EXCLUDED.sort_order;
+
+UPDATE articles a
+SET category_name=c.name,
+ updated_at=NOW()
+FROM categories c
+WHERE a.category_tag=c.tag
+ AND a.category_name<>c.name;
diff --git a/forma_n8_site/deploy/forma-n8.service b/forma_n8_site/deploy/forma-n8.service
new file mode 100644
index 0000000..7f7d14b
--- /dev/null
+++ b/forma_n8_site/deploy/forma-n8.service
@@ -0,0 +1,23 @@
+[Unit]
+Description=Forma N8 public website
+After=network.target postgresql.service
+Wants=postgresql.service
+
+[Service]
+Type=simple
+User=forma-n8
+Group=forma-n8
+WorkingDirectory=/opt/forma-n8
+EnvironmentFile=/etc/forma-n8.env
+Environment=PYTHONPATH=/opt/forma-n8/src
+ExecStart=/opt/forma-n8/.venv/bin/uvicorn forma_n8_site.app:app --host 127.0.0.1 --port 8090 --proxy-headers
+Restart=always
+RestartSec=3
+NoNewPrivileges=true
+PrivateTmp=true
+ProtectSystem=strict
+ProtectHome=true
+ReadWritePaths=/var/lib/forma-n8
+
+[Install]
+WantedBy=multi-user.target
diff --git a/forma_n8_site/deploy/nginx.conf b/forma_n8_site/deploy/nginx.conf
new file mode 100644
index 0000000..55a63f5
--- /dev/null
+++ b/forma_n8_site/deploy/nginx.conf
@@ -0,0 +1,31 @@
+server {
+ listen 80 default_server;
+ listen [::]:80 default_server;
+ server_name _;
+
+ client_max_body_size 52m;
+
+ location /static/ {
+ alias /opt/forma-n8/static/;
+ expires 30d;
+ add_header Cache-Control "public, immutable";
+ access_log off;
+ }
+
+ location /media/ {
+ alias /var/lib/forma-n8/uploads/;
+ expires 30d;
+ add_header Cache-Control "public";
+ add_header X-Content-Type-Options "nosniff";
+ }
+
+ location / {
+ proxy_pass http://127.0.0.1:8090;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_read_timeout 60s;
+ }
+}
diff --git a/forma_n8_site/design/concept-home.png b/forma_n8_site/design/concept-home.png
new file mode 100644
index 0000000..cb19625
Binary files /dev/null and b/forma_n8_site/design/concept-home.png differ
diff --git a/forma_n8_site/ghost-theme/forma-n8/assets/css/screen.css b/forma_n8_site/ghost-theme/forma-n8/assets/css/screen.css
new file mode 100644
index 0000000..fa2f0d4
--- /dev/null
+++ b/forma_n8_site/ghost-theme/forma-n8/assets/css/screen.css
@@ -0,0 +1,431 @@
+@font-face {
+ font-family: "Roboto Condensed";
+ src: url("../fonts/roboto-condensed-cyrillic-400.woff2") format("woff2");
+ font-weight: 400;
+ font-display: swap;
+}
+
+@font-face {
+ font-family: "Roboto Condensed";
+ src: url("../fonts/roboto-condensed-cyrillic-700.woff2") format("woff2");
+ font-weight: 700;
+ font-display: swap;
+}
+
+:root {
+ color-scheme: dark;
+ --bg: #090c0d;
+ --surface: #101415;
+ --surface-strong: #151a1b;
+ --text: #eceee8;
+ --muted: #9ba19b;
+ --faint: #686f6b;
+ --border: #29302e;
+ --border-strong: #3a423f;
+ --olive: #a5ad62;
+ --red: #df4a40;
+ --max: 1240px;
+ --gutter: 24px;
+ --font: "Roboto Condensed", "Arial Narrow", Arial, sans-serif;
+}
+
+* { box-sizing: border-box; }
+
+html { background: var(--bg); scroll-behavior: smooth; }
+
+body {
+ margin: 0;
+ min-width: 320px;
+ background: var(--bg);
+ color: var(--text);
+ font-family: var(--font);
+ font-size: 18px;
+ line-height: 1.55;
+ letter-spacing: 0;
+ -webkit-font-smoothing: antialiased;
+}
+
+body::before {
+ content: "";
+ position: fixed;
+ inset: 0;
+ pointer-events: none;
+ opacity: .22;
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120' viewBox='0 0 120 120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.8' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.08'/%3E%3C/svg%3E");
+}
+
+a { color: inherit; text-decoration: none; }
+a:hover { color: var(--olive); }
+img, video { display: block; max-width: 100%; }
+button, input { font: inherit; letter-spacing: 0; }
+
+:focus-visible { outline: 2px solid var(--olive); outline-offset: 4px; }
+
+.skip-link {
+ position: fixed;
+ z-index: 100;
+ top: 10px;
+ left: 10px;
+ transform: translateY(-160%);
+ padding: 10px 14px;
+ background: var(--text);
+ color: var(--bg);
+}
+.skip-link:focus { transform: none; }
+
+.site-header {
+ position: sticky;
+ z-index: 20;
+ top: 0;
+ border-bottom: 1px solid var(--border);
+ background: rgba(9, 12, 13, .96);
+ backdrop-filter: blur(12px);
+}
+
+.header-inner {
+ width: min(var(--max), calc(100% - var(--gutter) * 2));
+ min-height: 76px;
+ margin: 0 auto;
+ display: grid;
+ grid-template-columns: 170px 1fr auto;
+ align-items: center;
+ gap: 32px;
+}
+
+.brand {
+ display: inline-flex;
+ width: fit-content;
+ align-items: center;
+ gap: 11px;
+ font-size: 15px;
+ font-weight: 700;
+ line-height: .95;
+}
+.brand img { width: 42px; height: 42px; border: 1px solid var(--border-strong); }
+
+.main-nav { display: flex; justify-content: center; align-self: stretch; gap: 38px; }
+.main-nav .nav { display: contents; margin: 0; padding: 0; list-style: none; }
+.main-nav .nav li { display: contents; }
+.nav-dropdown { position: relative; display: flex; align-items: center; }
+.nav-dropdown-toggle { display: inline-flex; height: 100%; align-items: center; gap: 8px; padding: 0; border: 0; background: transparent; color: var(--muted); cursor: pointer; }
+.nav-dropdown-toggle:hover, .nav-dropdown-toggle[aria-expanded="true"] { color: var(--text); }
+.nav-dropdown-icon { width: 7px; height: 7px; border-right: 1.5px solid currentColor; border-bottom: 1.5px solid currentColor; transform: translateY(-2px) rotate(45deg); transition: transform .18s ease; }
+.nav-dropdown-toggle[aria-expanded="true"] .nav-dropdown-icon { transform: translateY(2px) rotate(225deg); }
+.category-menu { position: absolute; z-index: 30; top: calc(100% - 1px); left: 50%; display: none; grid-template-columns: repeat(2, minmax(170px, 1fr)); width: 430px; padding: 12px; border: 1px solid var(--border-strong); background: var(--bg); transform: translateX(-50%); box-shadow: 0 18px 44px rgba(0,0,0,.38); }
+.category-menu.is-open { display: grid; }
+.category-menu a { min-height: 40px; padding: 9px 11px; border-bottom: 1px solid var(--border); color: var(--muted); }
+.category-menu a:hover { background: var(--surface-strong); color: var(--text); }
+.main-nav a {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ color: var(--muted);
+ font-size: 16px;
+ white-space: nowrap;
+}
+.main-nav a:hover, .main-nav a[aria-current="page"] { color: var(--text); }
+.main-nav a[aria-current="page"]::after {
+ content: "";
+ position: absolute;
+ right: 0;
+ bottom: -1px;
+ left: 0;
+ height: 3px;
+ background: var(--olive);
+}
+
+.header-actions { display: flex; gap: 8px; }
+.header-actions > .social-links { margin-right: 5px; padding-right: 10px; border-right: 1px solid var(--border); }
+.icon-button {
+ display: grid;
+ width: 40px;
+ height: 40px;
+ padding: 0;
+ place-items: center;
+ border: 0;
+ background: transparent;
+ color: var(--text);
+ cursor: pointer;
+}
+.icon-button:hover { background: var(--surface-strong); color: var(--olive); }
+.icon-button svg { width: 23px; height: 23px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: square; }
+.menu-button { display: none; }
+
+.search-form {
+ width: min(760px, calc(100% - var(--gutter) * 2));
+ margin: 0 auto;
+ padding: 16px 0 20px;
+}
+.search-form[hidden] { display: none; }
+.search-form label { display: block; margin-bottom: 8px; color: var(--muted); font-size: 14px; }
+.search-form > div { display: grid; grid-template-columns: 1fr auto; }
+.search-form input {
+ min-width: 0;
+ height: 48px;
+ padding: 0 16px;
+ border: 1px solid var(--border-strong);
+ border-right: 0;
+ border-radius: 0;
+ background: var(--surface);
+ color: var(--text);
+ font-size: 17px;
+}
+.search-form button, .read-link, .empty-state a {
+ border: 1px solid var(--olive);
+ border-radius: 0;
+ background: transparent;
+ color: var(--text);
+ cursor: pointer;
+}
+.search-form button { padding: 0 24px; }
+.search-form button:hover, .read-link:hover, .empty-state a:hover { background: var(--olive); color: var(--bg); }
+
+.feed-shell, .article-page, .related-section, .site-footer {
+ position: relative;
+ width: min(var(--max), calc(100% - var(--gutter) * 2));
+ margin: 0 auto;
+}
+
+.page-heading { padding: 56px 0 46px; }
+.page-heading h1 {
+ max-width: 900px;
+ margin: 0;
+ font-size: 68px;
+ font-weight: 700;
+ line-height: .98;
+}
+@media (min-width: 981px) {
+ .home-template .page-heading {
+ position: relative;
+ left: 50%;
+ width: min(1600px, calc(100vw - var(--gutter) * 2));
+ transform: translateX(-50%);
+ }
+ .home-template .page-heading h1 {
+ max-width: none;
+ font-size: clamp(58px, 4vw, 68px);
+ text-align: center;
+ }
+}
+.page-heading > p { margin: 20px 0 0; color: var(--muted); }
+
+.lead-story {
+ display: grid;
+ grid-template-columns: minmax(0, 1.25fr) minmax(340px, .95fr);
+ min-height: 480px;
+ max-height: 680px;
+ border-bottom: 1px solid var(--border-strong);
+}
+.lead-media { min-height: 480px; max-height: 680px; overflow: hidden; background: var(--surface); }
+.lead-media img, .lead-media video { width: 100%; height: 100%; object-fit: cover; transition: transform .45s ease; }
+.lead-media:hover img { transform: scale(1.015); }
+.lead-copy { display: flex; flex-direction: column; justify-content: center; padding: 34px 0 36px 44px; }
+.lead-copy h2 { margin: 24px 0 22px; font-size: 40px; line-height: 1.14; }
+.lead-copy h2 a:hover { color: var(--text); text-decoration: underline; text-decoration-color: var(--olive); text-underline-offset: 7px; }
+.lead-copy > p { max-width: 580px; margin: 0; color: var(--muted); font-size: 20px; line-height: 1.5; }
+.read-link { width: fit-content; margin-top: 30px; padding: 10px 15px; font-size: 15px; }
+.read-link span { margin-left: 8px; }
+
+.article-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 0; color: var(--muted); font-size: 14px; }
+.article-meta > * { display: inline-flex; align-items: center; }
+.article-meta > * + *::before { content: ""; width: 1px; height: 18px; margin: 0 16px; background: var(--border-strong); }
+.article-meta .category-link { color: var(--olive); }
+.article-meta .producer-link { color: var(--red); }
+
+.feed-list { width: 100%; }
+.feed-list-top { border-top: 1px solid var(--border-strong); }
+.feed-row {
+ display: grid;
+ grid-template-columns: 350px minmax(0, 1fr);
+ gap: 34px;
+ padding: 22px 0;
+ border-bottom: 1px solid var(--border);
+}
+.feed-row-media { aspect-ratio: 16 / 9; overflow: hidden; background: var(--surface); }
+.feed-row-media img, .feed-row-media video { width: 100%; height: 100%; object-fit: cover; transition: transform .35s ease; }
+.feed-row-media:hover img { transform: scale(1.02); }
+.feed-row-copy { align-self: center; min-width: 0; }
+.feed-row h2 { margin: 16px 0 8px; font-size: 25px; line-height: 1.2; }
+.feed-row h2 a:hover { color: var(--text); text-decoration: underline; text-decoration-color: var(--olive); text-underline-offset: 5px; }
+.feed-row p { max-width: 760px; margin: 0; color: var(--muted); font-size: 17px; line-height: 1.5; }
+.preview-social { margin-top: 14px; }
+.media-placeholder { display: grid; width: 100%; height: 100%; place-items: center; background: var(--surface); }
+.media-placeholder img { width: 76px; height: 76px; opacity: .28; object-fit: contain; }
+.feed-media { position: relative; }
+.feed-media-feature, .feed-media-feature > img, .feed-media-feature > .media-placeholder { display: block; width: 100%; height: 100%; }
+.feed-media-track { display: flex; width: 100%; height: 100%; overflow-x: auto; overflow-y: hidden; scroll-snap-type: x mandatory; scrollbar-width: none; }
+.feed-media-track::-webkit-scrollbar { display: none; }
+.feed-media-slide { flex: 0 0 100%; width: 100%; height: 100%; scroll-snap-align: start; }
+.feed-media-slide > a, .feed-media-slide img, .feed-media-slide video { display: block; width: 100%; height: 100%; }
+.feed-media-slide img, .feed-media-slide video { object-fit: cover; }
+.feed-media-count { position: absolute; top: 10px; right: 10px; z-index: 2; padding: 4px 8px; border-radius: 999px; background: rgba(0,0,0,.68); color: #fff; font-size: 12px; line-height: 1; pointer-events: none; }
+
+.pagination { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; min-height: 90px; font-size: 15px; }
+.pagination > :last-child { justify-self: end; }
+.pagination span { color: var(--faint); }
+.pagination .older-posts { grid-column: 3; justify-self: end; }
+.pagination .newer-posts { grid-column: 1; }
+.pagination .page-number { grid-column: 2; grid-row: 1; }
+
+.home-seo {
+ max-width: 960px;
+ margin: 54px auto 0;
+ padding: 40px 0 0;
+ border-top: 1px solid var(--border-strong);
+}
+.home-seo h2 { margin: 0 0 24px; font-size: 34px; line-height: 1.15; }
+.home-seo p { margin: 0 0 18px; color: var(--muted); font-size: 17px; line-height: 1.7; }
+.home-seo p:last-child { margin-bottom: 0; }
+
+.taxonomy-heading { padding-bottom: 34px; }
+.taxonomy-heading > p { margin: 0 0 10px; color: var(--olive); font-size: 15px; text-transform: uppercase; }
+.taxonomy-heading h1 { font-size: 56px; }
+
+.article-page { padding-top: 56px; }
+.article-header { max-width: 960px; margin: 0 auto 42px; }
+.article-breadcrumbs { display: flex; gap: 12px; margin-bottom: 28px; color: var(--muted); font-size: 14px; }
+.article-breadcrumbs span { color: var(--faint); }
+.article-header h1 { margin: 0; font-size: 58px; line-height: 1.06; }
+.article-lead { max-width: 850px; margin: 26px 0 0; color: var(--muted); font-size: 23px; line-height: 1.45; }
+.article-byline { display: flex; gap: 24px; margin-top: 28px; color: var(--muted); font-size: 14px; }
+.article-byline a { color: var(--red); }
+.article-media { display: grid; gap: 12px; margin-bottom: 46px; }
+.article-media figure { margin: 0; background: var(--surface); }
+.article-media img, .article-media video { width: 100%; max-height: 760px; object-fit: contain; }
+.article-layout { display: grid; grid-template-columns: minmax(0, 760px) 260px; justify-content: center; gap: 78px; }
+.article-body { font-size: 21px; line-height: 1.7; }
+.article-body p { margin: 0 0 26px; }
+.gh-content > * { max-width: 100%; }
+.gh-content a { color: var(--olive); text-decoration: underline; text-underline-offset: 3px; }
+.gh-content figure, .gh-content .kg-card { margin: 32px 0; }
+.gh-content img, .gh-content video { width: 100%; height: auto; }
+.gh-content blockquote { margin: 30px 0; padding-left: 24px; border-left: 3px solid var(--olive); color: var(--muted); }
+.kg-width-wide, .kg-width-full { width: 100%; }
+.kg-gallery-container { display: flex; flex-direction: column; gap: 10px; }
+.kg-gallery-row { display: flex; gap: 10px; }
+.kg-gallery-image { flex: 1 1 0; min-width: 0; margin: 0; overflow: hidden; background: var(--surface); }
+.kg-gallery-image img { width: 100%; height: 100%; margin: 0; object-fit: cover; cursor: zoom-in; }
+.media-lightbox { width: 100%; height: 100%; max-width: none; max-height: none; padding: 0; border: 0; background: rgba(0,0,0,.94); }
+.media-lightbox::backdrop { background: rgba(0,0,0,.94); }
+.media-lightbox img { width: 100%; height: 100%; object-fit: contain; }
+.media-lightbox button { position: fixed; top: 18px; right: 18px; z-index: 2; width: 44px; height: 44px; border: 1px solid var(--border-strong); background: var(--bg); color: var(--text); font-size: 28px; cursor: pointer; }
+.article-facts { align-self: start; border-top: 2px solid var(--olive); }
+.article-facts > div { padding: 18px 0; border-bottom: 1px solid var(--border); }
+.article-facts span { display: block; margin-bottom: 4px; color: var(--faint); font-size: 13px; text-transform: uppercase; }
+.article-facts a { font-size: 17px; }
+.article-facts .source-link { display: block; margin-top: 22px; color: var(--muted); font-size: 14px; }
+.social-links { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-top: 13px; }
+.social-link { display: inline-flex; min-height: 34px; align-items: center; gap: 7px; padding: 5px 10px; border: 1px solid var(--border); color: var(--muted); font-size: 14px; line-height: 1; transition: border-color .18s ease, color .18s ease, background .18s ease; }
+.social-link:hover { border-color: var(--border-strong); background: var(--surface-strong); color: var(--text); }
+.social-link svg { width: 18px; height: 18px; fill: currentColor; }
+.social-link--vk:hover { color: #7ca8e8; }
+.social-link--tg:hover { color: #58b9e8; }
+.social-links--compact { gap: 4px; margin-top: 0; }
+.social-links--compact .social-link { width: 34px; min-height: 34px; justify-content: center; padding: 0; }
+.social-links--compact .social-link svg { width: 17px; height: 17px; }
+.about-social { margin-top: 42px; padding-top: 24px; border-top: 1px solid var(--border-strong); }
+.about-social > span { color: var(--faint); font-size: 13px; text-transform: uppercase; }
+.related-section { margin-top: 90px; border-top: 1px solid var(--border-strong); }
+.related-section > h2 { margin: 0; padding: 34px 0 18px; font-size: 32px; }
+
+.empty-state, .not-found { min-height: 56vh; display: flex; flex-direction: column; justify-content: center; align-items: flex-start; }
+.empty-state h2, .not-found h1 { margin: 0; font-size: 42px; }
+.empty-state p { color: var(--muted); }
+.empty-state a { margin-top: 16px; padding: 10px 15px; }
+.not-found > p { margin: 0; color: var(--red); font-size: 18px; }
+.not-found a { margin-top: 24px; color: var(--olive); }
+
+.site-footer {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-end;
+ margin-top: 84px;
+ padding: 34px 0 46px;
+ border-top: 1px solid var(--border-strong);
+ color: var(--muted);
+ font-size: 14px;
+}
+.footer-brand { color: var(--text); font-size: 20px; font-weight: 700; }
+.site-footer p { margin: 7px 0 0; }
+.site-footer nav { display: flex; gap: 24px; }
+.site-footer .social-links { margin-top: 18px; }
+
+@media (max-width: 980px) {
+ .header-inner { grid-template-columns: 150px 1fr auto; gap: 16px; }
+ .main-nav { gap: 22px; }
+ .main-nav a { font-size: 14px; }
+ .page-heading h1 { font-size: 56px; }
+ .lead-story { grid-template-columns: 1.1fr .9fr; min-height: 420px; }
+ .lead-media { min-height: 420px; }
+ .lead-copy { padding-left: 30px; }
+ .lead-copy h2 { font-size: 33px; }
+ .feed-row { grid-template-columns: 290px minmax(0, 1fr); gap: 26px; }
+ .article-layout { grid-template-columns: minmax(0, 680px) 220px; gap: 44px; }
+}
+
+@media (max-width: 760px) {
+ :root { --gutter: 16px; }
+ body { font-size: 17px; }
+ .home-seo { margin-top: 38px; padding-top: 30px; }
+ .home-seo h2 { font-size: 28px; }
+ .home-seo p { font-size: 16px; line-height: 1.65; }
+ .header-inner { min-height: 64px; grid-template-columns: 1fr auto; }
+ .brand img { width: 38px; height: 38px; }
+ .main-nav {
+ position: absolute;
+ top: 64px;
+ right: 0;
+ left: 0;
+ display: none;
+ padding: 12px var(--gutter) 18px;
+ border-bottom: 1px solid var(--border);
+ background: var(--bg);
+ }
+ .main-nav.is-open { display: grid; }
+ .main-nav a { min-height: 44px; border-bottom: 1px solid var(--border); font-size: 16px; }
+ .nav-dropdown { display: block; }
+ .nav-dropdown-toggle { width: 100%; min-height: 44px; border-bottom: 1px solid var(--border); text-align: left; font-size: 16px; }
+ .category-menu { position: static; width: 100%; grid-template-columns: 1fr; padding: 0 0 8px 14px; border: 0; box-shadow: none; transform: none; }
+ .category-menu a { font-size: 15px; }
+ .main-nav a[aria-current="page"]::after { display: none; }
+ .menu-button { display: grid; }
+ .page-heading { padding: 38px 0 30px; }
+ .page-heading h1, .taxonomy-heading h1 { font-size: 44px; line-height: 1; }
+ .lead-story { display: block; min-height: 0; max-height: none; }
+ .lead-media { min-height: 0; max-height: min(72vh, 560px); aspect-ratio: 4 / 3; }
+ .lead-copy { padding: 25px 0 34px; }
+ .lead-copy h2 { margin: 20px 0 15px; font-size: 32px; }
+ .lead-copy > p { font-size: 18px; }
+ .feed-row { display: block; padding: 24px 0; }
+ .feed-row-media { width: 100%; max-height: min(72vh, 560px); aspect-ratio: 4 / 3; }
+ .feed-row-copy { padding-top: 16px; }
+ .feed-row h2 { margin: 10px 0 8px; font-size: 24px; }
+ .feed-row p { display: block; font-size: 16px; }
+ .preview-social { margin-top: 10px; }
+ .preview-social .social-link { width: 30px; min-height: 30px; }
+ .article-meta { font-size: 12px; }
+ .article-meta time { display: none; }
+ .article-meta > * + *::before { height: 14px; margin: 0 10px; }
+ .article-meta .category-link::before { display: none; }
+ .article-header { margin-bottom: 30px; }
+ .article-header h1 { font-size: 42px; }
+ .article-lead { font-size: 20px; }
+ .article-layout { display: block; }
+ .article-body { font-size: 19px; }
+ .article-facts { margin-top: 42px; }
+ .related-section { margin-top: 64px; }
+}
+
+@media (max-width: 460px) {
+ .feed-row h2 { font-size: 22px; line-height: 1.2; }
+ .article-meta .producer-link::before { display: none; }
+ .article-meta .category-link { display: none; }
+ .article-header h1 { font-size: 36px; }
+ .article-byline { display: grid; gap: 7px; }
+ .site-footer { display: grid; gap: 24px; }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ html { scroll-behavior: auto; }
+ *, *::before, *::after { transition-duration: .01ms !important; }
+}
diff --git a/forma_n8_site/ghost-theme/forma-n8/assets/fonts/roboto-condensed-cyrillic-400.woff2 b/forma_n8_site/ghost-theme/forma-n8/assets/fonts/roboto-condensed-cyrillic-400.woff2
new file mode 100644
index 0000000..10138a0
Binary files /dev/null and b/forma_n8_site/ghost-theme/forma-n8/assets/fonts/roboto-condensed-cyrillic-400.woff2 differ
diff --git a/forma_n8_site/ghost-theme/forma-n8/assets/fonts/roboto-condensed-cyrillic-700.woff2 b/forma_n8_site/ghost-theme/forma-n8/assets/fonts/roboto-condensed-cyrillic-700.woff2
new file mode 100644
index 0000000..c001da3
Binary files /dev/null and b/forma_n8_site/ghost-theme/forma-n8/assets/fonts/roboto-condensed-cyrillic-700.woff2 differ
diff --git a/forma_n8_site/ghost-theme/forma-n8/assets/images/favi.png b/forma_n8_site/ghost-theme/forma-n8/assets/images/favi.png
new file mode 100644
index 0000000..23a1268
Binary files /dev/null and b/forma_n8_site/ghost-theme/forma-n8/assets/images/favi.png differ
diff --git a/forma_n8_site/ghost-theme/forma-n8/assets/js/site.js b/forma_n8_site/ghost-theme/forma-n8/assets/js/site.js
new file mode 100644
index 0000000..af1bfa8
--- /dev/null
+++ b/forma_n8_site/ghost-theme/forma-n8/assets/js/site.js
@@ -0,0 +1,86 @@
+(() => {
+ document.querySelectorAll('[data-feed-media]').forEach((container) => {
+ const source = container.querySelector('.feed-media-source');
+ const feature = container.querySelector('.feed-media-feature');
+ if (!source || !feature) return;
+
+ const videos = [...source.querySelectorAll('video')].map((item) => {
+ const video = item.cloneNode(true);
+ video.removeAttribute('poster');
+ video.controls = true;
+ video.playsInline = true;
+ video.preload = 'metadata';
+ return video;
+ });
+ const images = [...source.querySelectorAll('.kg-gallery-card img, .kg-image-card img')].map((item) => {
+ const link = document.createElement('a');
+ link.href = item.currentSrc || item.src;
+ link.setAttribute('aria-label', item.alt ? `Открыть: ${item.alt}` : 'Открыть изображение');
+ link.appendChild(item.cloneNode(true));
+ return link;
+ });
+
+ const items = videos.length ? [...videos, ...images] : [feature, ...images];
+ if (items.length === 1 && !videos.length) return;
+ const track = document.createElement('div');
+ track.className = 'feed-media-track';
+ items.forEach((item) => {
+ const slide = document.createElement('div');
+ slide.className = 'feed-media-slide';
+ slide.appendChild(item);
+ track.appendChild(slide);
+ });
+ container.replaceChildren(track, source);
+ if (items.length > 1) {
+ const count = document.createElement('span');
+ count.className = 'feed-media-count';
+ count.textContent = `1 / ${items.length}`;
+ container.appendChild(count);
+ track.addEventListener('scroll', () => {
+ const index = Math.round(track.scrollLeft / Math.max(track.clientWidth, 1));
+ count.textContent = `${Math.min(index + 1, items.length)} / ${items.length}`;
+ }, { passive: true });
+ }
+ });
+})();
+
+(() => {
+ const button = document.querySelector('[data-menu-toggle]');
+ const nav = document.querySelector('#main-nav');
+ if (!button || !nav) return;
+ button.addEventListener('click', () => {
+ const open = nav.classList.toggle('is-open');
+ button.setAttribute('aria-expanded', String(open));
+ });
+})();
+
+(() => {
+ const toggle = document.querySelector('[data-category-toggle]');
+ const menu = document.querySelector('[data-category-menu]');
+ if (!toggle || !menu) return;
+ const close = () => { menu.classList.remove('is-open'); toggle.setAttribute('aria-expanded', 'false'); };
+ toggle.addEventListener('click', (event) => {
+ event.stopPropagation();
+ const open = menu.classList.toggle('is-open');
+ toggle.setAttribute('aria-expanded', String(open));
+ });
+ document.addEventListener('click', (event) => { if (!menu.contains(event.target)) close(); });
+ document.addEventListener('keydown', (event) => { if (event.key === 'Escape') close(); });
+})();
+
+(() => {
+ const images = document.querySelectorAll('.gh-content .kg-gallery-image img');
+ if (!images.length || !('HTMLDialogElement' in window)) return;
+ const dialog = document.createElement('dialog');
+ dialog.className = 'media-lightbox';
+ dialog.innerHTML = '';
+ const preview = dialog.querySelector('img');
+ dialog.querySelector('button').addEventListener('click', () => dialog.close());
+ dialog.addEventListener('click', (event) => { if (event.target === dialog) dialog.close(); });
+ document.body.appendChild(dialog);
+ images.forEach((image) => image.addEventListener('click', () => {
+ preview.src = image.currentSrc || image.src;
+ preview.alt = image.alt || '';
+ dialog.showModal();
+ }));
+})();
diff --git a/forma_n8_site/ghost-theme/forma-n8/default.hbs b/forma_n8_site/ghost-theme/forma-n8/default.hbs
new file mode 100644
index 0000000..647acc2
--- /dev/null
+++ b/forma_n8_site/ghost-theme/forma-n8/default.hbs
@@ -0,0 +1,81 @@
+
+
+
+
+
+
+
+
+ {{#is "home"}}
+ Forma N8 — все новости снаряжения в одном месте
+ {{else}}
+ {{#is "post"}}
+ {{#post}}Forma N8 — {{title}}{{/post}}
+ {{else}}
+ {{#is "tag"}}
+ {{#tag}}Forma N8 — {{name}}{{/tag}}
+ {{else}}
+ Forma N8 — {{meta_title}}
+ {{/is}}
+ {{/is}}
+ {{/is}}
+
+
+ {{ghost_head}}
+
+
+
+
+
+ К содержанию
+
+
Forma N8 — лента новостей о тактическом снаряжении, экипировке и EDC. Мы собираем новинки с десятков источников и публикуем в одном месте: плитоносцы и бронезащита, подсумки и разгрузочные системы, боевые брюки и рубашки, прицелы и оптика, тактические фонари, средства связи, медицина и ночные приборы.
+
В ленте — новинки российских и зарубежных производителей: Ars Arma, Wartech, SSO, Stich Profi, Giena Tactical, 5.45 Design, Red Cliff, VOIN, Sturmer, БАРС, SRVV, ANA Tactical, Mordor Tac, Centurion Gear, ENOTACTICAL, Black Buckle, MILGEAR, SKIF Armor, Atlant Armour, БронеЁж, R5 GEAR, VINDEX, JAEGER Equipment, Margul Gear, Gordeev Tactical, IGLA TAC, STRIX TAC, Колчуга, APEX, Пересвет, Гарсинг и многих других. Отслеживаем оптику Диполь, ARTELV и ARKON, гарнитуры VULCAN, Fidem и ОКБ Октава, фонари Nicron, патчи BAD GRINGO и RedWar Patch, ножи DAGGERR, Morpheus Knives и N.C.Custom.
+
Темы ленты охватывают весь комплект тактической экипировки: системы MOLLE и Molle-Minus, плиты стандартов Гранит, SAPI и ESAPI, ткани Cordura и NYCO, расцветки Multicam, Tiger Stripe и EMR. Новинки магазинов Академия Снаряжения, Semper Fi, ФИНИСТ и Империя ножей, обновления линеек, цены и отзывы пользователей — всё в одной новостной ленте о снаряжении.
Активные категории попадают в редакторскую и передаются AI-райтеру как ID, название и тэг. ID стабилен: модель возвращает его в JSON, а тэг подставляется из БД.
+
Название описывает категорию для AI, тэг используется в соцсетях. Название и URL сайта хранятся отдельно и не меняют рабочие хэштеги.
-
Название
Тэг
ID
Статус
+
Название AI
Тэг
Название сайта
URL сайта
ID
Статус
{% for c in categories %}
@@ -37,8 +40,10 @@
+
+
-
{% if c.is_active %}активна{% else %}выключена{% endif %}
+
{% if c.is_active %}активна{% else %}выключена{% endif %}
+
+ Расписание TG-постера
+
Время публикации задаётся по Екатеринбургу. Для запуска нужны оба флага: worker tg-poster и настройка tg_poster_enabled.
+
+
+
Время Екб
Постов
Статус
+ {% for row in tg_schedule %}
+
+
+
+
+
+
+
+
+
+
+
+ {% endfor %}
+
+
+
+
+ Расписание VK-постера
+
Время публикации задаётся по Екатеринбургу. Для запуска нужны оба флага: worker vk-poster и настройка vk_poster_enabled.