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

79 KiB
Raw Permalink Blame History

N8 Parser: проектная документация

Этот документ описывает текущую архитектуру проекта n8_parser: зачем он нужен, как устроены БД, воркеры, админка, AI-контур, медиа-хранилище, деплой и типовые операции. Документ написан как рабочая карта проекта, чтобы к нему можно было вернуться через месяц и быстро понять, где что лежит и почему сделано именно так.

1. Цель проекта

Проект автоматизирует сбор и редакционную обработку постов из VK-сообществ производителей, магазинов и площадок по тактическому, милитари и airsoft-снаряжению.

Основная цепочка:

Источники 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. Структура проекта

.
├── 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-массив:

[
  {
    "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.

Контракт требует:

{
  "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-объект:

{
  "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 в тексте каждого рерайта;
  • выбор категории.

Технический контракт требует:

{
  "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

Название категории может быть длинным и описательным:

Снаряжение - подсумки, чехол для плит, нагрудники, боевые пояса и тд

Модели легче вернуть число 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. Формат текста

Финальный текст собирается системно:

пост

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

Применение:

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. Локальный запуск

Установка:

python -m venv .venv
.venv\Scripts\activate
pip install -r requirements.txt

Для Windows PowerShell:

$env:PYTHONPATH="src"
python scripts/apply_migrations.py
uvicorn vk_parser_app.admin:app --host 0.0.0.0 --port 8080

Воркеры локально:

$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__. Тогда:

$env:PYTHONPYCACHEPREFIX="$env:TEMP\n8_pycache"
python -m py_compile src\vk_parser_app\admin.py

16. Сервер

Рабочий путь:

/opt/vk-parser

Основные команды:

cd /opt/vk-parser
git pull --ff-only
PYTHONPATH=src .venv/bin/python scripts/apply_migrations.py
systemctl restart vk-parser-admin.service

Проверка сервисов:

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

Логи:

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

Локальный репозиторий:

git status
git add ...
git commit -m "Message"
git push

На сервере origin настроен через deploy key.

Обычный деплой:

cd /opt/vk-parser
git pull --ff-only
PYTHONPATH=src .venv/bin/python scripts/apply_migrations.py
systemctl restart vk-parser-admin.service

Если менялся конкретный worker, перезапустить его:

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. Остановить сервис:
systemctl stop ai-writer.service
  1. Временно включить:
  • worker_controls.ai-writer.enabled=true
  • app_settings.ai_writer_enabled=true
  • app_settings.ai_writer_batch_size=1
  1. Выполнить один AIWriterWorker.run_once().

  2. Вернуть:

  • worker_controls.ai-writer.enabled=false
  • app_settings.ai_writer_enabled=false
  • app_settings.ai_writer_batch_size=1
  1. Запустить сервис обратно:
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. Можно позже переименовать:

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

Если нужно быстро вспомнить проект:

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

Главные таблицы:

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

Главные файлы:

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

Главное правило эксплуатации:

Перед тестом 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:

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:

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