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