# 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 находится в начале `<head>`, noscript iframe — сразу
после открывающего `<body>`.

### 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
