# N8 Parser: проектная документация Этот документ описывает текущую архитектуру проекта `n8_parser`: зачем он нужен, как устроены БД, воркеры, админка, AI-контур, медиа-хранилище, деплой и типовые операции. Документ написан как рабочая карта проекта, чтобы к нему можно было вернуться через месяц и быстро понять, где что лежит и почему сделано именно так. ## 1. Цель проекта Проект автоматизирует сбор и редакционную обработку постов из VK-сообществ производителей, магазинов и площадок по тактическому, милитари и airsoft-снаряжению. Основная цепочка: ```text Источники VK -> VK-парсер -> raw_posts / raw_post_media в PostgreSQL -> Telegram media storage -> AI-квалификатор -> AI-райтер -> редакторская -> принятие / отклонение / ручное редактирование ``` Проект не должен превращаться в набор разрозненных скриптов. Главная идея текущей реализации: все долгие операции живут в отдельных воркерах, все настройки управляются из админки, а секреты остаются в `.env`. ## 2. Текущий стек - Backend: Python 3.12+, FastAPI. - Шаблоны: Jinja2. - БД: PostgreSQL. - Асинхронный доступ к БД: `asyncpg`. - VK API: собственный клиент в `src/vk_parser_app/vk_api.py`. - Telegram: `aiogram`. - AI: `litellm`, сейчас используется Anthropic через LiteLLM. - Логи: `loguru`. - Системный запуск на сервере: `systemd`. - Reverse proxy / TLS: nginx + сертификат для `https://sw.exostring.xyz`. - Репозиторий: GitHub `exostring/n8_parser`. ## 3. Структура проекта ```text . ├── db/ │ └── migrations/ # SQL-миграции ├── scripts/ │ ├── apply_migrations.py # применение миграций │ └── vk_storage_spike.py # старый тест VK storage ├── src/ │ └── vk_parser_app/ │ ├── admin.py # FastAPI админка и маршруты │ ├── config.py # env-настройки │ ├── constants.py # статусы, имена воркеров, типы jobs │ ├── db.py # pool, settings, миграции │ ├── heartbeat.py # heartbeat воркеров │ ├── jobs.py # очередь jobs │ ├── main.py # ASGI entrypoint │ ├── security.py # пароли, сессии, CSRF │ ├── text_utils.py # тэги, категории │ ├── vk_api.py # VK API client и извлечение media │ ├── templates/ # HTML-шаблоны │ └── workers/ │ ├── parser.py # VK-парсер │ ├── vk_storage_uploader.py# Telegram media storage uploader │ ├── ai_qualifier.py # AI-квалификатор │ └── ai_writer.py # AI-райтер ├── original_project/ # старый проект как справочник ├── requirements.txt ├── README.md # короткий early quick start, частично устарел └── PROJECT.md # этот документ ``` ## 4. Важное архитектурное решение ### 4.0. Важные изменения фиксируются в документации Если меняется поведение системы, эксплуатационная схема, публикационный формат, БД, деплой или админка, это нужно отражать в `PROJECT.md` или другом релевантном `.md` рядом с кодом. Текущие зафиксированные изменения: - Публичный сайт вынесен в самостоятельное приложение `forma_n8_site/` и разворачивается на отдельном VPS. Сайт хранит собственные статьи и медиа, отдаёт серверный HTML, RSS, sitemap, robots.txt, canonical и JSON-LD `NewsArticle`. - Интеграция с сайтом строится через idempotent `POST /api/v1/articles`: `external_id` уникален, а slug и дата первой публикации сохраняются при обновлениях. Дизайн можно менять без изменения URL и повторной загрузки постов. Медиа передаются через защищённый `POST /api/v1/media` и хранятся на стороне сайта. - Публичные названия категорий сайта отделены от описательных AI-категорий парсера: таблица `categories` связывает стабильный латинский tag с коротким SEO-названием. API нормализует входной tag и использует справочник для H1, breadcrumbs и category URL. - Пока у публичного сайта нет домена, `SITE_INDEXING_ENABLED=false`: HTML получает `noindex`, а robots.txt закрывает IP от обхода. Индексация включается только после настройки домена, HTTPS, корректного `SITE_BASE_URL` и редиректа с IP. - `favi.png` лежит в корне проекта и отдаётся FastAPI по `/favi.png` и `/favicon.ico`. - Финальный текст публикации собирается через общий helper: к тексту добавляется строка `#category #source`. - Категорийный хэштег берётся из `content_categories.tag`, а не из человекочитаемого названия категории. - В админке добавлена вкладка `/logs` для просмотра `audit_log`; записи старше 30 дней удаляются автоматически. - Добавлен TG-постер: расписание по Екб, отдельный токен/чат, ранжирование, Telegram-лимиты и статусы `published` / `publish_failed`. - Добавлен TG-реактор: отдельный воркер ставит реакции на опубликованные Telegram-посты из `post_publications`; один bot token ставит одну реакцию, несколько bot tokens могут поставить несколько реакций. - Добавлен ежедневный Telegram-отчет `daily-report`: в `00:04` по Екатеринбургу отправляет получателям из `daily_report_recipient_ids` операционный отчет за прошедший день: raw-сбор, AI-принято/отклонено, редакторская очередь, TG/VK публикации, ошибки и состояние воркеров. По умолчанию получатель `442509142`, bot token берется из `daily_report_bot_token`, затем `tg_poster_bot_token`, затем `TG_BOT_TOKEN`. - Текущая схема TG-реактора: основной бот публикации + два отдельных reaction-бота; реакции распределены по порядку токенов как `👍`, `🔥`, `❤`. - Для догонки реакций TG-реактор использует паузу `tg_reactor_reaction_pause_sec` между успешными реакциями, чтобы не отправлять пачку запросов в Telegram одним всплеском. - Разделительная полоса в AI-рерайтах должна быть строго `━━━━━━━━━━━━━━━━━` (17 символов). - TG-постер перечитывает настройки перед циклом и не догоняет прошедшие часы; слот срабатывает только в 10-минутном окне после своего времени. - TG-постер публикует медиа только через Telegram `file_id`/storage attachment или локальный upload; сырые `http(s)` media URL не отправляются как Telegram media, чтобы не ловить `WEBPAGE_MEDIA_EMPTY` / `wrong type of the web page content`. - TG-постер повторяет отправку только при `TelegramRetryAfter` flood-control. Обычные timeout/network ошибки после `send_message`/`send_media` не ретраятся, потому что Telegram мог уже принять сообщение, а повтор создаёт дубли. - На сервере собран и запущен локальный Telegram Bot API (`telegram-bot-api.service`) на `127.0.0.1:8081`. Uploader использует его через `local_bot_api_url`, чтобы загружать видео больше облачного лимита Bot API. - Telegram storage uploader скачивает VK-видео через `yt-dlp` и только после успешной загрузки всех pending фото/видео переводит raw-пост в `storage_ready`. Если видео временно не скачалось, job ретраится; если media окончательно failed, raw-пост получает `failed`, чтобы публикация не ушла с одной обложкой вместо ролика. Текущие лимиты после включения local Bot API: `video_max_size_mb=512`, `video_max_height=1080`, `video_max_duration_sec=1200`, `uploader_yt_dlp_timeout_sec=1800`. - Вся цепочка после storage теперь требует минимум одно видимое загруженное `photo`/`video`: uploader не переводит пост без media в `storage_ready`, AI-квалификатор и AI-райтер не забирают посты без загруженного media, TG-постер не публикует старые готовые записи без загруженного media. Если видео ушло в `failed` или `link_only`, пост считается `failed` и не попадает даже на квалификацию. - При финальной проверке media uploader не считает `pending` те файлы, которые уже успешно скачаны и подготовлены в текущем проходе: их Telegram `file_id` появляется только после отправки. Иначе uploader зацикливается между подготовкой файла и повтором job, не отправляя медиа в storage. - TG-постер дополнительно не берёт кандидата в публикацию, если у видимого `photo`/`video` нет `tg_file_id`/`storage_attachment_id`; это защищает от старых `storage_ready` записей, которые успели стать готовыми до ужесточения uploader. - Для разовой дозагрузки старых timeout-видео добавлен `scripts/backfill_pending_videos.py`: он последовательно скачивает pending VK-видео, отправляет в Telegram storage через local Bot API и проставляет `tg_file_id`. - На странице Raw можно выделить несколько постов и вручную отправить их в AI-рерайт; обработчик переводит только `storage_ready` посты в `qualification_status='accepted'` и `rewrite_status='pending'`. - Категория AI-райтера `18` / `Не целевой контент` / `НЦК` считается автоматическим AI-отклонением: такие посты получают `qualification_status='rejected'`, `editorial_status='rejected'` и не попадают в редакторскую `На проверке`. - Добавлена общая таблица `post_publications`: в редакторской у каждой новости видны платформенные статусы TG/VK/MAX/Сайт. TG-постер зеркалит результат в эту таблицу, VK-постер пишет туда напрямую. - Запрос редакторской агрегирует строки `post_publications` для каждой новости; статусы TG/VK берутся из фактических платформенных публикаций, поэтому опубликованный пост не отображается как `ожидает`. - Добавлен VK-постер `src/vk_parser_app/workers/vk_poster.py`: публикует принятые/уже опубликованные в TG посты в целевое VK-сообщество по отдельному расписанию Екб. На текущем этапе VK-постер не загружает медиа через `photos.*`, а берёт только исходные VK attachments (`photo...` / `video...`) из `raw_post_media.original_attachment_id`; посты без такого VK attachment не попадают в кандидаты VK-публикации. - Добавлен MAX-постер `src/vk_parser_app/workers/max_poster.py`: публикует принятые/уже опубликованные материалы в MAX-канал через Bot API от имени бота-администратора. Расписание по умолчанию повторяет TG hourly schedule. Текст режется по лимиту MAX `4000` символов, медиа загружается в MAX отдельно через `/uploads`, а результат пишется в `post_publications(platform='max')`. - MAX-постер для видео использует не `original_url` VK-страницы, а готовый файл из Telegram storage по `tg_file_id` / `storage_attachment_id`. Если локальный Telegram Bot API возвращает абсолютный `file_path` в `/var/lib/telegram-bot-api/...`, воркер читает файл напрямую с диска. После upload видео воркер poll-ит `GET /videos/{videoToken}` и отправляет пост только когда MAX вернул `urls`. - В MAX Bot API на момент внедрения нет публичного метода для реакции на пост: попытка `/messages/{message_id}/reactions` возвращает `404 method.not.found`. Auto-reaction оставлен как non-fatal hook и не должен помечать успешную публикацию ошибкой. - В исходных VK-постах могут встречаться отдельные пустые/белые фото рядом с видео. Пример: `raw_post_id=398`, `photo-190867868_457284718` полностью белая (`1433x2000`, RGB `255/255/255`). MAX-постер пока прикрепляет такие фото как обычные исходные медиа; следующий безопасный шаг - добавить фильтр почти полностью белых изображений перед upload. - Ранжирование TG/VK-постеров не использует `qualification_score`: квалификатор только допускает пост в дальнейшую цепочку. Постер сначала выбирает вариант без совпадения категории/источника с предыдущей публикацией, затем минимизирует повторы категории и источника в окне последних 20 публикаций, а при равенстве берёт самый старый ожидающий пост. - Для VK-постера user token нужно получать через VK ID Authorization Code Flow с PKCE: `/vk-oauth/start` -> `id.vk.ru/authorize` -> `/vk-oauth/callback`. Callback обменивает `code` через `id.vk.ru/oauth2/auth` на `access_token` + `refresh_token`, сохраняет `vk_poster_access_token`, `vk_poster_refresh_token`, `vk_poster_token_device_id`, `vk_poster_token_expires_at` и проверяет `wall.get` / `photos.getWallUploadServer`. Воркер обновляет access token через refresh token до истечения. Refresh token живёт 180 дней по документации VK ID; Redirect URI в VK ID-приложении: `https://sw.exostring.xyz/vk-oauth/callback`. - По документации VK API метод `photos.getWallUploadServer` требует пользовательское право `photos`, которое VK выдаёт в исключительных случаях через запрос в поддержку `devsupport@corp.vk.com`. Если token без `photos`, VK-постер пропускает upload редакторских/URL-фото, но всё равно публикует текст и существующие VK `photo...`/`video...` attachments. - VK-постер исключает из очереди video-only записи с `raw_post_media.status='link_only'`: VK API не разрешает прикреплять часть чужих видео без предварительной загрузки в целевое сообщество. Медиа `link_only` также не добавляются к смешанным постам, а публикации со статусом `publish_failed` не возвращаются автоматически в каждый следующий часовой слот и требуют явного ручного повтора. ### 4.1. Секреты в `.env`, операционные настройки в БД В `.env` должны лежать только секреты и базовые параметры окружения: - `APP_SECRET_KEY` - `ADMIN_BOOTSTRAP_LOGIN` - `ADMIN_BOOTSTRAP_PASSWORD` - параметры подключения к PostgreSQL - `VK_ACCESS_TOKEN` - `TG_BOT_TOKEN` - и другие токены/пароли В БД, в таблице `app_settings`, лежат операционные настройки: - интервалы воркеров; - batch size; - лимиты текста; - включение/выключение AI; - выбор provider/model; - prompt/contract; - Telegram storage channel id; - лимиты Telegram uploader; - параметры парсинга. Причина: секреты опасно показывать в админке и хранить в обычных настройках, а операционные параметры удобно менять без деплоя. ### 4.2. Worker control состоит из двух флагов Для AI-воркеров используется двойная защита: 1. `worker_controls.enabled` 2. `app_settings.ai_*_enabled` Воркер начнёт работу только если включены оба. Это сделано специально, чтобы случайное включение одного тумблера не запускало расход денег на LLM. ## 5. БД: ключевые таблицы ### 5.1. `admin_users` Пользователи админки. Основные поля: - `id` - `login` - `password_hash` - `is_active` - `created_at` Первый пользователь создаётся из `ADMIN_BOOTSTRAP_LOGIN` и `ADMIN_BOOTSTRAP_PASSWORD`. ### 5.2. `admin_sessions` Сессии админки. Хранит: - `token_hash` - `user_id` - `csrf_token` - `expires_at` - `created_at` Сырой session token в БД не хранится. ### 5.3. `sources` Источники для парсинга. Сейчас реально используется только `platform='vk'`, но схема заложена под другие площадки. Важные поля: - `id` - `platform` - `name` - `tag` - `url` - `external_id` - `external_owner_id` - `active` - `status` - `status_msg` - `last_checked_at` - `last_parsed_at` - `parse_from` - `priority` - `archived_at` `tag` нужен для будущих хэштегов и финального оформления. ### 5.4. `raw_posts` Главная таблица жизненного цикла поста. Основные поля raw: - `id` - `source_id` - `platform` - `external_post_id` - `external_owner_id` - `original_url` - `raw_text` - `raw_json` - `text_hash` - `content_hash` - `posted_at` - `created_at` - `updated_at` - `status` - `skip_reason` Storage-поля: - `storage_post_url` - `tg_storage_chat_id` - `tg_storage_thread_id` - `tg_storage_message_ids` AI-квалификация: - `qualification_status` - `qualification_score` - `qualification_decision` - `qualification_model_decision` - `qualification_reason` - `qualification_reject_tag` - `qualification_model` - `qualification_prompt_hash` - `qualification_batch_id` - `qualified_at` AI-райтер: - `rewrite_status` - `rewritten_text` - `rewrite_notes` - `rewrite_model` - `rewrite_prompt_hash` - `rewrite_batch_id` - `rewrite_category_id` - `rewrite_category` - `rewrite_category_tag` - `rewrite_source_tag` - `rewritten_at` Редакторская: - `editorial_status` - `final_text` - `final_category_id` - `final_category` - `final_category_tag` - `final_source_tag` - `editor_notes` - `reviewed_by` - `reviewed_at` - `edited_at` ### 5.5. `raw_post_media` Медиа исходного поста. Основные поля: - `id` - `raw_post_id` - `platform` - `media_type` - `original_url` - `original_attachment_id` - `preview_url` - `storage_url` - `storage_attachment_id` - `tg_file_id` - `tg_file_unique_id` - `width` - `height` - `duration_sec` - `sort_order` - `status` - `attempts` - `error` - `editor_hidden` - `editor_added` - `local_path` Удаление медиа в редакторской не обязано физически удалять старое медиа из Telegram. В редакторском контуре оно помечается как скрытое через `editor_hidden`. ### 5.6. `jobs` Очередь фоновых задач. Основные поля: - `id` - `type` - `entity_type` - `entity_id` - `payload_json` - `status` - `attempts` - `max_attempts` - `next_run_at` - `locked_by` - `locked_at` - `last_error` Сейчас основной тип: - `vk.storage.copy` Название историческое: сначала планировался VK storage, потом медиа-сторедж вернулся в Telegram. Тип задачи пока не переименован. ### 5.7. `worker_controls` Включение/выключение воркеров. Поля: - `name` - `enabled` - `settings_json` - `updated_by` - `updated_at` Имена воркеров: - `vk-parser` - `vk-storage-uploader` - `ai-qualifier` - `ai-writer` - `tg-poster` - `max-poster` ### 5.8. `worker_heartbeats` Состояние воркеров для админки. Поля: - `name` - `heartbeat_at` - `status` - `current_job_id` - `meta_json` ### 5.9. `app_settings` Настройки приложения. Поля: - `key` - `value_json` - `value_type` - `title` - `description` - `category` - `updated_at` Важно: `value_json` всегда JSONB, даже если настройка выглядит как строка. ### 5.10. `ai_qualification_batches` История запросов AI-квалификатора. Хранит: - provider/model; - prompt hash; - полный prompt text; - request JSON; - response JSON; - количество токенов; - примерную стоимость; - счётчики accepted/rejected/maybe; - ошибку, если batch упал. ### 5.11. `ai_writer_batches` История запросов AI-райтера. Хранит: - provider/model; - prompt hash; - полный prompt text; - request JSON; - response JSON; - количество токенов; - примерную стоимость; - ready/failed count; - ошибку. Важно: после фикса batch с ошибкой также должен сохранять `response_json`, если модель вернула валидный JSON неправильной структуры. ### 5.12. `content_categories` Категории публикаций для AI-райтера и редакторской. Поля: - `id` - внутренний технический PK; - `sort_order` - публичный стабильный ID категории для AI; - `name` - человекочитаемое название/описание; - `tag` - тэг, который будет подставляться системой; - `is_active`; - `created_at`; - `updated_at`. Решение: `sort_order` используется как `category_id` для AI-райтера. В UI он отображается как `ID`, readonly. Модель возвращает только `category_id`, а код сам подставляет `name` и `tag` из БД. ### 5.13. `audit_log` Журнал действий админки. Хранит: - `id`; - `actor_id`; - `action`; - `entity_type`; - `entity_id`; - `before_json`; - `after_json`; - `created_at`. В `after_json` дополнительно пишется request-контекст, если он доступен: - `ip`; - `method`; - `path`; - `query`; - `user_agent`; - `status_code` для request-level записей. Retention: записи старше 30 дней удаляются при старте приложения, при применении миграции `022_audit_log_retention_and_indexes.sql` и при открытии вкладки `/logs`. ### 5.14. `publication_runs` Защита расписания постеров от повторной публикации одного и того же слота. Поля: - `poster` - тип постера, сейчас `tg`; - `schedule_id` - ID строки расписания; - `scheduled_for` - дата по Екб; - `scheduled_time` - время строки расписания; - `planned_count`; - `published_count`; - `status`; - `error`. Уникальность `poster + schedule_id + scheduled_for` гарантирует, что слот расписания за день будет обработан один раз. Важно: TG-постер не должен публиковать все прошедшие за день слоты при включении. Он обрабатывает только текущий слот расписания в коротком окне после заданного времени и перед проверкой перечитывает настройки, включая `tg_poster_chat_id`. ## 6. Статусы ### 6.1. `raw_posts.status` Основные значения: - `storage_pending` - пост сохранён, ждёт загрузки в Telegram storage. - `storage_ready` - пост и медиа загружены/сохранены в storage. - `skipped` - пост сохранён как мусорный, если включено `parser_store_skipped_posts`. - `failed` - ошибка на этапе storage. ### 6.2. `raw_posts.qualification_status` Основные значения: - `pending` / `NULL` - ещё не квалифицирован. - `processing` - взят AI-квалификатором. - `accepted` - подходит для рерайта. - `rejected` - мусор/не подходит. - `maybe` - спорный по модели. Сейчас финальная логика может сводить `maybe` к accepted/rejected в зависимости от score и min_score. - `failed` - ошибка AI-квалификации. ### 6.3. `raw_posts.rewrite_status` Основные значения: - `pending` / `NULL` - ещё не переписан. - `processing` - взят AI-райтером. - `ready` - рерайт готов. - `failed` - ошибка рерайта. ### 6.4. `raw_posts.editorial_status` Основные значения: - `review` - на проверке редактора. - `edited` - сохранены ручные правки. - `accepted` - принято к публикации. - `rejected` - отклонено. ### 6.5. `raw_posts.publication_status` Статус финальной публикации. Сейчас используется TG-постером, позже может расшириться под VK/MAX/site. Основные значения: - `pending` - пост ещё не опубликован. - `published` - пост опубликован в Telegram. - `publish_failed` - публикация не удалась, причина лежит в `publication_error`. ## 7. VK-парсер Файл: `src/vk_parser_app/workers/parser.py`. ### 7.1. Что делает 1. Берёт активные источники из `sources`. 2. Если источник ещё не resolved, вызывает VK API и получает: - `external_id`; - `external_owner_id`; - нормальное имя. 3. Загружает посты со стены через VK API. 4. Определяет временное окно парсинга: - если есть `last_parsed_at`, берёт его минус overlap; - иначе если есть `parse_from`, берёт его; - иначе берёт `now - parser_new_source_lookback_days`. 5. Убирает уже известные посты по `(source_id, external_post_id)`. 6. При включенном `parser_dedupe_content_hash` также убирает дубли по `content_hash`. 7. Применяет политику мусорных постов. 8. Сохраняет raw post и media. 9. Создаёт job `vk.storage.copy`. ### 7.2. Политика мусорных постов Настройки: - `parser_skip_empty_text` - `parser_skip_no_media` - `parser_skip_text_too_short` - `parser_min_text_length` - `parser_skip_reposts` - `parser_store_skipped_posts` Если пост не проходит фильтр: - при `parser_store_skipped_posts=false` он вообще не сохраняется; - при `true` сохраняется со статусом `skipped` и `skip_reason`. Возможные `skip_reason`: - `empty_text` - `no_media` - `text_too_short` ### 7.3. Дедупликация Есть два уровня: 1. Точный дубль по `source_id + external_post_id`. 2. Контентный дубль по `content_hash`, если включен `parser_dedupe_content_hash`. `content_hash` считается по тексту и media attachment ids. ### 7.4. Настройки VK-парсера Ключевые: - `parser_interval_sec` - `parser_new_source_lookback_days` - `parser_reparse_overlap_minutes` - `parser_min_text_length` - `parser_skip_empty_text` - `parser_skip_no_media` - `parser_skip_text_too_short` - `parser_skip_reposts` - `parser_store_skipped_posts` - `parser_dedupe_content_hash` - `parser_source_pause_sec` - `vk_requests_per_second` - `vk_wall_page_size` - `vk_api_timeout_total_sec` - `vk_api_timeout_connect_sec` - `vk_rate_limit_sleep_sec` - `vk_api_retry_attempts` - `vk_api_retry_min_delay_sec` - `vk_api_retry_max_delay_sec` ## 8. Media storage uploader Файл: `src/vk_parser_app/workers/vk_storage_uploader.py`. Название файла историческое: сейчас фактическое хранилище медиа - Telegram, а не VK. ### 8.1. Что делает 1. Забирает job типа `vk.storage.copy`. 2. Загружает `raw_post` и media. 3. Скачивает изображения/видео. 4. Отправляет в Telegram storage channel. 5. Сохраняет Telegram file ids и message ids. 6. Помечает post как `storage_ready`. ### 8.2. Почему Telegram storage Изначально рассматривался VK storage через приватную группу VK. От идеи отказались, потому что: - права VK API и редактирование постов создают лишнюю сложность; - stable raw проще держать через Telegram bot API; - старый проект уже имел рабочую логику Telegram uploader; - Telegram file ids удобны для повторного отображения и хранения. ### 8.3. Ограничения Telegram Uploader учитывает: - media group limit до 10 элементов; - caption limit; - message limit; - flood control через `TelegramRetryAfter`; - retry/backoff; - размер видео; - длительность видео; - отдельные задержки между media upload и job. ### 8.4. Текст и ссылка на оригинал Логика должна стремиться к такому формату: - медиа и текст отправляются вместе, если текст влезает в caption; - если текст не влезает, текст отправляется отдельным сообщением/частями; - в конец добавляется ссылка на оригинал; - `#id` должен быть ссылкой на оригинальный VK-пост. ### 8.5. Временные файлы Временные файлы uploader должны жить в `/tmp` с префиксом `vkparser_tg_media_`. Для видео используется `yt-dlp` и временные файлы/netrc. Нужно регулярно проверять, что временные файлы удаляются после upload/failure. Это важный пункт техдолга. ### 8.6. Настройки uploader Исторически часть ключей в коде называется `telegram_*`, а часть миграций/админки - `media_*`, `tg_*`, `uploader_*`. Это стоит привести к единому виду. Сейчас важно проверять конкретный ключ в коде. Ключевые настройки: - `tg_media_channel_id` - `local_bot_api_url` - `uploader_interval_sec` - `uploader_download_timeout_sec` - `media_group_max_items` - `media_upload_delay_sec` - `media_post_job_pause_sec` - `max_media_attempts` - `tg_retry_attempts` - `tg_retry_backoff_max_sec` - `video_max_size_mb` - `video_max_duration_sec` - `uploader_yt_dlp_timeout_sec` Потенциальное расхождение: в коде встречаются `telegram_media_group_max_items`, `telegram_media_upload_delay_sec`, `telegram_retry_max_attempts`, `telegram_retry_backoff_max_sec`, `telegram_caption_limit`, `telegram_message_limit`, `telegram_text_overflow_marker`, `uploader_max_media_attempts`. Это надо унифицировать. ## 9. AI-квалификатор Файл: `src/vk_parser_app/workers/ai_qualifier.py`. ### 9.1. Задача Отсеять мусорные или неподходящие raw-посты до рерайта. Примеры мусора: - мемы; - политика; - вакансии; - низкоконтентные посты; - скидка без полезной новости; - оффтоп; - неправильный язык; - injection/попытка управлять моделью; - оружие, если оно не подходит под редакционную политику. ### 9.2. Вход в модель AI получает JSON-массив: ```json [ { "id": 123, "source": "Название источника", "original_url": "https://vk.com/wall-...", "media_count": 3, "media_types": ["photo"], "text": "исходный текст" } ] ``` Текст обрезается по `ai_qualifier_max_text_chars`. ### 9.3. Prompt + contract Промпт разделён на две части: 1. `ai_qualifier_prompt` - свободная редакционная инструкция. 2. `ai_qualifier_contract` - технический контракт JSON. Контракт требует: ```json { "results": [ { "id": 123, "score": 8, "decision": "accepted", "reason": "до 10 слов на русском", "reject_tag": null } ] } ``` ### 9.4. Настройки - `ai_qualifier_enabled` - `ai_qualifier_provider` - `ai_qualifier_model` - `ai_qualifier_api_key` - `ai_qualifier_api_base` - `ai_qualifier_prompt` - `ai_qualifier_contract` - `ai_qualifier_batch_size` - `ai_qualifier_min_score` - `ai_qualifier_max_text_chars` - `ai_qualifier_temperature` - `ai_qualifier_timeout_sec` - `ai_qualifier_interval_sec` ### 9.5. Деньги и токены `ai_qualification_batches` хранит: - `prompt_tokens` - `completion_tokens` - `total_tokens` - `estimated_cost_usd` Эта информация нужна, чтобы контролировать стоимость экспериментов с prompt/model. ## 10. AI-райтер Файл: `src/vk_parser_app/workers/ai_writer.py`. ### 10.1. Задача Взять посты: - `status='storage_ready'` - `qualification_status='accepted'` - `rewrite_status is NULL/pending/failed` И переписать их в короткую публикацию для Telegram/VK-канала. ### 10.2. Вход в модель AI получает JSON-объект: ```json { "task": "rewrite_accepted_posts", "categories": [ { "id": 1, "name": "Одежда", "tag": "одежда" }, { "id": 2, "name": "Снаряжение - подсумки, чехол для плит, нагрудники, боевые пояса и тд", "tag": "снаряжение" } ], "posts": [ { "id": 123, "producer_name": "academy_gear", "producer_tag": "academy_gear", "original_url": "https://vk.com/wall-...", "qualification_score": 8, "qualification_reason": "...", "media_count": 4, "media_types": ["photo"], "text": "исходный текст" } ] } ``` Текст обрезается по `ai_writer_max_text_chars`. ### 10.3. Prompt + contract Промпт разделён на: 1. `ai_writer_prompt` - редакционная инструкция. 2. `ai_writer_contract` - технический JSON-контракт. Свободная часть описывает: - роль редактора; - знания в тактическом/милитари/airsoft-снаряжении; - структуру поста; - стиль; - запрет на выдумывание фактов; - обязательное явное упоминание `producer_name` в тексте каждого рерайта; - выбор категории. Технический контракт требует: ```json { "rewrites": [ { "id": 123, "category_id": 2, "text": "готовый текст без ссылок и хэштегов", "notes": "короткая заметка для редактора или null" } ] } ``` Важно: модель возвращает `category_id`, а не тэг и не название. Код сам берёт категорию из `content_categories` и сохраняет: - `rewrite_category_id` - `rewrite_category` - `rewrite_category_tag` ### 10.4. Почему category_id, а не category Название категории может быть длинным и описательным: ```text Снаряжение - подсумки, чехол для плит, нагрудники, боевые пояса и тд ``` Модели легче вернуть число `2`, чем точно повторить длинную строку. Это снижает ошибки валидации и позволяет менять описание категории без переписывания логики. ### 10.5. Категории Категории редактируются в `/workers`, блок "Категории публикаций". Поля: - `Название` - `Тэг` - `ID` - `Статус` `ID` отображается как readonly. В БД это поле `content_categories.sort_order`. ### 10.6. Настройки - `ai_writer_enabled` - `ai_writer_provider` - `ai_writer_model` - `ai_writer_api_key` - `ai_writer_api_base` - `ai_writer_prompt` - `ai_writer_contract` - `ai_writer_categories` - legacy, сейчас основной источник `content_categories` - `ai_writer_batch_size` - `ai_writer_max_text_chars` - `ai_writer_temperature` - `ai_writer_timeout_sec` - `ai_writer_interval_sec` Текущая рекомендация: держать `ai_writer_batch_size=1`, потому что редактировать и отлаживать результат по одному посту проще и безопаснее. ### 10.7. Последний проверенный тест Один контролируемый прогон `ai-writer`: - batch `#6` - `posts_count=1` - `status=done` - model `anthropic/claude-haiku-4-5-20251001` - результат для post `#26` - `category_id=1` - `rewrite_category=Одежда` - `rewrite_category_tag=одежда` - `rewrite_status=ready` - `editorial_status=review` - стоимость около `$0.007296` После теста writer был возвращён в безопасное состояние: - `ai_writer_enabled=false` - `ai_writer_batch_size=1` - `worker_controls.ai-writer.enabled=false` - `ai-writer.service=active`, но работу не берёт. ## 11. Prompt-test Страница: `/prompt-test`. Назначение: песочница для проверки prompt райтера на существующем raw-посте. Важно: - сейчас тестируется именно `ai_writer_prompt`; - `ai_writer_contract` намеренно не добавляется в тест, чтобы можно было свободно смотреть стиль; - входной payload должен совпадать с боевым форматом райтера; - можно выбрать пост по фильтру квалификации; - ответ отображается как человеческий текст с нормальными переносами, плюс raw JSON. Риск: если тестировать prompt без contract, модель может не вернуть JSON. Это нормально для песочницы, но боевой worker всегда склеивает prompt + contract. ## 11.5. TG poster Файл: `src/vk_parser_app/workers/tg_poster.py`. Назначение: брать посты, принятые редактором, и публиковать их в Telegram по расписанию. Это первый финальный poster; позже рядом должны появиться VK/MAX/site poster-воркеры. ### 11.5.1. Условия публикации Пост берётся в очередь TG-постера, если: - `raw_posts.status='storage_ready'`; - `rewrite_status='ready'`; - `editorial_status='accepted'`; - `publication_status='pending'`. После успеха: - `publication_status='published'`; - `editorial_status='published'`, поэтому пост уходит из вкладки "Принято"; - заполняются `published_at`, `tg_publication_chat_id`, `tg_publication_thread_id`, `tg_publication_message_ids`, `tg_publication_url`. После ошибки: - `publication_status='publish_failed'`; - `editorial_status='publish_failed'`; - причина пишется в `publication_error`; - автоматический повтор не делается, чтобы ошибка не перетиралась на каждом слоте расписания. ### 11.5.2. Настройки Категория настроек: `TG Poster`. Ключевые: - `tg_poster_enabled` - второй защитный флаг, синхронизируется с кнопкой worker toggle; - `tg_poster_bot_token` - отдельный токен бота для финальной публикации; если пустой, worker берёт `TG_BOT_TOKEN`; - `tg_poster_chat_id` - куда публиковать, для теста `-1003712724327`; - `tg_poster_schedule_json` - строки расписания; - `tg_poster_interval_sec`; - `tg_poster_caption_limit`; - `tg_poster_message_limit`; - `tg_poster_media_group_max_items`; - `tg_poster_max_attempts`; - `tg_poster_retry_backoff_max_sec`; - `tg_poster_send_delay_sec`; - `tg_poster_text_overflow_caption`; - `tg_poster_recent_window`; - `tg_poster_category_repeat_penalty`; - `tg_poster_source_repeat_penalty`. Расписание редактируется на `/workers` отдельным блоком "Расписание TG-постера". Время указывается по Екатеринбургу (`Asia/Yekaterinburg`). Сейчас по умолчанию стоит 24 строки: каждый час `HH:00`, по 4 поста. ### 11.5.3. Telegram-лимиты Worker учитывает: - caption limit до 1024 символов; - message limit до 4096 символов с разбиением длинного текста на части; - media group limit до 10 элементов; - одиночное медиа отправляется через `send_photo`/`send_video`, а не через album API; - flood control через `TelegramRetryAfter`; - retry/backoff; - задержку между отправками. Если текст вместе с хэштегами влезает в caption, он публикуется под первым медиа. Если не влезает, caption становится `⬇️ Описание`, а полный текст публикуется следующим сообщением. ### 11.5.4. Формат текста Финальный текст собирается системно: ```text пост #category #source ``` Категорийный хэштег берётся из `content_categories.tag`, source-тэг - из `final_source_tag`, `rewrite_source_tag` или `sources.tag`. ### 11.5.5. Ранжирование Логика intentionally простая: 1. Берём кандидатов из принятых неопубликованных постов. 2. Базовый порядок: выше `qualification_score`, затем более старые принятые посты. 3. Смотрим последние `tg_poster_recent_window` опубликованных постов. 4. Штрафуем кандидата за повторы категории и источника среди недавних публикаций. 5. При выборе пачки дополнительно штрафуем повторы внутри текущего слота. Так категории и источники реже идут подряд, но алгоритм остаётся понятным и предсказуемым. ## 12. Админка Основной файл: `src/vk_parser_app/admin.py`. ### 12.1. Основные страницы - `/login` - вход. - `/sources` - источники парсинга. - `/raw` - raw-посты. - `/raw/{id}` - подробная карточка raw-поста, AI-ответы, batch info. - `/editor` - редакторская лента. - `/workers` - воркеры, настройки, категории публикаций, расписание TG-постера. - `/logs` - журнал действий, запросов и служебных событий админки. - `/users` - пользователи. - `/prompt-test` - тест промпта райтера. - `/vk/oauth/callback` - callback для VK OAuth, исторически нужен для экспериментов с VK токенами. ### 12.2. Sources На странице источников можно: - добавлять источник; - добавлять пачкой; - редактировать; - включать/выключать; - архивировать; - видеть статус и ошибки. Для VK важны: - platform `vk` - name - tag - url - active - parse_from ### 12.3. Raw Raw-страница показывает: - источник; - этап; - пост; - AI-оценку; - медиа-превью; - дату; - фильтры справа. Фильтры построены как sidebar в стиле интернет-магазина: - score min/max; - дата; - источник; - категория; - raw status; - qualification status; - rewrite status. ### 12.4. Editor Редакторская нужна для постов после AI-райтера. Возможности: - посмотреть посты на проверке; - принять; - отклонить; - открыть редактирование; - изменить текст; - поменять категорию; - поменять source tag; - скрыть/добавить медиа; - сохранить правки; - принять к публикации. Текущая логика: - `Сохранить правки` сохраняет изменения, но не принимает пост. - `Принять к публикации` сохраняет и переводит в `accepted`. ### 12.5. Workers Страница `/workers` содержит: - список воркеров и heartbeat; - категории публикаций; - расписание TG-постера; - настройки по разделам. Prompt-настройки имеют подсказки: - что можно менять безопасно; - что приходит на вход; - базовая JSON-схема. ### 12.6. Logs Страница `/logs` показывает `audit_log` за последние 30 дней. Что логируется: - успешные, неуспешные и заблокированные входы; - выходы; - GET/POST-запросы авторизованной админки, кроме `/health`, favicon и `/uploads`; - существующие предметные действия: источники, редакторская, категории, воркеры, настройки, пользователи, prompt-test. Фильтры: - поиск по action/entity/JSON; - action; - пользователь; - entity type; - диапазон дат. Старые записи чистятся автоматически, чтобы журнал не рос бесконечно. ## 13. Безопасность админки Сейчас реализовано: - password hash; - session token hash; - CSRF token; - таблица `admin_login_attempts`; - ограничение попыток входа; - users management; - fail2ban/ufw на сервере были добавлены в рамках настройки сервера. Что важно помнить: - API keys сейчас могут отображаться в админке целиком для удобства. Нельзя открывать страницу настроек при демонстрации экрана. - Нужно держать `.env` вне git. - Нужно регулярно менять временные пароли. - Root SSH по паролю лучше заменить на ключи и отключить password login. ## 14. Миграции Миграции лежат в `db/migrations`. Применение: ```bash cd /opt/vk-parser PYTHONPATH=src .venv/bin/python scripts/apply_migrations.py ``` Механика: - `schema_migrations` хранит имена применённых файлов; - файлы выполняются по алфавитному порядку; - каждая миграция выполняется в transaction; - повторно применённая миграция пропускается. Последние важные миграции: - `017_content_categories_and_media_cleanup.sql` - категории публикаций. - `018_writer_prompt_contract_cleanup.sql` - JSON-only вынесен из prompt в contract. - `019_writer_prompt_profile_style.sql` - новый подробный prompt райтера. - `020_writer_category_ids.sql` - `category_id` для AI-райтера, новые поля в raw_posts. - `021_writer_contract_no_empty_response.sql` - усиленный writer contract и сохранение неправильного ответа. - `024_tg_poster.sql` - TG-постер, статусы публикации, расписание, лимиты и prompt-лимит райтера под caption. - `026_writer_separator_17_chars.sql` - строгая разделительная полоса `━━━━━━━━━━━━━━━━━` в prompt/contract и существующих рерайтах. - `031_writer_require_producer_name.sql` - AI-райтер обязан явно упоминать `producer_name` в тексте каждого рерайта. - `032_tg_reactor.sql` - TG-реактор, таблица `post_reactions`, настройки реакций и worker flag. - `033_tg_reactor_since.sql` - настройка `tg_reactor_since`, чтобы реактор не проходил по старым TG-публикациям при включении. - `034_tg_reactor_reaction_pause.sql` - пауза между успешными реакциями TG-реактора, чтобы безопасно догонять архив. - `035_uploader_video_format_limits.sql` - ограничение высоты VK-видео для `yt-dlp` и увеличение timeout скачивания, чтобы ролики укладывались в Telegram Bot API. - `039_max_poster.sql` - MAX-постер, расписание как у TG, настройки MAX Bot API, лимиты сообщения/медиа, ожидание обработки видео и double safety flags. ## 15. Локальный запуск Установка: ```bash python -m venv .venv .venv\Scripts\activate pip install -r requirements.txt ``` Для Windows PowerShell: ```powershell $env:PYTHONPATH="src" python scripts/apply_migrations.py uvicorn vk_parser_app.admin:app --host 0.0.0.0 --port 8080 ``` Воркеры локально: ```powershell $env:PYTHONPATH="src" python -m vk_parser_app.workers.parser python -m vk_parser_app.workers.vk_storage_uploader python -m vk_parser_app.workers.ai_qualifier python -m vk_parser_app.workers.ai_writer python -m vk_parser_app.workers.tg_poster python -m vk_parser_app.workers.max_poster ``` На Windows при `py_compile` может быть ошибка доступа к `__pycache__`. Тогда: ```powershell $env:PYTHONPYCACHEPREFIX="$env:TEMP\n8_pycache" python -m py_compile src\vk_parser_app\admin.py ``` ## 16. Сервер Рабочий путь: ```bash /opt/vk-parser ``` Основные команды: ```bash cd /opt/vk-parser git pull --ff-only PYTHONPATH=src .venv/bin/python scripts/apply_migrations.py systemctl restart vk-parser-admin.service ``` Проверка сервисов: ```bash systemctl is-active vk-parser-admin.service systemctl is-active vk-parser.service systemctl is-active vk-storage-uploader.service systemctl is-active ai-qualifier.service systemctl is-active ai-writer.service systemctl is-active tg-poster.service systemctl is-active max-poster.service systemctl is-active telegram-bot-api.service ``` Логи: ```bash journalctl -u vk-parser-admin.service -n 100 --no-pager journalctl -u vk-parser.service -n 100 --no-pager journalctl -u vk-storage-uploader.service -n 100 --no-pager journalctl -u ai-qualifier.service -n 100 --no-pager journalctl -u ai-writer.service -n 100 --no-pager journalctl -u tg-poster.service -n 100 --no-pager journalctl -u max-poster.service -n 100 --no-pager journalctl -u telegram-bot-api.service -n 100 --no-pager ``` ## 17. Git workflow Локальный репозиторий: ```bash git status git add ... git commit -m "Message" git push ``` На сервере origin настроен через deploy key. Обычный деплой: ```bash cd /opt/vk-parser git pull --ff-only PYTHONPATH=src .venv/bin/python scripts/apply_migrations.py systemctl restart vk-parser-admin.service ``` Если менялся конкретный worker, перезапустить его: ```bash systemctl restart ai-writer.service systemctl restart ai-qualifier.service systemctl restart vk-parser.service systemctl restart vk-storage-uploader.service ``` ## 18. Контролируемый тест AI-райтера Важно: не включать AI writer обычным способом, если нужно проверить один пост. Иначе живой systemd service может начать брать очередь самостоятельно. Правильная схема: 1. Остановить сервис: ```bash systemctl stop ai-writer.service ``` 2. Временно включить: - `worker_controls.ai-writer.enabled=true` - `app_settings.ai_writer_enabled=true` - `app_settings.ai_writer_batch_size=1` 3. Выполнить один `AIWriterWorker.run_once()`. 4. Вернуть: - `worker_controls.ai-writer.enabled=false` - `app_settings.ai_writer_enabled=false` - `app_settings.ai_writer_batch_size=1` 5. Запустить сервис обратно: ```bash systemctl start ai-writer.service ``` Так service остаётся active, но в disabled-режиме. ## 19. Контролируемый тест AI-квалификатора Схема такая же: 1. Остановить `ai-qualifier.service`. 2. Включить оба флага. 3. Поставить нужный `ai_qualifier_batch_size`. 4. Выполнить `AIQualifierWorker.run_once()`. 5. Вернуть настройки. 6. Запустить service обратно. Важно: при batch size 25 и активном service можно случайно получить несколько batch подряд. Так уже происходило. Поэтому сначала останавливать service. ## 20. Известные хвосты и техдолг ### 20.1. Старые AI writer batches В истории есть старые тестовые batches: - batch `#4` со статусом `processing`; - batch `#5` со статусом `failed`, созданный во время теста на 5 постов. Они не влияют на текущую очередь, но могут путать в админке. Их стоит аккуратно пометить как failed/cancelled или скрывать служебные тесты. ### 20.2. Название `vk_storage_uploader.py` Файл исторически называется VK storage uploader, но фактически загружает в Telegram. Можно позже переименовать: ```text vk_storage_uploader.py -> tg_storage_uploader.py JOB_TYPE_VK_STORAGE_COPY -> tg.storage.copy ``` Делать только миграцией и аккуратно, чтобы не потерять jobs. ### 20.3. Настройки Telegram uploader Часть ключей в UI/миграциях и в коде отличается по именам. Нужно унифицировать, чтобы админка реально управляла всеми параметрами uploader. ### 20.4. README устарел `README.md` был написан на раннем этапе, когда ещё рассматривался VK storage. Сейчас он полезен как quick start, но его надо обновить или заменить ссылкой на `PROJECT.md`. ### 20.5. Медиа cleanup Нужно отдельно проверить: - удаляются ли временные файлы из `/tmp`; - что происходит с локально загруженными медиа редактора; - как чистить orphan files; - как чистить Telegram storage, если редактор удалил медиа. ### 20.6. Категории и старые посты Новые посты получают `rewrite_category_id`. Старые посты могут иметь только текстовую категорию. Это нормально, но фильтры/отображение должны учитывать оба варианта. ### 20.7. Prompt-test без contract Это осознанное решение. Но если хочется тестировать боевой формат, стоит добавить переключатель: - "тестировать только стиль" - "тестировать style + contract" ## 21. Что делать дальше Ближайшие полезные задачи: 1. Обновить `README.md`, чтобы он не противоречил реальности. 2. Унифицировать ключи настроек Telegram uploader. 3. Добавить отдельную страницу batch history для AI. 4. Аккуратно закрыть/пометить старые зависшие AI writer batches. 5. Улучшить отображение категорий в editor/raw: - показывать `category_id`; - показывать `tag`; - показывать source tag. 6. Добавить режим "боевой prompt-test" с contract. 7. Добавить будущие постеры рядом с TG-постером: - VK; - MAX; - сайт. 8. Добавить backup strategy: - PostgreSQL dump; - `.env` отдельно; - uploads/editor_media. 9. Навести порядок в UI, если админка кажется слишком самописной: - либо продолжить текущий server-rendered UI; - либо вынести frontend в React/shadcn; - либо подключить готовую admin-систему. ## 22. Короткий mental model Если нужно быстро вспомнить проект: ```text sources -> parser -> raw_posts + raw_post_media -> jobs(vk.storage.copy) -> Telegram storage uploader -> status=storage_ready -> AI qualifier -> qualification_status=accepted/rejected -> AI writer -> rewrite_status=ready, editorial_status=review -> editor -> accepted/rejected/edited -> tg-poster -> publication_status=published/publish_failed ``` Главные таблицы: ```text sources raw_posts raw_post_media jobs worker_controls worker_heartbeats app_settings publication_runs ai_qualification_batches ai_writer_batches content_categories admin_users admin_sessions ``` Главные файлы: ```text src/vk_parser_app/admin.py src/vk_parser_app/workers/parser.py src/vk_parser_app/workers/vk_storage_uploader.py src/vk_parser_app/workers/ai_qualifier.py src/vk_parser_app/workers/ai_writer.py src/vk_parser_app/workers/tg_poster.py src/vk_parser_app/vk_api.py db/migrations/*.sql ``` Главное правило эксплуатации: ```text Перед тестом AI-воркера останавливай systemd service, иначе живой worker может параллельно забрать очередь. ``` ## 23. Сайт Forma N8 на Ghost 21 июня 2026 года лента сайта перенесена на Ghost CMS 6.46.0. Это основа для дальнейшей работы над сайтом, SEO и отдельным site-poster worker. ### 23.1. Сервер и домен - VPS: `139.100.234.51`; - SSH: `root@139.100.234.51:22`, пароль `7LaoHsQ1JxnK`; - подключение к этому серверу выполнять из Python через `paramiko`; - домен: `f-n8.ru`; - Ghost: `/var/www/f-n8.ru`; - systemd: `ghost_f-n8-ru.service`; - nginx vhost: `/etc/nginx/sites-available/f-n8.ru`; - MySQL database: `ghost_prod`; - публичный Ghost порт слушает только `127.0.0.1:2368`. Сервер основного парсера доступен по SSH: `root@109.120.156.205:22`, пароль `OU8gUSCtQYcf`. Подключение к обоим серверам выполняется через Python-библиотеку `paramiko`. 23 июня 2026 года устранён 502 после автоматического обновления MySQL: `unattended-upgrade` остановил MySQL, а жёсткая зависимость `Requires=mysql.service` вслед за ним остановила Ghost без последующего запуска. В `ghost_f-n8-ru.service` зависимость заменена на `Wants=mysql.service` при сохранённом `After=mysql.service`; unit включён и использует `Restart=always`. Резервная копия исходного unit: `/etc/systemd/system/ghost_f-n8-ru.service.bak-20260623-1115`. 25 июня 2026 года из основного CSS Ghost Admin удалены 21 внешних `@import` с `fonts.bunny.net`: CDN сбрасывал соединения у части клиентов, из-за чего `/ghost/` оставался на бесконечной загрузке. В `index.html` к административному CSS добавлен cache-busting query `?v=localfonts-20260625`; резервная копия находится в `/root/forma-n8-backups/ghost-admin-fonts-20260624-190120/`. Обновление Ghost может перезаписать этот патч, поэтому после обновлений нужно проверить наличие `fonts.bunny.net` в `current/core/built/admin/assets/*.css`. В тот же день отключена проверка нового устройства для staff-входов: `security.staffDeviceVerification=false` в `/var/www/f-n8.ru/config.production.json`. Без рабочего SMTP эта проверка завершалась ошибкой `Failed to send email` и не позволяла новым администраторам войти. Резервная копия конфигурации: `/var/www/f-n8.ru/config.production.json.bak-device-verification-20260624-190851`. 22 июня 2026 года DNS начал разрешаться публично: `f-n8.ru` и `www.f-n8.ru` указывают на `139.100.234.51`. Выпущен сертификат Let's Encrypt для обоих имён, включено автоматическое обновление через `certbot.timer`, HSTS и принудительный HTTPS. Основной адрес Ghost и canonical URL: `https://f-n8.ru`; `www` перенаправляется на основной домен. Для подтверждения сайта в Яндекс Вебмастере nginx отдаёт статический файл `https://f-n8.ru/yandex_9c5e1348b34e9b34.html`; исходный файл хранится в `/var/www/f-n8.ru/yandex_9c5e1348b34e9b34.html`, exact-location настроен в `/etc/nginx/sites-available/f-n8.ru`. ### 23.2. Тема и контент - исходники темы в репозитории: `forma_n8_site/ghost-theme/forma-n8`; - установленная тема: `/var/www/f-n8.ru/content/themes/forma-n8`; - тема проходит Ghost gscan без ошибок; - favicon и кириллические Roboto Condensed лежат внутри темы; - постоянный URL публикаций: `/news/{slug}/`; - категории и производители переносятся в Ghost tags; - RSS: `/rss/`; - sitemap: `/sitemap.xml`; - админка: `/ghost/`. В Ghost перенесены 12 опубликованных материалов старой ленты с исходными slug, датами, SEO-полями и локальными изображениями. Стартовые публикации Ghost удалены. Старый FastAPI-сайт и PostgreSQL пока сохранены как резерв на сервере; предмиграционный backup находится в `/root/forma-n8-backups/20260621-172603/`. ### 23.3. Доступы и будущий site-poster Секреты не хранятся в git: - MySQL credentials: `/root/ghost-db.env`; - initial owner credentials: `/root/ghost-owner-credentials`; - Admin API key интеграции `Forma N8 Site Poster`: `/root/ghost-admin-api-key`. Site-poster использует Ghost Admin API key, создаёт публикации через Admin API, загружает медиа в Ghost и записывает внешний Ghost post id/url в общую таблицу публикаций. Прямые INSERT в Ghost posts для рабочего постинга запрещены. ### 23.4. Категории сайта Миграция `037_site_category_fields.sql` разделяет три разных назначения категории: - `name` — подробное описание категории для AI; - `tag` — неизменяемый тэг для хэштега в соцсетях; - `site_name` — тэг с заменой `_` на пробел и нормальным регистром; - `site_slug` — стабильный латинский хвост URL; - `site_enabled` — разрешение публикации категории на сайт. Категория `18` / `НЦК` имеет `site_enabled=FALSE`. Архивы Ghost используют URL `/category/{site_slug}/`; URL публикации не содержит категорию и остаётся `/news/{slug}/`, чтобы смена категории не ломала canonical. Производители хранятся во внутренних Ghost tags и не попадают в sitemap как категории. ### 23.5. Изображения и аналитика В ленте показывается только `feature_image`. Если у публикации есть дополнительные изображения, site-poster должен добавить их в тело статьи нативной Ghost Gallery; тема поддерживает плитку и полноэкранный просмотр. Перед загрузкой изображения нужно: - применить EXIF orientation и удалить метаданные; - ограничить размер до 2000x2000 без увеличения; - сохранить в WebP quality 82; - первое изображение назначить обложкой, остальные поместить в галерею. Контрольный замер на первых 12 публикациях: исходные изображения занимали 12,09 МБ, после такой обработки — 3,18 МБ, в среднем 272 КБ на файл. В тему добавлен счётчик Яндекс Метрики `110060335` с clickmap, trackLinks, accurateTrackBounce и webvisor. Google Tag Manager подключён в общем Ghost-шаблоне с контейнером `GTM-MM4FR8LJ`: script находится в начале ``, noscript iframe — сразу после открывающего ``. ### 23.6. Ручной batch site-poster Скрипт `scripts/publish_site_batch.py` публикует явно выбранные raw-посты через Ghost Admin API: ```bash PYTHONPATH=/opt/vk-parser/src .venv/bin/python \ scripts/publish_site_batch.py --ids 26,455 ``` Скрипт: - не публикует `site_enabled=FALSE` и уже опубликованные на сайт записи; - скачивает сохранённые фото через локальный Telegram Bot API; - применяет EXIF orientation, 2000x2000, WebP quality 82; - назначает первое фото `feature_image`; - собирает остальные фото в Ghost Gallery; - создаёт статью через Admin API; - записывает результат или ошибку в `post_publications` с platform `site`. Первый боевой batch 22 июня 2026 года: 10 статей, 46 фотографий, все публикации успешны. Upload payload после нормализации занял около 10,9 МБ. Ghost штатно хранит рабочую версию и исходную WebP-копию `_o` для последующей регенерации размеров: суммарно 92 файла / 21,12 МБ на диске. В тот же день выполнен полный photo-ready backfill. В `post_publications` создан backfill для 12 ранних импортов, после чего опубликован весь оставшийся хвост без дублей. Итог: - 310 Ghost posts / 310 `site · published`; - 0 `site · publish_failed`; - 0 оставшихся photo-ready постов; - 16 video-only постов опубликованы через нативные Ghost video cards; - 5 длинных VK-видео остаются `link_only / video too long` без Telegram file_id и не публикуются, чтобы на сайте не было карточек без медиа; - video pipeline загружает MP4 через Ghost `/media/upload/`, извлекает WebP-обложку ffmpeg и хранит ролик в нативном Lexical `video` node; - ролики больше 40 МБ или выше 720p перекодируются в H.264/AAC, максимум 720p, target около 38 МБ, preset `fast`, `faststart`; - после photo backfill каталог Ghost images занимал около 453 МБ; актуальный общий расход проверяется отдельно с учётом MP4. Режим полного backfill: ```bash PYTHONPATH=/opt/vk-parser/src .venv/bin/python \ scripts/publish_site_batch.py --all-ready ``` Он исключает уже опубликованные записи и ранжирует очередь так, чтобы категории и источники реже повторялись подряд. ### 23.6.1. Автоматический site-poster - worker: `src/vk_parser_app/workers/site_poster.py`; - systemd: `site-poster.service`; - двойное включение: `worker_controls.site-poster.enabled` и `app_settings.site_poster_enabled`; - интервал по умолчанию: 300 секунд; - за проход: до 10 photo-ready и 1 video-only материала; - worker вызывает общий проверенный pipeline `scripts/publish_site_batch.py`, который защищён lock-файлом `/tmp/forma-n8-site-poster.lock` от параллельных ручных и автоматических запусков; - обычный автоматический запуск не повторяет `publish_failed`; для явного ручного повтора используется флаг `--retry-failed`. ### 23.7. Навигация Ghost - лента выводит 25 постов на страницу; - H1 главной: `Новости тактического снаряжения и экипировки`; - только на первой странице `/`, после ленты и пагинации, выводится статический SEO-блок `О новостной ленте Forma N8` с описанием тематики, категорий и производителей; на `/page/2/` и далее блок не дублируется; - пагинация полностью русская; - шапка: `Лента`, выпадающие `Категории`, `О проекте`, поиск; - ссылки на каналы Forma N8 (`https://vk.com/forma_n8` и `https://t.me/forma_n8`) с SVG-иконками присутствуют в шапке, каждом превью, открытой статье, разделе `О проекте` и футере; - описание проекта начинается с текста: «Ежедневно мы собираем новости снаряжения с сотен источников, отбираем лучшее и публикуем в одном месте!»; - футер: `RSS`, `Карта сайта`; - старые `/tag/{slug}/` перенаправляются на `/category/{slug}/`; - `/about/` содержит описание проекта; - ссылка `Первоисточник` удалена из существующих статей и не добавляется новым site-poster batch. ### 23.8. SEO Ghost - название сайта: `Forma N8`; - слоган главной: `Forma N8 — все новости снаряжения в одном месте`; - description: `Forma N8 — все новости снаряжения в одном месте. Новинки, обзоры и события производителей тактического, туристического и специального снаряжения.`; - title статьи: `Forma N8 — Название статьи`; - title раздела: `Forma N8 — Название раздела`; - meta keywords и «SEO-хештеги» не используются: категории оформляются обычными Ghost tags, формирующими структуру сайта и посадочные страницы; - тема должна сохранять один H1, canonical, Open Graph, Twitter Cards, JSON-LD, RSS и sitemap, которые формирует Ghost через `ghost_head`; - SSH-доступ и изменения на серверах `139.100.234.51` и `109.120.156.205` выполняются через Python-библиотеку `paramiko`. доступ к серверу по ssh root@109.120.156.205 -p 22 пароль OU8gUSCtQYcf