79 KiB
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-LDNewsArticle. -
Интеграция с сайтом строится через 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-постер повторяет отправку только при
TelegramRetryAfterflood-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те файлы, которые уже успешно скачаны и подготовлены в текущем проходе: их Telegramfile_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. Текст режется по лимиту MAX4000символов, медиа загружается в MAX отдельно через/uploads, а результат пишется вpost_publications(platform='max'). -
MAX-постер для видео использует не
original_urlVK-страницы, а готовый файл из 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, RGB255/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-фото, но всё равно публикует текст и существующие VKphoto.../video...attachments. -
VK-постер исключает из очереди video-only записи с
raw_post_media.status='link_only': VK API не разрешает прикреплять часть чужих видео без предварительной загрузки в целевое сообщество. Медиаlink_onlyтакже не добавляются к смешанным постам, а публикации со статусомpublish_failedне возвращаются автоматически в каждый следующий часовой слот и требуют явного ручного повтора.
4.1. Секреты в .env, операционные настройки в БД
В .env должны лежать только секреты и базовые параметры окружения:
APP_SECRET_KEYADMIN_BOOTSTRAP_LOGINADMIN_BOOTSTRAP_PASSWORD- параметры подключения к PostgreSQL
VK_ACCESS_TOKENTG_BOT_TOKEN- и другие токены/пароли
В БД, в таблице app_settings, лежат операционные настройки:
- интервалы воркеров;
- batch size;
- лимиты текста;
- включение/выключение AI;
- выбор provider/model;
- prompt/contract;
- Telegram storage channel id;
- лимиты Telegram uploader;
- параметры парсинга.
Причина: секреты опасно показывать в админке и хранить в обычных настройках, а операционные параметры удобно менять без деплоя.
4.2. Worker control состоит из двух флагов
Для AI-воркеров используется двойная защита:
worker_controls.enabledapp_settings.ai_*_enabled
Воркер начнёт работу только если включены оба. Это сделано специально, чтобы случайное включение одного тумблера не запускало расход денег на LLM.
5. БД: ключевые таблицы
5.1. admin_users
Пользователи админки.
Основные поля:
idloginpassword_hashis_activecreated_at
Первый пользователь создаётся из ADMIN_BOOTSTRAP_LOGIN и ADMIN_BOOTSTRAP_PASSWORD.
5.2. admin_sessions
Сессии админки.
Хранит:
token_hashuser_idcsrf_tokenexpires_atcreated_at
Сырой session token в БД не хранится.
5.3. sources
Источники для парсинга.
Сейчас реально используется только platform='vk', но схема заложена под другие площадки.
Важные поля:
idplatformnametagurlexternal_idexternal_owner_idactivestatusstatus_msglast_checked_atlast_parsed_atparse_frompriorityarchived_at
tag нужен для будущих хэштегов и финального оформления.
5.4. raw_posts
Главная таблица жизненного цикла поста.
Основные поля raw:
idsource_idplatformexternal_post_idexternal_owner_idoriginal_urlraw_textraw_jsontext_hashcontent_hashposted_atcreated_atupdated_atstatusskip_reason
Storage-поля:
storage_post_urltg_storage_chat_idtg_storage_thread_idtg_storage_message_ids
AI-квалификация:
qualification_statusqualification_scorequalification_decisionqualification_model_decisionqualification_reasonqualification_reject_tagqualification_modelqualification_prompt_hashqualification_batch_idqualified_at
AI-райтер:
rewrite_statusrewritten_textrewrite_notesrewrite_modelrewrite_prompt_hashrewrite_batch_idrewrite_category_idrewrite_categoryrewrite_category_tagrewrite_source_tagrewritten_at
Редакторская:
editorial_statusfinal_textfinal_category_idfinal_categoryfinal_category_tagfinal_source_tageditor_notesreviewed_byreviewed_atedited_at
5.5. raw_post_media
Медиа исходного поста.
Основные поля:
idraw_post_idplatformmedia_typeoriginal_urloriginal_attachment_idpreview_urlstorage_urlstorage_attachment_idtg_file_idtg_file_unique_idwidthheightduration_secsort_orderstatusattemptserroreditor_hiddeneditor_addedlocal_path
Удаление медиа в редакторской не обязано физически удалять старое медиа из Telegram. В редакторском контуре оно помечается как скрытое через editor_hidden.
5.6. jobs
Очередь фоновых задач.
Основные поля:
idtypeentity_typeentity_idpayload_jsonstatusattemptsmax_attemptsnext_run_atlocked_bylocked_atlast_error
Сейчас основной тип:
vk.storage.copy
Название историческое: сначала планировался VK storage, потом медиа-сторедж вернулся в Telegram. Тип задачи пока не переименован.
5.7. worker_controls
Включение/выключение воркеров.
Поля:
nameenabledsettings_jsonupdated_byupdated_at
Имена воркеров:
vk-parservk-storage-uploaderai-qualifierai-writertg-postermax-poster
5.8. worker_heartbeats
Состояние воркеров для админки.
Поля:
nameheartbeat_atstatuscurrent_job_idmeta_json
5.9. app_settings
Настройки приложения.
Поля:
keyvalue_jsonvalue_typetitledescriptioncategoryupdated_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. Что делает
- Берёт активные источники из
sources. - Если источник ещё не resolved, вызывает VK API и получает:
external_id;external_owner_id;- нормальное имя.
- Загружает посты со стены через VK API.
- Определяет временное окно парсинга:
- если есть
last_parsed_at, берёт его минус overlap; - иначе если есть
parse_from, берёт его; - иначе берёт
now - parser_new_source_lookback_days.
- если есть
- Убирает уже известные посты по
(source_id, external_post_id). - При включенном
parser_dedupe_content_hashтакже убирает дубли поcontent_hash. - Применяет политику мусорных постов.
- Сохраняет raw post и media.
- Создаёт job
vk.storage.copy.
7.2. Политика мусорных постов
Настройки:
parser_skip_empty_textparser_skip_no_mediaparser_skip_text_too_shortparser_min_text_lengthparser_skip_repostsparser_store_skipped_posts
Если пост не проходит фильтр:
- при
parser_store_skipped_posts=falseон вообще не сохраняется; - при
trueсохраняется со статусомskippedиskip_reason.
Возможные skip_reason:
empty_textno_mediatext_too_short
7.3. Дедупликация
Есть два уровня:
- Точный дубль по
source_id + external_post_id. - Контентный дубль по
content_hash, если включенparser_dedupe_content_hash.
content_hash считается по тексту и media attachment ids.
7.4. Настройки VK-парсера
Ключевые:
parser_interval_secparser_new_source_lookback_daysparser_reparse_overlap_minutesparser_min_text_lengthparser_skip_empty_textparser_skip_no_mediaparser_skip_text_too_shortparser_skip_repostsparser_store_skipped_postsparser_dedupe_content_hashparser_source_pause_secvk_requests_per_secondvk_wall_page_sizevk_api_timeout_total_secvk_api_timeout_connect_secvk_rate_limit_sleep_secvk_api_retry_attemptsvk_api_retry_min_delay_secvk_api_retry_max_delay_sec
8. Media storage uploader
Файл: src/vk_parser_app/workers/vk_storage_uploader.py.
Название файла историческое: сейчас фактическое хранилище медиа - Telegram, а не VK.
8.1. Что делает
- Забирает job типа
vk.storage.copy. - Загружает
raw_postи media. - Скачивает изображения/видео.
- Отправляет в Telegram storage channel.
- Сохраняет Telegram file ids и message ids.
- Помечает 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_idlocal_bot_api_urluploader_interval_secuploader_download_timeout_secmedia_group_max_itemsmedia_upload_delay_secmedia_post_job_pause_secmax_media_attemptstg_retry_attemptstg_retry_backoff_max_secvideo_max_size_mbvideo_max_duration_secuploader_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
Промпт разделён на две части:
ai_qualifier_prompt- свободная редакционная инструкция.ai_qualifier_contract- технический контракт JSON.
Контракт требует:
{
"results": [
{
"id": 123,
"score": 8,
"decision": "accepted",
"reason": "до 10 слов на русском",
"reject_tag": null
}
]
}
9.4. Настройки
ai_qualifier_enabledai_qualifier_providerai_qualifier_modelai_qualifier_api_keyai_qualifier_api_baseai_qualifier_promptai_qualifier_contractai_qualifier_batch_sizeai_qualifier_min_scoreai_qualifier_max_text_charsai_qualifier_temperatureai_qualifier_timeout_secai_qualifier_interval_sec
9.5. Деньги и токены
ai_qualification_batches хранит:
prompt_tokenscompletion_tokenstotal_tokensestimated_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
Промпт разделён на:
ai_writer_prompt- редакционная инструкция.ai_writer_contract- технический JSON-контракт.
Свободная часть описывает:
- роль редактора;
- знания в тактическом/милитари/airsoft-снаряжении;
- структуру поста;
- стиль;
- запрет на выдумывание фактов;
- обязательное явное упоминание
producer_nameв тексте каждого рерайта; - выбор категории.
Технический контракт требует:
{
"rewrites": [
{
"id": 123,
"category_id": 2,
"text": "готовый текст без ссылок и хэштегов",
"notes": "короткая заметка для редактора или null"
}
]
}
Важно: модель возвращает category_id, а не тэг и не название. Код сам берёт категорию из content_categories и сохраняет:
rewrite_category_idrewrite_categoryrewrite_category_tag
10.4. Почему category_id, а не category
Название категории может быть длинным и описательным:
Снаряжение - подсумки, чехол для плит, нагрудники, боевые пояса и тд
Модели легче вернуть число 2, чем точно повторить длинную строку. Это снижает ошибки валидации и позволяет менять описание категории без переписывания логики.
10.5. Категории
Категории редактируются в /workers, блок "Категории публикаций".
Поля:
НазваниеТэгIDСтатус
ID отображается как readonly. В БД это поле content_categories.sort_order.
10.6. Настройки
ai_writer_enabledai_writer_providerai_writer_modelai_writer_api_keyai_writer_api_baseai_writer_promptai_writer_contractai_writer_categories- legacy, сейчас основной источникcontent_categoriesai_writer_batch_sizeai_writer_max_text_charsai_writer_temperatureai_writer_timeout_secai_writer_interval_sec
Текущая рекомендация: держать ai_writer_batch_size=1, потому что редактировать и отлаживать результат по одному посту проще и безопаснее.
10.7. Последний проверенный тест
Один контролируемый прогон ai-writer:
- batch
#6 posts_count=1status=done- model
anthropic/claude-haiku-4-5-20251001 - результат для post
#26 category_id=1rewrite_category=Одеждаrewrite_category_tag=одеждаrewrite_status=readyeditorial_status=review- стоимость около
$0.007296
После теста writer был возвращён в безопасное состояние:
ai_writer_enabled=falseai_writer_batch_size=1worker_controls.ai-writer.enabled=falseai-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 простая:
- Берём кандидатов из принятых неопубликованных постов.
- Базовый порядок: выше
qualification_score, затем более старые принятые посты. - Смотрим последние
tg_poster_recent_windowопубликованных постов. - Штрафуем кандидата за повторы категории и источника среди недавних публикаций.
- При выборе пачки дополнительно штрафуем повторы внутри текущего слота.
Так категории и источники реже идут подряд, но алгоритм остаётся понятным и предсказуемым.
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 может начать брать очередь самостоятельно.
Правильная схема:
- Остановить сервис:
systemctl stop ai-writer.service
- Временно включить:
worker_controls.ai-writer.enabled=trueapp_settings.ai_writer_enabled=trueapp_settings.ai_writer_batch_size=1
-
Выполнить один
AIWriterWorker.run_once(). -
Вернуть:
worker_controls.ai-writer.enabled=falseapp_settings.ai_writer_enabled=falseapp_settings.ai_writer_batch_size=1
- Запустить сервис обратно:
systemctl start ai-writer.service
Так service остаётся active, но в disabled-режиме.
19. Контролируемый тест AI-квалификатора
Схема такая же:
- Остановить
ai-qualifier.service. - Включить оба флага.
- Поставить нужный
ai_qualifier_batch_size. - Выполнить
AIQualifierWorker.run_once(). - Вернуть настройки.
- Запустить 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. Что делать дальше
Ближайшие полезные задачи:
- Обновить
README.md, чтобы он не противоречил реальности. - Унифицировать ключи настроек Telegram uploader.
- Добавить отдельную страницу batch history для AI.
- Аккуратно закрыть/пометить старые зависшие AI writer batches.
- Улучшить отображение категорий в editor/raw:
- показывать
category_id; - показывать
tag; - показывать source tag.
- показывать
- Добавить режим "боевой prompt-test" с contract.
- Добавить будущие постеры рядом с TG-постером:
- VK;
- MAX;
- сайт.
- Добавить backup strategy:
- PostgreSQL dump;
.envотдельно;- uploads/editor_media.
- Навести порядок в 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с platformsite.
Первый боевой 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 и хранит ролик в нативном Lexicalvideonode; - ролики больше 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