Files
2026-07-18 21:29:05 +05:00

1715 lines
79 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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