Compare commits

...
16 Commits
Author SHA1 Message Date
gitadmin 87ccd23e96 [ZILANT] коррекция логики проверки добавленных ссылок
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/manual/woodpecker Pipeline was successful
2026-09-02 22:49:03 +03:00
gitadmin f74953e082 [ZILANT] логирование ответа АИ
ci/woodpecker/push/woodpecker Pipeline was successful
2026-09-02 22:19:58 +03:00
gitadmin 709178d9ea [ZILANT] отладка добавления ссылок 02
ci/woodpecker/push/woodpecker Pipeline was successful
2026-09-02 21:51:18 +03:00
gitadmin ac17043a08 [ZILANT] отладка до-редактирования ссылок в пост
ci/woodpecker/push/woodpecker Pipeline was successful
2026-09-02 21:40:29 +03:00
gitadmin 62a77dba78 Коррекция логики добавления ссылок при [ZILANT] постинге в ТГ
ci/woodpecker/push/woodpecker Pipeline was successful
2026-09-02 21:32:35 +03:00
gitadmin ea6c5e5a7d [ZILANT] обработка пустых постов
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-30 12:33:10 +03:00
gitadmin 80e0847329 [ZILANT] перенаправление лога автораннера
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-30 12:06:42 +03:00
gitadmin c7ba0479ba fix accidental local overwrite
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-30 11:49:52 +03:00
gitadmin d7e105d986 [ZILANT] отправлять картинки при посте событий как файл, а не как URL; делаем как при репосте ВК постов
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-30 11:37:19 +03:00
gitadmin 352c0da209 update .env for debug mode
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-30 11:18:40 +03:00
gitadmin 4500cf5bf4 создан шаблон .env.example
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-30 11:02:30 +03:00
gitadmin a1aadb6393 создан шаблон .env файла конфигурации 2026-08-30 11:02:19 +03:00
gitadmin 251fa0be11 реструктуризация файлов проекта
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-30 10:08:43 +03:00
gitadmin adc49c285c delete gitignored file
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-02 20:19:43 +03:00
gitadmin 1b080990fa Add .gitignore for season_links.pyc
ci/woodpecker/push/woodpecker Pipeline failed
2026-08-02 20:17:04 +03:00
gitadmin 7af6748c74 [ZILANT] вывод __pycache__ в gitignore 2026-08-02 20:16:11 +03:00
29 changed files with 1376 additions and 642 deletions
+23
View File
@@ -0,0 +1,23 @@
---
description: Поддерживать VERSION и docs/CHANGELOG при изменениях проекта
alwaysApply: true
---
# Версионирование и документация
При **значимых** изменениях кода или поведения:
1. Обновить [`VERSION`](../VERSION) (SemVer):
- **PATCH** — исправления, мелкие правки без смены API/поведения
- **MINOR** — новая функциональность, обратно совместимая
- **MAJOR** — ломающие изменения (формат ссылок, схема БД, API)
2. Добавить запись в [`docs/CHANGELOG.md`](../docs/CHANGELOG.md) под новой версией (Added / Changed / Fixed / Removed).
3. При изменении архитектуры, пайплайна, режимов ZILANT/VOLK или карты файлов — обновить [`docs/ARCHITECTURE.md`](../docs/ARCHITECTURE.md) и при необходимости [`README.md`](../README.md), [`docs/volk-mode.md`](../docs/volk-mode.md).
4. Перенос скриптов в `tools/` — обновить [`tools/README.md`](../tools/README.md).
5. Не поднимать версию за косметические правки без изменения поведения.
Текущая базовая версия: **1.1.4**.
+8 -6
View File
@@ -56,17 +56,17 @@ VK_ACCESS_TOKEN = 'e3732a7ee3732a7ee3732a7ea7e044a75fee373e3732a7e8bf4e48a52d012
VK_API_VERSION = '5.131' VK_API_VERSION = '5.131'
# MySQL database # MySQL database
# MDBASE = 'testposts' MDBASE = 'testposts'
# MDBASE = 'tempzilant' # debug Zilant database # MDBASE = 'tempzilant' # debug Zilant database
# Zilant 2026 database # Zilant 2026 database
MDBASE = 'zilant_2026' # MDBASE = 'zilant_2026'
# MDBASE = 'volk_test' # debug VOLK database # MDBASE = 'volk_test' # debug VOLK database
# MDBASE = 'volkdata' # VOLK production database # MDBASE = 'volkdata' # VOLK production database
MDB_HOST = 'localhost' MDB_HOST = 'localhost'
MDB_USER = 'zilantposter' # MDB_USER = 'zilantposter'
MDB_PW = 'Z1l@ntp08t3r' # MDB_PW = 'Z1l@ntp08t3r'
# MDB_USER = 'testuser' MDB_USER = 'testuser'
# MDB_PW = 'T38tU83r69' MDB_PW = 'T38tU83r69'
# MDB_USER = 'volkuser' # MDB_USER = 'volkuser'
# MDB_PW = 'V0lK@dM1n23' # MDB_PW = 'V0lK@dM1n23'
@@ -104,6 +104,8 @@ RESPONDER_BOT_NAME = "posto_1638_bot"
ORG_MESSAGE_TAG = "#оргинфаЗиланткона" ORG_MESSAGE_TAG = "#оргинфаЗиланткона"
# tag to detect posts to not repost from VK # tag to detect posts to not repost from VK
VK_NO_REPOST_TAG = "#окоЗиланта" VK_NO_REPOST_TAG = "#окоЗиланта"
# Минимум букв в тексте VK-поста (без учёта цифр/знаков). Меньше — «пустой пост», не в публикацию
VK_POST_MIN_LETTERS = 5
# Minutes delay before publishing an event # Minutes delay before publishing an event
+128
View File
@@ -0,0 +1,128 @@
# =============================================================================
# Шаблон окружения для инстанса проекта.
# Скопируйте в .env и заполните значения:
# cp .env.example .env
# Генерация SECRET_KEY / WEBHOOK_SECRET:
# python tools/ops/genkey.py
# =============================================================================
# --- Общие настройки ---------------------------------------------------------
SECRET_KEY=CHANGE_ME_hex_64_chars
LOG_FILE=./logs/app.log
# Режим работы: ZILANT | VOLK
WORKMODE=ZILANT
# Код сезона для deep link / callback подписок (zk2026_post_…, subscribe_zk2026_…)
SEASON=zk2026
# --- Telegram через internal_relay (retransbot) — обязательно ----------------
# Схема http:// или https:// обязательна (иначе подставится http://)
# Примеры: http://127.0.0.1:5100 | https://relay.example.com
RELAY_URL=http://127.0.0.1:5100
# --- Webhook responder-бота --------------------------------------------------
# WEBHOOK_URL — адрес локальной доставки апдейтов от relay к gunicorn-боту
# (доступен с машины relay). Это не публичный URL для Telegram.
# ZILANT (пример): http://bot.example.com:5005/testbot
# VOLK (пример): http://bot.example.com:5010/volk_bot
WEBHOOK_URL=http://192.168.0.1:5005/testbot
WEBHOOK_PORT=5005
WEBHOOK_SECRET=CHANGE_ME_webhook_secret
# Опционально: webhook poster-бота (если используется)
# POSTHOOK_URL=https://bot.example.com/poster
# POSTHOOK_PORT=5008
# POSTHOOK_SECRET=CHANGE_ME
# --- Веб-контроллер (db_edit) ------------------------------------------------
# ZILANT (пример):
WEBCTRL_URL=https://bot.example.com/testedit
WEBCTRL_PORT=5006
WEBCTRL_PREFIX=/testedit
# VOLK (пример, раскомментировать при WORKMODE=VOLK):
# WEBCTRL_URL=https://bot.example.com/volk
# WEBCTRL_PORT=5009
# WEBCTRL_PREFIX=/volk
# Логин/пароль первого администратора веб-UI (создаётся ensure_db при пустой таблице users)
DEFNM=admin
DEFPW=CHANGE_ME_admin_password
# --- VK API ------------------------------------------------------------------
# ZILANT group (пример): -3342146
# VOLK group (пример): -58475527
VK_GROUP_ID=-3342146
VK_ACCESS_TOKEN=CHANGE_ME_vk_token
VK_API_VERSION=5.131
# --- MariaDB -----------------------------------------------------------------
# ZILANT: zilant_2026 | tempzilant | testposts
# VOLK: volkdata | volk_test
MDBASE=CHANGE_ME_database_name
MDB_HOST=localhost
MDB_USER=CHANGE_ME_db_user_name
MDB_PW=CHANGE_ME_db_password
# --- Telegram-боты -----------------------------------------------------------
# Poster — публикация в канал
POSTER_BOT_TOKEN=CHANGE_ME_poster_bot_token
# Responder — подписки / webhook
RESPONDER_BOT_TOKEN=CHANGE_ME_responder_bot_token
RESPONDER_BOT_NAME=your_responder_bot_username
# --- Канал -------------------------------------------------------------------
# ZILANT debug: @posto1638 | production: @zilantkon
# VOLK production: @VOLKinformer
CHANNEL_ID=@your_channel
MAX_CAPTION_LENGTH=1000
MAX_TEXT_LENGTH=4000
# --- Теги VK (ZILANT) --------------------------------------------------------
# Org messages go without picture
# to fit the long text into TG message limit
ORG_MESSAGE_TAG=#оргинфаЗиланткона
# VK posts with no repost tag are skipped by poster
VK_NO_REPOST_TAG=#окоЗиланта
# Минимум букв (isalpha) в тексте VK-поста. Меньше — в БД как «- = Пустой пост = -», не для публикации
VK_POST_MIN_LETTERS=5
# --- Публикация событий ------------------------------------------------------
# Задержка перед публикацией события (минуты). В БД время в UTC.
# Ближе к событию: 60; далеко: 1440
EVENT_POST_DELAY=1440
# --- Ссылки ZILANT (План Захвата / карточки событий) --------------------------
# В конце обычно trailing slash; к URL добавляется номер события
PZK_PREFIX=https://reg.konvent.ru/zk2026/draft/plan/api/add/
EVT_PREFIX=https://reg.konvent.ru/zk2026/events/actions/api/
DESC_PREFIX=https://reg.konvent.ru/zk2026/events/actions/
# --- API VOLK (нужны при WORKMODE=VOLK) --------------------------------------
# Раскомментируйте и заполните для режима VOLK:
# VOLK_API_VERSION=1.0
# VOLK_CATEGORY_LIST=https://base.volk-nn.ru/api/categories?event=volk26&form=volk26_event
# VOLK_CATEGORY_DESC_PREFIX=https://base.volk-nn.ru/api/category?event=volk26&form=volk26_event&category=
# VOLK_EVENT_LIST_PREFIX=https://base.volk-nn.ru/api/applications?event=volk26&form=volk26_event&apps_on_page=200&page=0&category=
# --- Флаги поведения ---------------------------------------------------------
# IS_EVENT=true - каждый пост в ВК считать пригодным для репоста в канал
# AUTO_PUBLISH=true - автоматическая публикация потсов из ВК в канал
# AUTO_PUB_EVT=true - автоматическая публикация событий в канал
# PROCESS_EVENT_UPDATES=false - не репостить уже опубликованные события при обновлениях
# PUBLISH_SILENTLY=true - публиковать в канал в режиме "без звука"
# USE_SUBSCRIPTION_BOT=true - использовать бота подписок
# LOG_DEBUG_DATA=false - не вести расширенного журналирования отладочных данных
IS_EVENT=true
AUTO_PUBLISH=true
AUTO_PUB_EVT=true
PROCESS_EVENT_UPDATES=false
PUBLISH_SILENTLY=true
USE_SUBSCRIPTION_BOT=true
LOG_DEBUG_DATA=false
# --- RouterAI (генерация shortname через «Назвать») ---------------------------
RA_KEY=CHANGE_ME_routerai_key
RA_MODEL=moonshotai/kimi-k2-thinking
+11
View File
@@ -0,0 +1,11 @@
__pycache__/
*.py[cod]
*$py.class
# Секреты инстанса — только шаблон .env.example в репозитории
.env
.env.local
.env.*.local
# Логи инстанса
logs/
+67 -3
View File
@@ -1,4 +1,68 @@
# Zilant2025 # Zilant / VOLK — Telegram poster и подписки
Zilantkon bot testarea **Версия:** 1.1.4 ([`VERSION`](VERSION))
test update #8
Автоматическая публикация постов и событий в Telegram-канал, веб-редактор, бот подписок с сезонными deep link.
## Режимы
| `WORKMODE` | Описание |
|------------|----------|
| `ZILANT` | VK + Zilant JSON → канал (основной пайплайн) |
| `VOLK` | VOLK API → категории и события → канал |
Подробнее: [`docs/volk-mode.md`](docs/volk-mode.md).
## Быстрый старт (сервер)
```bash
# Deploy через Woodpecker → rsync + post-deploy.sh
# Сервисы:
sudo systemctl status testbot-git.service # бот :5005
sudo systemctl status testedit-git.service # UI :5006
sudo systemctl status runner.timer # hourly pipeline
```
Конфигурация — скопируйте [`.env.example`](.env.example) в `.env` и заполните значения
(файл `.env` в git не коммитится). Ключевые переменные: `WORKMODE`, `RELAY_URL`, `WEBHOOK_URL`, `SEASON`, `MDB_*`.
Генерация секретов: `python tools/ops/genkey.py`.
## Пайплайн (ZILANT)
`runner.sh` по расписанию:
`vk_load``zk_load``db_update_shortname``tg_publish``evtg_publish`
Настройка timer: [`docs/runner-setup.md`](docs/runner-setup.md).
## Документация
| Документ | О чём |
|----------|--------|
| [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) | Компоненты, карта файлов, deploy |
| [`docs/volk-mode.md`](docs/volk-mode.md) | Режим VOLK |
| [`docs/telegram-messaging.md`](docs/telegram-messaging.md) | API отправки сообщений пользователям |
| [`docs/runner-setup.md`](docs/runner-setup.md) | Systemd timer / cron для runner |
| [`docs/CHANGELOG.md`](docs/CHANGELOG.md) | История версий |
| [`tools/README.md`](tools/README.md) | Вспомогательные и legacy-скрипты |
## Версионирование
SemVer в [`VERSION`](VERSION). При изменениях обновлять `VERSION` и [`docs/CHANGELOG.md`](docs/CHANGELOG.md).
## Структура каталогов
```
├── tg_mainbot.py, wsgi_bot.py # responder-бот
├── db_edit.py, wsgi_edit.py # веб-UI
├── tg_publish.py, evtg_publish.py # публикация (ZILANT)
├── volk_load.py, volk_cat_publish.py # VOLK
├── runner.sh, backupdb.sh
├── services/ # systemd units, post-deploy
├── templates/ # HTML UI
├── docs/ # документация
└── tools/
├── ops/ # genkey, register_tg_webhook, copy_databases
├── dev/ # send_telegram_example, volk_*_testing
└── legacy/ # evt_prefetch
```
+2 -204
View File
@@ -1,205 +1,3 @@
# Отправка сообщений через Telegram бота # Перенесено
## Описание Документация: [`docs/telegram-messaging.md`](docs/telegram-messaging.md)
В проект добавлена возможность отправки сообщений пользователям Telegram через бота по их ID. Функциональность включает:
1. **API endpoint** для отправки сообщений из других скриптов
2. **Интерфейс в веб-приложении** для отправки сообщений через браузер
3. **Обработку ошибок** и логирование всех операций
## Архитектура системы
Система состоит из двух основных компонентов:
- **Веб-интерфейс** (`db_edit.py`) - работает на порту **5006** (WEBCTRL_PORT)
- **Telegram бот** (`tg_mainbot.py`) - работает на порту **5005** (WEBHOOK_PORT)
API endpoint `/send_message` находится в Telegram боте, поэтому все запросы должны отправляться на порт **5005**.
### Конфигурация портов
Порты и URL настраиваются в файле `.env`:
- `RELAY_URL` - базовый URL **internal_relay** (все исходящие вызовы Bot API идут только через него)
- `WEBHOOK_PORT` - порт для Telegram бота (по умолчанию 5005)
- `WEBHOOK_URL` - URL доставки апдейтов от internal_relay к боту (доступен с машины relay; не URL, который видит Telegram напрямую)
- `WEBCTRL_PORT` - порт для веб-интерфейса (по умолчанию 5006)
Веб-интерфейс автоматически читает URL и порт бота из переменных окружения и использует их для отправки запросов.
## Использование через веб-интерфейс
В заголовке страницы "Управление постами VK и событиями" добавлены два поля:
- **ID пользователя** - числовой ID пользователя в Telegram
- **Сообщение** - текст сообщения для отправки
- **Кнопка "Отправить"** - отправляет сообщение через бота
### Особенности интерфейса:
- Поля проверяются на заполненность перед отправкой
- ID пользователя проверяется на корректность (должен быть числом)
- Поддержка отправки по клавише Enter
- Индикатор загрузки во время отправки
- Автоматическая очистка полей после успешной отправки
## Использование через API
### Endpoint
```
POST /send_message
POST {WEBHOOK_PATH}/send_message
```
Где `WEBHOOK_PATH` - путь из переменной окружения `WEBHOOK_URL` (например, `/testbot`).
### Примеры URL:
- **Локально:** `http://localhost:5005/send_message`
- **Через reverse proxy:** `https://bot.aabpro.ru/testbot/send_message`
### Заголовки запроса
```
Content-Type: application/json
X-API-Secret-Token: <секретный_токен>
```
### Параметры запроса
```json
{
"user_id": 123456789,
"message": "Текст сообщения"
}
```
### Аутентификация
API защищен секретным токеном, который передается в заголовке `X-API-Secret-Token`. Токен берется из переменной окружения `WEBHOOK_SECRET`.
### Ответы
**Успешная отправка (200):**
```json
{
"success": true,
"message": "Сообщение отправлено успешно"
}
```
**Ошибка (400):**
```json
{
"success": false,
"error": "Описание ошибки"
}
```
### Пример использования из Python
```python
import requests
def send_telegram_message(user_id, message):
# Используем внешний URL через reverse proxy
url = "https://bot.aabpro.ru/testbot/send_message"
data = {
"user_id": user_id,
"message": message
}
headers = {
'Content-Type': 'application/json',
'X-API-Secret-Token': 'ваш_секретный_токен'
}
response = requests.post(url, json=data, headers=headers)
result = response.json()
if result["success"]:
print("Сообщение отправлено успешно!")
else:
print(f"Ошибка: {result['error']}")
```
### Пример использования из командной строки
```bash
# Используя curl (через reverse proxy)
curl -X POST https://bot.aabpro.ru/testbot/send_message \
-H "Content-Type: application/json" \
-H "X-API-Secret-Token: ваш_секретный_токен" \
-d '{"user_id": 123456789, "message": "Привет!"}'
# Используя curl (локально)
curl -X POST http://localhost:5005/send_message \
-H "Content-Type: application/json" \
-H "X-API-Secret-Token: ваш_секретный_токен" \
-d '{"user_id": 123456789, "message": "Привет!"}'
# Используя примерный скрипт
python send_telegram_example.py 123456789 "Привет! Это тестовое сообщение."
```
## Обработка ошибок
Система обрабатывает следующие типы ошибок:
### HTTP ошибки:
1. **401 Unauthorized** - Неверный или отсутствующий API токен
2. **400 Bad Request** - Неверный ID пользователя или отсутствующие данные
3. **500 Internal Server Error** - Внутренняя ошибка сервера
### Коды ошибок API:
- `INVALID_TOKEN` - Неверный токен аутентификации
- `NO_DATA` - Отсутствуют данные в запросе
- `NO_USER_ID` - Не указан ID пользователя
- `NO_MESSAGE` - Не указан текст сообщения
- `INVALID_USER_ID` - user_id не является числом
- `TELEGRAM_ERROR` - Ошибка Telegram API
- `INTERNAL_ERROR` - Внутренняя ошибка сервера
### Ошибки Telegram API:
1. **403 Forbidden** - Пользователь заблокировал бота или не начал с ним диалог
2. **400 Bad Request** - Неверный ID пользователя
3. **429 Too Many Requests** - Превышен лимит отправки сообщений
4. **404 Not Found** - Пользователь не найден
### Диагностика в веб-интерфейсе
Веб-интерфейс предоставляет подробную диагностику ошибок:
- **Время ответа** сервера
- **HTTP статус** код
- **Код ошибки** API
- **Конкретные предложения** по решению проблемы
- **Автоматическое скрытие** уведомлений
Все ошибки логируются в файл логов с указанием типа ошибки и ID пользователя.
## Логирование
Все операции отправки сообщений записываются в лог с указанием:
- Времени операции
- ID пользователя
- Статуса отправки (успех/ошибка)
- Краткого описания ошибки (при наличии)
Пример записи в логе:
```
[2024-01-15 10:30:45] [TG_bot] ИНФО: Сообщение отправлено: Привет! Это тестовое сообщение... (Пользователь user_123456789)
```
## Требования
- Telegram бот должен быть запущен и подключен через webhook
- Пользователь должен начать диалог с ботом (отправить команду /start)
- Пользователь не должен заблокировать бота
## Безопасность
- API endpoint доступен только для POST запросов
- Проверка корректности входных данных
- Логирование всех попыток отправки для аудита
- Обработка ошибок без раскрытия внутренней информации системы
+1
View File
@@ -0,0 +1 @@
1.1.4
Binary file not shown.
+110 -3
View File
@@ -30,6 +30,79 @@ ROUTERAI_CONFIG = {
# Настройки журналирования # Настройки журналирования
LOG_FILE = os.getenv('LOG_FILE', 'ai_namer.log') # Путь к лог-файлу LOG_FILE = os.getenv('LOG_FILE', 'ai_namer.log') # Путь к лог-файлу
LOG_PREFIX = "AI_namer" # Уникальный префикс для идентификации скрипта LOG_PREFIX = "AI_namer" # Уникальный префикс для идентификации скрипта
LOG_AI_RESPONSE_MAX = int(os.getenv('LOG_AI_RESPONSE_MAX', '8000')) # обрезка длинных полей в логе
def _truncate_for_log(value, max_len=None):
"""Обрезает длинные значения для журнала, не теряя информацию о размере."""
if value is None:
return None
if not isinstance(value, str):
value = json.dumps(value, ensure_ascii=False, default=str)
limit = max_len if max_len is not None else LOG_AI_RESPONSE_MAX
if len(value) <= limit:
return value
return f"{value[:limit]}... [обрезано, всего {len(value)} символов]"
def _message_fields_for_log(message):
"""Все значимые поля message из ответа chat/completions."""
if not message:
return {}
logged = {}
for key in (
'role', 'content', 'reasoning_content', 'reasoning',
'refusal', 'tool_calls', 'function_call', 'audio',
):
if key not in message:
continue
value = message.get(key)
if value in (None, '', [], {}):
logged[key] = value
elif isinstance(value, str):
logged[key] = _truncate_for_log(value)
else:
logged[key] = _truncate_for_log(json.dumps(value, ensure_ascii=False, default=str))
return logged
def _log_routerai_request(text, prompt):
"""Журнал параметров исходящего запроса (без API-ключа)."""
log_message(
"Запрос RouterAI: "
f"model={ROUTERAI_CONFIG['model']}, "
f"text_len={len(text)}, prompt_len={len(prompt)}, "
f"text_preview={_truncate_for_log(text.strip(), 300)}"
)
def _log_routerai_response(response, result=None, error_body=None):
"""Подробный журнал HTTP-ответа RouterAI."""
payload = {
"http_status": getattr(response, 'status_code', None),
"model": ROUTERAI_CONFIG.get('model'),
}
if result is not None:
payload["response_id"] = result.get("id")
payload["object"] = result.get("object")
payload["created"] = result.get("created")
payload["usage"] = result.get("usage")
payload["system_fingerprint"] = result.get("system_fingerprint")
choices = result.get("choices") or []
payload["choices_count"] = len(choices)
payload["choices"] = []
for i, choice in enumerate(choices):
payload["choices"].append({
"index": choice.get("index", i),
"finish_reason": choice.get("finish_reason"),
"message": _message_fields_for_log(choice.get("message") or {}),
"logprobs": choice.get("logprobs"),
})
if result.get("error"):
payload["api_error"] = result.get("error")
if error_body is not None:
payload["error_body"] = _truncate_for_log(error_body)
log_message(f"Ответ RouterAI: {json.dumps(payload, ensure_ascii=False, default=str)}")
def log_message(message, max_retries=5, retry_delay=0.1): def log_message(message, max_retries=5, retry_delay=0.1):
""" """
@@ -90,6 +163,8 @@ def _generate_shortname_by_api(text):
"temperature": 0.3, "temperature": 0.3,
} }
_log_routerai_request(text, prompt)
try: try:
response = requests.post(url, headers=headers, json=data, timeout=180) response = requests.post(url, headers=headers, json=data, timeout=180)
@@ -107,20 +182,36 @@ def _generate_shortname_by_api(text):
except Exception: except Exception:
pass pass
_log_routerai_response(response, error_body=error_info.get("body"))
log_message(f"Ошибка 429: Превышен лимит запросов. Ответ API: {json.dumps(error_info, ensure_ascii=False)}") log_message(f"Ошибка 429: Превышен лимит запросов. Ответ API: {json.dumps(error_info, ensure_ascii=False)}")
print("Ошибка 429: Превышен лимит запросов. Подробности в логе.") print("Ошибка 429: Превышен лимит запросов. Подробности в логе.")
# Не sys.exit — эта функция вызывается и из веб-API Flask # Не sys.exit — эта функция вызывается и из веб-API Flask
raise RuntimeError("RouterAI 429: превышен лимит запросов") raise RuntimeError("RouterAI 429: превышен лимит запросов")
response.raise_for_status() if not response.ok:
_log_routerai_response(response, error_body=response.text)
response.raise_for_status()
result = response.json() result = response.json()
_log_routerai_response(response, result=result)
# Извлекаем только текст ответа (у thinking-моделей ответ в content, # Извлекаем только текст ответа (у thinking-моделей ответ в content,
# рассуждения — в reasoning_content / reasoning) # рассуждения — в reasoning_content / reasoning)
message = result['choices'][0]['message'] choices = result.get('choices') or []
if not choices:
log_message("RouterAI вернул пустой choices — название не извлечь")
return None
message = choices[0].get('message') or {}
shortname = (message.get('content') or '').strip() shortname = (message.get('content') or '').strip()
if not shortname: if not shortname:
log_message(f"Пустой content в ответе RouterAI: {json.dumps(result, ensure_ascii=False)[:1000]}") reasoning = (message.get('reasoning_content') or message.get('reasoning') or '').strip()
log_message(
"Пустой content в ответе RouterAI; "
f"finish_reason={choices[0].get('finish_reason')!r}, "
f"message_keys={list(message.keys())}, "
f"reasoning_len={len(reasoning)}"
)
return None return None
# Удаляем возможные кавычки и лишние символы # Удаляем возможные кавычки и лишние символы
@@ -149,14 +240,30 @@ def _generate_shortname_by_api(text):
except Exception: except Exception:
pass pass
_log_routerai_response(e.response, error_body=error_info.get("body"))
log_message(f"Ошибка 429: Превышен лимит запросов. Ответ API: {json.dumps(error_info, ensure_ascii=False)}") log_message(f"Ошибка 429: Превышен лимит запросов. Ответ API: {json.dumps(error_info, ensure_ascii=False)}")
print("Ошибка 429: Превышен лимит запросов. Подробности в логе.") print("Ошибка 429: Превышен лимит запросов. Подробности в логе.")
raise RuntimeError("RouterAI 429: превышен лимит запросов") raise RuntimeError("RouterAI 429: превышен лимит запросов")
else: else:
if e.response is not None:
try:
_log_routerai_response(e.response, error_body=e.response.text)
except Exception:
pass
error_msg = f"HTTP ошибка при генерации названия: {e}" error_msg = f"HTTP ошибка при генерации названия: {e}"
log_message(error_msg) log_message(error_msg)
print(error_msg) print(error_msg)
return None return None
except requests.exceptions.Timeout as e:
error_msg = f"Таймаут запроса RouterAI ({e})"
log_message(error_msg)
print(error_msg)
return None
except requests.exceptions.RequestException as e:
error_msg = f"Сетевая ошибка RouterAI: {e}"
log_message(error_msg)
print(error_msg)
return None
except RuntimeError: except RuntimeError:
raise raise
except Exception as e: except Exception as e:
+165
View File
@@ -0,0 +1,165 @@
# Архитектура проекта
Версия: см. [`VERSION`](../VERSION). История изменений: [`docs/CHANGELOG.md`](CHANGELOG.md).
## Назначение
Система загружает посты и события из внешних источников, хранит их в MariaDB, публикует анонсы в Telegram-канал и даёт веб-интерфейс для редактирования и управления подписками через responder-бота.
Поддерживаются два режима работы (`WORKMODE` в `.env`):
| Режим | Источники данных | Публикация |
|-------|------------------|------------|
| **ZILANT** | VK (`vk_load.py`), JSON Zilant (`zk_load.py`) | `tg_publish.py`, `evtg_publish.py` |
| **VOLK** | VOLK API (`volk_load.py`) | `volk_cat_publish.py` |
Подробнее о VOLK: [`docs/volk-mode.md`](volk-mode.md).
## Компоненты runtime
```
┌─────────────────┐ rsync ┌──────────────────┐
│ Woodpecker CI │ ─────────────► │ /opt/testbot-git │
└─────────────────┘ └────────┬─────────┘
┌──────────────────────────────────┼──────────────────────────┐
▼ ▼ ▼
runner.timer testbot-git.service testedit-git.service
→ runner.sh gunicorn :5005 gunicorn :5006
vk_load, zk_load, wsgi_bot → tg_mainbot wsgi_edit → db_edit
db_update_shortname,
tg_publish, evtg_publish
```
### Responder-бот (подписки)
- **`tg_mainbot.py`** — Flask + webhook, обработка `/start`, callback-кнопок подписки.
- **`wsgi_bot.py`** — точка входа gunicorn; однократная регистрация webhook с file lock.
- **`season_links.py`** — сезонные payload (`zk2026_post_*`, `subscribe_zk2026_*` и т.д.).
- Старые ссылки без префикса сезона → сообщение «сезон завершён» + кнопка в канал.
### Poster (публикация в канал)
- **`tg_publish.py`** — посты VK → Telegram.
- **`evtg_publish.py`** — события → Telegram.
- **`volk_cat_publish.py`** — категории VOLK → Telegram (режим VOLK).
- Все исходящие вызовы Bot API — через **`telegram_relay.py`** (`RELAY_URL`).
### Веб-интерфейс
- **`db_edit.py`** — CRUD постов/событий, кнопки рескана, публикации, «Назвать» (AI shortname).
- **`db_update_shortname.py`** — генерация коротких названий через RouterAI.
- **`test_tg_poster.py`** — тестовый пост в канал (кнопка «Тест» в UI).
- Шаблоны: `templates/`.
### База данных
- **`ensure_db.py`** — создание/миграция схемы (вызывается из loader-скриптов).
- **`backupdb.sh`** — дамп БД при deploy (`post-deploy.sh`).
## Пайплайн runner.sh (ZILANT)
Порядок выполнения (systemd timer `runner.timer`):
1. `vk_load.py` — загрузка постов из VK
2. `zk_load.py` — загрузка событий из JSON
3. `db_update_shortname.py` — AI shortname для новых записей
4. `tg_publish.py` — публикация постов
5. `evtg_publish.py` — публикация событий
> **`tools/legacy/evt_prefetch.py`** раньше стоял перед `evtg_publish.py`; с 1.0.0 снят с пайплайна.
## Deploy
- **`.woodpecker.yml`** — rsync на сервер, затем `services/post-deploy.sh`.
- **`post-deploy.sh`** — venv, pip, backup БД, обновление systemd units, restart сервисов.
## Конфигурация (.env)
Шаблон для нового инстанса: [`.env.example`](../.env.example).
```bash
cp .env.example .env
# заполнить CHANGE_ME_*; секреты: python tools/ops/genkey.py
```
| Переменная | Назначение |
|------------|------------|
| `WORKMODE` | `ZILANT` или `VOLK` |
| `SEASON` | Префикс сезона для deep link (`zk2026`) |
| `RELAY_URL` | Internal relay для Bot API |
| `WEBHOOK_URL`, `WEBHOOK_SECRET` | Доставка апдейтов боту |
| `MDB_*`, `MDBASE` | MariaDB |
| `POSTER_BOT_TOKEN`, `RESPONDER_BOT_TOKEN` | Токены ботов |
| `PZK_PREFIX`, `EVT_PREFIX`, `DESC_PREFIX` | Ссылки ZILANT |
| `VK_NO_REPOST_TAG` | Тег VK, отключающий автопубликацию |
| `VK_POST_MIN_LETTERS` | Минимум букв в тексте VK-поста (иначе «пустой пост», не публиковать) |
| `VOLK_*` | API VOLK (при `WORKMODE=VOLK`) |
| `RA_KEY`, `RA_MODEL` | RouterAI для shortname |
## Карта файлов
### Ядро (runtime)
| Файл | Роль |
|------|------|
| `tg_mainbot.py` | Responder-бот, webhook |
| `wsgi_bot.py` | Gunicorn entry (бот) |
| `db_edit.py` | Веб-UI |
| `wsgi_edit.py` | Gunicorn entry (UI) |
| `tg_publish.py` | Публикация постов |
| `evtg_publish.py` | Публикация событий |
| `vk_load.py` | Загрузка VK |
| `zk_load.py` | Загрузка Zilant JSON |
| `volk_load.py` | Загрузка VOLK (режим VOLK) |
| `volk_cat_publish.py` | Публикация VOLK (режим VOLK) |
| `db_update_shortname.py` | AI shortname |
| `formatter.py` | Форматирование текста постов/событий |
| `ensure_db.py` | Схема БД |
| `telegram_relay.py` | Relay для Bot API |
| `season_links.py` | Сезонные ссылки и callback |
| `test_tg_poster.py` | Тестовая публикация (UI) |
| `runner.sh` | Пайплайн по расписанию |
| `backupdb.sh` | Бэкап БД |
| `gunicorn_bot.conf.py` | Stub-конфиг gunicorn (бот) |
### Инфраструктура
| Путь | Роль |
|------|------|
| `services/testbot-git.service` | systemd: бот :5005 |
| `services/testedit-git.service` | systemd: UI :5006 |
| `services/runner.service`, `runner.timer` | systemd: hourly runner |
| `services/post-deploy.sh` | Post-deploy |
| `.woodpecker.yml` | CI deploy |
### Вспомогательные (`tools/`)
| Путь | Роль |
|------|------|
| `tools/ops/genkey.py` | Генерация секретов |
| `tools/ops/register_tg_webhook.py` | Ручная регистрация webhook |
| `tools/ops/copy_databases.sh` | Клонирование MariaDB |
| `tools/dev/send_telegram_example.py` | CLI-пример `/send_message` |
| `tools/dev/volk_telegram_testing.py` | Отладка публикации в Telegram |
| `tools/dev/volk_category_testing.py` | Отладка загрузки категории VOLK |
| `tools/legacy/evt_prefetch.py` | Legacy: prefetch событий |
См. [`tools/README.md`](../tools/README.md).
### Документация
| Файл | Содержание |
|------|------------|
| `README.md` | Быстрый старт |
| `docs/ARCHITECTURE.md` | Этот файл |
| `docs/volk-mode.md` | Режим VOLK |
| `docs/telegram-messaging.md` | API `/send_message` |
| `docs/runner-setup.md` | Настройка runner timer |
| `docs/CHANGELOG.md` | История версий |
## Версионирование
- Версия в корне: **`VERSION`** (SemVer: `MAJOR.MINOR.PATCH`).
- При каждом значимом изменении обновлять `VERSION` и запись в `docs/CHANGELOG.md`.
- Правило для агентов: `.cursor/rules/version-and-docs.mdc`.
+98
View File
@@ -0,0 +1,98 @@
# Changelog
Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/).
Версия проекта — в файле [`VERSION`](../VERSION) (SemVer).
## [1.1.4] - 2026-09-02
### Fixed
- Проверка footer-ссылок после edit: убрано сравнение `min_length` (HTML длиннее plain text от Telegram) — лишние retry, мигание ссылок и обрыв пакетной публикации из UI.
### Changed
- Проверка footer-ссылок: убрано сравнение `min_length` (HTML длиннее plain text от Telegram).
## [1.1.3] - 2026-09-02
### Fixed
- Проверка footer-ссылок после edit: сравнение по конкретным URL (Инфо/Иду/Подписка, ВК), а не по любому `text_link` или словам в тексте тела события — устраняет ложный «успех» при пакетной публикации.
## [1.1.2] - 2026-09-02
### Fixed
- Проверка успешного edit ссылок: Telegram отдаёт plain text и `text_link` entities, а не `href=` в caption; `message is not modified` трактуется как успех (ссылки уже применены). Устраняет ложные ошибки при успешной публикации.
## [1.1.1] - 2026-09-02
### Fixed
- Публикация постов и событий: второй шаг (edit со строкой ссылок) повторяется до появления `href=` в ответе API; без подтверждённых ссылок запись не помечается опубликованной. Устраняет редкие случаи, когда edit логировался как успех, а ссылки в канале отсутствовали.
## [1.1.0] - 2026-08-30
### Added
- Импорт VK (`vk_load.py`): посты с числом букв меньше `VK_POST_MIN_LETTERS` (по умолчанию 5) сохраняются с названием `- = Пустой пост = -` и без отметки «к публикации».
### Documentation
- Параметр `VK_POST_MIN_LETTERS` в `.env.example` и `.env`.
## [1.0.4] - 2026-08-30
### Changed
- `runner.sh` пишет лог в `logs/runner.log` (каталог создаётся при первом запуске).
## [1.0.3] - 2026-08-30
### Fixed
- Публикация событий (`evtg_publish.py`): картинка скачивается на сервере и уходит в `sendPhoto` файлом, как у постов VK. Раньше в Bot API передавался URL (`http://reg.konvent.ru/...`), Telegram не мог его забрать (`failed to get HTTP URL content`) и пост уходил текстом.
## [1.0.2] - 2026-08-30
### Added
- Шаблон окружения [`.env.example`](../.env.example) для ZILANT и VOLK (секреты — плейсхолдеры).
- `.env` добавлен в [`.gitignore`](../.gitignore).
### Documentation
- README и `docs/ARCHITECTURE.md`: копирование `.env.example``.env` при настройке инстанса.
## [1.0.1] - 2026-08-30
### Changed
- Утилиты перенесены в `tools/`:
- `tools/ops/``register_tg_webhook.py`, `copy_databases.sh` (рядом с `genkey.py`)
- `tools/dev/``send_telegram_example.py`, `volk_telegram_testing.py`, `volk_category_testing.py`
- Скрипты в `tools/` загружают `.env` из корня проекта и добавляют корень в `sys.path` при импорте модулей ядра.
### Documentation
- Обновлены `tools/README.md`, `docs/ARCHITECTURE.md`, `docs/volk-mode.md`, `docs/telegram-messaging.md`, `README.md`.
## [1.0.0] - 2026-08-30
### Added
- Файл версии `VERSION` (SemVer).
- Документация: `README.md`, `docs/ARCHITECTURE.md`, `docs/volk-mode.md`.
- Каталог `tools/` с README; `tools/ops/genkey.py`, `tools/legacy/evt_prefetch.py`.
- Сезонные deep link и callback с префиксом `SEASON` (`season_links.py`, по умолчанию `zk2026`).
- Интеграция Telegram Bot API через internal relay (`telegram_relay.py`, `RELAY_URL`).
### Changed
- `evt_prefetch.py` перенесён в `tools/legacy/` — не входит в `runner.sh`.
- `genkey.py` перенесён в `tools/ops/`.
- Документация по runner и отправке сообщений — в `docs/`.
### Documentation
- Правило Cursor `.cursor/rules/version-and-docs.mdc` — поддержка версии и changelog при изменениях.
+77
View File
@@ -0,0 +1,77 @@
# Настройка автоматического запуска runner.sh
См. также [`docs/ARCHITECTURE.md`](ARCHITECTURE.md) — состав пайплайна.
Есть два способа настроить запуск `runner.sh` каждый час:
## Вариант 1: Systemd Timer (рекомендуется)
В проекте уже используются systemd-сервисы.
### Установка
1. Скопируйте файлы в systemd:
```bash
sudo cp services/runner.service /etc/systemd/system/
sudo cp services/runner.timer /etc/systemd/system/
```
2. Или используйте скрипт установки:
```bash
chmod +x services/install_runner_timer.sh
sudo bash services/install_runner_timer.sh
```
3. Вручную:
```bash
sudo systemctl daemon-reload
sudo systemctl enable runner.timer
sudo systemctl start runner.timer
```
### Управление
- Статус: `sudo systemctl status runner.timer`
- Остановить: `sudo systemctl stop runner.timer`
- Запустить: `sudo systemctl start runner.timer`
- Отключить: `sudo systemctl disable runner.timer`
- Логи: `sudo journalctl -u runner.service -f`
- Список timers: `sudo systemctl list-timers`
### Расписание
Файл `/etc/systemd/system/runner.timer`:
- `OnCalendar=hourly` — каждый час
- `OnCalendar=*-*-* *:00:00` — в начале каждого часа
- `OnCalendar=*-*-* 0,6,12,18:00:00` — в 0, 6, 12, 18 часов
- `OnCalendar=Mon..Fri 09:00:00` — будни в 9:00
После изменения: `sudo systemctl daemon-reload`
---
## Вариант 2: Cron
```bash
sudo crontab -e
```
```bash
0 * * * * /bin/bash /opt/testbot-git/runner.sh
```
Другие варианты: `30 * * * *`, `*/30 * * * *`, `0 9-17 * * *`.
---
## Состав runner.sh (ZILANT)
`vk_load.py``zk_load.py``db_update_shortname.py``tg_publish.py``evtg_publish.py`
Legacy `tools/legacy/evt_prefetch.py` в пайплайн не входит (с 1.0.0).
Лог пайплайна: `/opt/testbot-git/logs/runner.log` (каталог `logs/` создаётся автоматически).
+94
View File
@@ -0,0 +1,94 @@
# Отправка сообщений через Telegram бота
## Описание
В проект добавлена возможность отправки сообщений пользователям Telegram через бота по их ID. Функциональность включает:
1. **API endpoint** для отправки сообщений из других скриптов
2. **Интерфейс в веб-приложении** для отправки сообщений через браузер
3. **Обработку ошибок** и логирование всех операций
## Архитектура системы
Система состоит из двух основных компонентов:
- **Веб-интерфейс** (`db_edit.py`) - работает на порту **5006** (WEBCTRL_PORT)
- **Telegram бот** (`tg_mainbot.py`) - работает на порту **5005** (WEBHOOK_PORT)
API endpoint `/send_message` находится в Telegram боте, поэтому все запросы должны отправляться на порт **5005**.
### Конфигурация портов
Порты и URL настраиваются в файле `.env`:
- `RELAY_URL` - базовый URL **internal_relay** (все исходящие вызовы Bot API идут только через него)
- `WEBHOOK_PORT` - порт для Telegram бота (по умолчанию 5005)
- `WEBHOOK_URL` - URL доставки апдейтов от internal_relay к боту
- `WEBCTRL_PORT` - порт для веб-интерфейса (по умолчанию 5006)
## Использование через веб-интерфейс
В заголовке страницы добавлены поля **ID пользователя**, **Сообщение** и кнопка **Отправить**.
## Использование через API
### Endpoint
```
POST /send_message
POST {WEBHOOK_PATH}/send_message
```
### Заголовки
```
Content-Type: application/json
X-API-Secret-Token: <WEBHOOK_SECRET>
```
### Тело запроса
```json
{
"user_id": 123456789,
"message": "Текст сообщения"
}
```
### Пример (Python)
```python
import requests
def send_telegram_message(user_id, message):
url = "http://192.168.0.109:5005/testbot/send_message"
headers = {
"Content-Type": "application/json",
"X-API-Secret-Token": "ваш_секретный_токен",
}
response = requests.post(url, json={"user_id": user_id, "message": message}, headers=headers)
return response.json()
```
### Пример (curl)
```bash
curl -X POST http://localhost:5005/testbot/send_message \
-H "Content-Type: application/json" \
-H "X-API-Secret-Token: ваш_секретный_токен" \
-d '{"user_id": 123456789, "message": "Привет!"}'
```
Опционально: CLI-скрипт `tools/dev/send_telegram_example.py`.
## Коды ошибок API
- `INVALID_TOKEN`, `NO_DATA`, `NO_USER_ID`, `NO_MESSAGE`, `INVALID_USER_ID`
- `TELEGRAM_ERROR`, `INTERNAL_ERROR`
## Требования
- Бот запущен (webhook через `wsgi_bot.py`)
- Пользователь начал диалог с ботом (`/start`)
См. также [`ARCHITECTURE.md`](ARCHITECTURE.md).
+53
View File
@@ -0,0 +1,53 @@
# Режим VOLK
Режим включается в `.env` (скопируйте из [`.env.example`](../.env.example)):
```env
WORKMODE = "VOLK"
```
Проект поддерживает оба режима — **ZILANT** и **VOLK**; переключение только через `WORKMODE`, без смены кодовой базы.
## Отличия от ZILANT
| Аспект | ZILANT | VOLK |
|--------|--------|------|
| Загрузка данных | `vk_load.py`, `zk_load.py` | `volk_load.py` |
| Публикация | `tg_publish.py`, `evtg_publish.py` | `volk_cat_publish.py` |
| Runner | Полный пайплайн ZILANT | Отдельные кнопки рескана в UI |
| UI | Посты + события VK/Zilant | Категории (площадки) + события VOLK |
## Модули VOLK
### `volk_load.py`
- Загружает список категорий и событий с VOLK API.
- Переменные: `VOLK_CATEGORY_LIST`, `VOLK_CATEGORY_DESC_PREFIX`, `VOLK_EVENT_LIST_PREFIX`, `VOLK_API_VERSION`.
- Вызывается из `db_edit.py` (API `/api/run_volk_rescan`, кнопка «Рескан ВОЛК»).
### `volk_cat_publish.py`
- Публикует категории (площадки) в Telegram-канал.
- Функции `tg_post_category_by_id`, `tg_post_all_categories` — из веб-UI.
### Шаблоны
- `templates/edit_category.html` — редактирование площадки (только VOLK).
- Блоки `{% if g.workmode == 'VOLK' %}` в `templates/index.html`.
## Отладочные скрипты (не в production-пайплайне)
| Файл | Запуск |
|------|--------|
| `tools/dev/volk_category_testing.py` | `python tools/dev/volk_category_testing.py <category_id>` |
| `tools/dev/volk_telegram_testing.py` | `python tools/dev/volk_telegram_testing.py` |
См. [`tools/README.md`](../tools/README.md).
## Переключение режима на сервере
1. Изменить `WORKMODE` в `/opt/testbot-git/.env`.
2. Перезапустить UI: `sudo systemctl restart testedit-git.service`.
3. Responder-бот (`testbot-git`) от режима не зависит; сезонные ссылки общие (`SEASON`).
При смене режима проверьте переменные VOLK/ZILANT в `.env` (префиксы URL, API, `LOG_FILE`).
+198 -203
View File
@@ -6,9 +6,17 @@ import time
import html import html
import httpx import httpx
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from urllib.parse import urlparse, unquote
from dotenv import load_dotenv from dotenv import load_dotenv
from formatter import get_event_text from formatter import get_event_text
from telegram_relay import bot_api_method_url from telegram_relay import (
bot_api_method_url,
EDIT_LINKS_INITIAL_DELAY_SEC,
EDIT_LINKS_MAX_ATTEMPTS,
EDIT_LINKS_RETRY_INTERVAL_SEC,
published_message_has_expected_links,
extract_text_link_urls,
)
from season_links import subscription_start_link from season_links import subscription_start_link
# Загрузка переменных окружения # Загрузка переменных окружения
@@ -92,6 +100,154 @@ def prepare_text(text):
return text return text
def _filename_from_image_url(image_url):
path = unquote(urlparse(image_url).path)
name = os.path.basename(path) or "image.jpg"
if "." not in name:
name = f"{name}.jpg"
return name
async def download_image(image_url, httpx_client):
"""Скачивает картинку самим сервером (как tg_publish), не отдаёт URL Telegram.
sendPhoto с photo=http://... Telegram качает сам и часто отвечает
«failed to get HTTP URL content» (http, недоступность с DC Telegram).
"""
logger.info(f"Скачивание изображения: {image_url}")
started = time.time()
response = await httpx_client.get(
image_url,
timeout=httpx.Timeout(connect=10.0, read=60.0, write=30.0, pool=10.0),
)
response.raise_for_status()
image_data = response.content
content_type = (response.headers.get("content-type") or "image/jpeg").split(";")[0].strip()
if not content_type.startswith("image/"):
content_type = "image/jpeg"
logger.info(
f"Изображение скачано за {time.time() - started:.2f} с, "
f"размер: {len(image_data)} байт, type={content_type}"
)
return image_data, content_type, _filename_from_image_url(image_url)
async def _edit_event_add_links(
httpx_client, has_image, message_id, final_text, entity_label, expected_urls
):
"""Edit caption/text со ссылками; повтор до подтверждения всех expected_urls в text_link."""
await asyncio.sleep(EDIT_LINKS_INITIAL_DELAY_SEC)
method = "editMessageCaption" if has_image else "editMessageText"
field = "caption" if has_image else "text"
entities_field = "caption_entities" if has_image else "entities"
api_url = bot_api_method_url(BOT_TOKEN, method)
logger.info(
f"Попытка редактирования {entity_label}: message_id={message_id}, "
f"final_text_length={len(final_text)}"
)
for attempt in range(1, EDIT_LINKS_MAX_ATTEMPTS + 1):
payload = {
'chat_id': CHANNEL_ID,
'message_id': message_id,
field: final_text,
'parse_mode': 'HTML',
}
if not has_image:
payload['disable_web_page_preview'] = True
try:
response = await httpx_client.post(api_url, json=payload)
except httpx.TimeoutException as e:
if attempt >= EDIT_LINKS_MAX_ATTEMPTS:
logger.error(
f"Таймаут edit ссылок для {entity_label} после "
f"{EDIT_LINKS_MAX_ATTEMPTS} попыток: {e}"
)
return False
logger.warning(
f"Таймаут edit (попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS}) для {entity_label}"
)
await asyncio.sleep(EDIT_LINKS_RETRY_INTERVAL_SEC)
continue
if response.status_code == 200:
data = response.json()
if data.get('ok'):
result = data.get('result') or {}
result_text = result.get(field) or ""
entities = result.get(entities_field) or []
if published_message_has_expected_links(entities, expected_urls):
logger.info(
f"Ссылки добавлены для {entity_label} "
f"(попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS})"
)
return True
found = extract_text_link_urls(entities)
logger.warning(
f"edit ok, но footer-ссылки не подтверждены "
f"(попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS}) для {entity_label}; "
f"ожидались {expected_urls}, найдены text_link: {found}"
)
else:
error_code = data.get('error_code')
error_description = (data.get('description') or '').lower()
if error_code == 429:
retry_after = data.get('parameters', {}).get('retry_after', 60)
raise Exception(f"RetryAfter:{retry_after}")
if error_code == 400 and "not modified" in error_description:
logger.info(
f"Caption уже содержит ссылки для {entity_label} "
f"(message is not modified, попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS})"
)
return True
logger.warning(
f"API error edit (попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS}) "
f"для {entity_label}: {data.get('description')}"
)
elif response.status_code == 429:
retry_after = 60
try:
retry_after = response.json().get('parameters', {}).get('retry_after', 60)
except Exception:
pass
raise Exception(f"RetryAfter:{retry_after}")
elif response.status_code == 400:
try:
error_description = (response.json().get('description') or '').lower()
if "not modified" in error_description:
logger.info(
f"Caption уже содержит ссылки для {entity_label} "
f"(message is not modified HTTP 400, попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS})"
)
return True
logger.warning(
f"HTTP 400 edit (попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS}) "
f"для {entity_label}: {response.text[:300]}"
)
except Exception:
logger.warning(
f"HTTP 400 edit (попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS}) "
f"для {entity_label}: {response.text[:300]}"
)
else:
logger.warning(
f"HTTP {response.status_code} edit (попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS}) "
f"для {entity_label}: {response.text[:300]}"
)
if attempt < EDIT_LINKS_MAX_ATTEMPTS:
await asyncio.sleep(EDIT_LINKS_RETRY_INTERVAL_SEC)
logger.error(
f"Не удалось добавить строку ссылок для {entity_label} "
f"после {EDIT_LINKS_MAX_ATTEMPTS} попыток"
)
return False
async def tg_post_event(httpx_client, event_data): async def tg_post_event(httpx_client, event_data):
"""Публикация одного события""" """Публикация одного события"""
try: try:
@@ -194,24 +350,35 @@ async def tg_post_event(httpx_client, event_data):
# Публикация первоначального сообщения без ссылок # Публикация первоначального сообщения без ссылок
send_photo_url = bot_api_method_url(BOT_TOKEN, "sendPhoto") send_photo_url = bot_api_method_url(BOT_TOKEN, "sendPhoto")
send_message_url = bot_api_method_url(BOT_TOKEN, "sendMessage") send_message_url = bot_api_method_url(BOT_TOKEN, "sendMessage")
message_is_photo = False
if has_image: if has_image:
try: try:
logger.info(f"Попытка отправки фото для события {event_data['number']}: chat_id={CHANNEL_ID}, caption_length={len(initial_text)}, image_url_length={len(image_url)}") logger.info(
photo_payload = { f"Попытка отправки фото для события {event_data['number']}: "
f"chat_id={CHANNEL_ID}, caption_length={len(initial_text)}, "
f"image_url={image_url}"
)
image_data, content_type, filename = await download_image(image_url, httpx_client)
photo_data = {
'chat_id': CHANNEL_ID, 'chat_id': CHANNEL_ID,
'photo': image_url,
'caption': initial_text, 'caption': initial_text,
'parse_mode': 'HTML', 'parse_mode': 'HTML',
'disable_notification': PUBLISH_SILENTLY 'disable_notification': 'true' if PUBLISH_SILENTLY else 'false',
} }
photo_response = await httpx_client.post(send_photo_url, json=photo_payload) photo_files = {
'photo': (filename, image_data, content_type),
}
photo_response = await httpx_client.post(
send_photo_url, data=photo_data, files=photo_files
)
if photo_response.status_code == 200: if photo_response.status_code == 200:
photo_result = photo_response.json() photo_result = photo_response.json()
if photo_result.get('ok'): if photo_result.get('ok'):
message_data = photo_result.get('result', {}) message_data = photo_result.get('result', {})
message_id = message_data.get('message_id') message_id = message_data.get('message_id')
message_is_photo = True
logger.info(f"Фото успешно отправлено для события {event_data['number']}, message_id={message_id}") logger.info(f"Фото успешно отправлено для события {event_data['number']}, message_id={message_id}")
else: else:
# Ошибка в ответе API - пробуем отправить текстовое сообщение # Ошибка в ответе API - пробуем отправить текстовое сообщение
@@ -370,208 +537,36 @@ async def tg_post_event(httpx_client, event_data):
else: else:
final_text = initial_text final_text = initial_text
expected_urls = []
if DESC_PREFIX:
expected_urls.append(f"{DESC_PREFIX}{number}/")
if USE_SUBSCRIPTION_BOT:
expected_urls.append(f"{PZK_PREFIX}{number}/")
expected_urls.append(
subscription_start_link(RESPONDER_BOT_NAME, "event", message_id)
)
# Проверяем, изменился ли текст. Если нет - не редактируем сообщение # Проверяем, изменился ли текст. Если нет - не редактируем сообщение
if final_text == initial_text: if final_text == initial_text:
logger.info(f"Финальный текст идентичен исходному для события {event_data['number']}, редактирование не требуется") logger.info(f"Финальный текст идентичен исходному для события {event_data['number']}, редактирование не требуется")
return message_id return message_id
await asyncio.sleep(2) entity_label = f"события {event_data['number']}"
links_ok = await _edit_event_add_links(
# Редактируем сообщение, добавляя ссылки httpx_client, message_is_photo, message_id, final_text, entity_label,
logger.info(f"Попытка редактирования сообщения для события {event_data['number']}: message_id={message_id}, final_text_length={len(final_text)}") expected_urls,
edit_caption_url = bot_api_method_url(BOT_TOKEN, "editMessageCaption") )
edit_text_url = bot_api_method_url(BOT_TOKEN, "editMessageText") if not links_ok and message_is_photo:
links_ok = await _edit_event_add_links(
if has_image: httpx_client, False, message_id, final_text, entity_label,
try: expected_urls,
edit_caption_payload = { )
'chat_id': CHANNEL_ID, if not links_ok:
'message_id': message_id, logger.error(
'caption': final_text, f"Событие {event_data['number']}: сообщение отправлено (id={message_id}), "
'parse_mode': 'HTML' f"но ссылки не добавлены"
} )
edit_caption_response = await httpx_client.post(edit_caption_url, json=edit_caption_payload) return None
if edit_caption_response.status_code == 200:
edit_result = edit_caption_response.json()
if edit_result.get('ok'):
logger.info(f"Подпись успешно отредактирована для события {event_data['number']}")
else:
# Ошибка при редактировании подписи
error_code = edit_result.get('error_code')
error_description = edit_result.get('description', 'Unknown error')
# Обрабатываем ошибку 400 (сообщение не изменено) как предупреждение
if error_code == 400 and "not modified" in error_description.lower():
logger.info(f"Сообщение для события {event_data['number']} не требует редактирования (текст идентичен)")
return message_id
if error_code == 429:
retry_after = edit_result.get('parameters', {}).get('retry_after', 60)
raise Exception(f"RetryAfter:{retry_after}")
raise Exception(f"API error: {error_description}")
else:
if edit_caption_response.status_code == 400:
try:
error_data = edit_caption_response.json()
error_description = error_data.get('description', '')
if "not modified" in error_description.lower():
logger.info(f"Сообщение для события {event_data['number']} не требует редактирования (текст идентичен)")
return message_id
except:
pass
if edit_caption_response.status_code == 429:
retry_after = 60
try:
error_data = edit_caption_response.json()
retry_after = error_data.get('parameters', {}).get('retry_after', 60)
except:
pass
raise Exception(f"RetryAfter:{retry_after}")
raise Exception(f"HTTP {edit_caption_response.status_code}: {edit_caption_response.text}")
except httpx.TimeoutException as e:
logger.error(f"ТАЙМАУТ при редактировании подписи для события номер {event_data['number']}")
logger.error(f"Полная диагностическая информация об ошибке TimedOut:")
logger.error(f" Тип ошибки: {type(e).__name__}")
logger.error(f" Сообщение: {str(e)}")
logger.error(f" Все атрибуты ошибки: {vars(e)}")
logger.error(f" Полное представление: {repr(e)}")
logger.error(f" Параметры запроса:")
logger.error(f" chat_id: {CHANNEL_ID}")
logger.error(f" message_id: {message_id}")
logger.error(f" caption_length: {len(final_text)}")
logger.error(f" parse_mode: HTML")
raise
except Exception as e:
error_msg = str(e)
if error_msg.startswith("RetryAfter:"):
raise
logger.warning(f"Не удалось отредактировать подпись для события номер {event_data['number']}: {e}. Пробуем отредактировать текстовое сообщение.")
logger.error(f"Полная информация об ошибке Telegram при редактировании подписи для события {event_data['number']}:")
logger.error(f" Тип ошибки: {type(e).__name__}")
logger.error(f" Сообщение: {str(e)}")
logger.error(f" Все атрибуты ошибки: {vars(e) if hasattr(e, '__dict__') else 'N/A'}")
logger.error(f" Полное представление: {repr(e)}")
logger.info(f"Попытка редактирования текста вместо подписи для события {event_data['number']}")
try:
edit_text_payload = {
'chat_id': CHANNEL_ID,
'message_id': message_id,
'text': final_text,
'parse_mode': 'HTML',
'disable_web_page_preview': True
}
edit_text_response = await httpx_client.post(edit_text_url, json=edit_text_payload)
if edit_text_response.status_code == 200:
edit_result = edit_text_response.json()
if edit_result.get('ok'):
logger.info(f"Текст успешно отредактирован для события {event_data['number']}")
else:
error_code = edit_result.get('error_code')
error_description = edit_result.get('description', 'Unknown error')
# Обрабатываем ошибку 400 (сообщение не изменено) как предупреждение
if error_code == 400 and "not modified" in error_description.lower():
logger.info(f"Сообщение для события {event_data['number']} не требует редактирования (текст идентичен)")
return message_id
if error_code == 429:
retry_after = edit_result.get('parameters', {}).get('retry_after', 60)
raise Exception(f"RetryAfter:{retry_after}")
raise Exception(f"API error: {error_description}")
else:
if edit_text_response.status_code == 400:
try:
error_data = edit_text_response.json()
error_description = error_data.get('description', '')
if "not modified" in error_description.lower():
logger.info(f"Сообщение для события {event_data['number']} не требует редактирования (текст идентичен)")
return message_id
except:
pass
if edit_text_response.status_code == 429:
retry_after = 60
try:
error_data = edit_text_response.json()
retry_after = error_data.get('parameters', {}).get('retry_after', 60)
except:
pass
raise Exception(f"RetryAfter:{retry_after}")
raise Exception(f"HTTP {edit_text_response.status_code}: {edit_text_response.text}")
except httpx.TimeoutException as e:
logger.error(f"ТАЙМАУТ при редактировании текста для события номер {event_data['number']}")
logger.error(f"Полная диагностическая информация об ошибке TimedOut:")
logger.error(f" Тип ошибки: {type(e).__name__}")
logger.error(f" Сообщение: {str(e)}")
logger.error(f" Все атрибуты ошибки: {vars(e)}")
logger.error(f" Полное представление: {repr(e)}")
logger.error(f" Параметры запроса:")
logger.error(f" chat_id: {CHANNEL_ID}")
logger.error(f" message_id: {message_id}")
logger.error(f" text_length: {len(final_text)}")
logger.error(f" parse_mode: HTML")
raise
except Exception as e:
error_msg = str(e)
if error_msg.startswith("RetryAfter:"):
raise
raise
else:
try:
edit_text_payload = {
'chat_id': CHANNEL_ID,
'message_id': message_id,
'text': final_text,
'parse_mode': 'HTML',
'disable_web_page_preview': True
}
edit_text_response = await httpx_client.post(edit_text_url, json=edit_text_payload)
if edit_text_response.status_code == 200:
edit_result = edit_text_response.json()
if edit_result.get('ok'):
logger.info(f"Текст успешно отредактирован для события {event_data['number']}")
else:
error_code = edit_result.get('error_code')
error_description = edit_result.get('description', 'Unknown error')
# Обрабатываем ошибку 400 (сообщение не изменено) как предупреждение
if error_code == 400 and "not modified" in error_description.lower():
logger.info(f"Сообщение для события {event_data['number']} не требует редактирования (текст идентичен)")
return message_id
if error_code == 429:
retry_after = edit_result.get('parameters', {}).get('retry_after', 60)
raise Exception(f"RetryAfter:{retry_after}")
raise Exception(f"API error: {error_description}")
else:
if edit_text_response.status_code == 400:
try:
error_data = edit_text_response.json()
error_description = error_data.get('description', '')
if "not modified" in error_description.lower():
logger.info(f"Сообщение для события {event_data['number']} не требует редактирования (текст идентичен)")
return message_id
except:
pass
if edit_text_response.status_code == 429:
retry_after = 60
try:
error_data = edit_text_response.json()
retry_after = error_data.get('parameters', {}).get('retry_after', 60)
except:
pass
raise Exception(f"RetryAfter:{retry_after}")
raise Exception(f"HTTP {edit_text_response.status_code}: {edit_text_response.text}")
except httpx.TimeoutException as e:
logger.error(f"ТАЙМАУТ при редактировании текста для события номер {event_data['number']}")
logger.error(f"Полная диагностическая информация об ошибке TimedOut:")
logger.error(f" Тип ошибки: {type(e).__name__}")
logger.error(f" Сообщение: {str(e)}")
logger.error(f" Все атрибуты ошибки: {vars(e)}")
logger.error(f" Полное представление: {repr(e)}")
logger.error(f" Параметры запроса:")
logger.error(f" chat_id: {CHANNEL_ID}")
logger.error(f" message_id: {message_id}")
logger.error(f" text_length: {len(final_text)}")
logger.error(f" parse_mode: HTML")
raise
return message_id # Возвращаем только message_id return message_id # Возвращаем только message_id
-2
View File
@@ -1,2 +0,0 @@
import secrets
print(secrets.token_hex(32))
+19 -10
View File
@@ -6,15 +6,18 @@ SCRIPTS_DIR="/opt/testbot-git"
VENV_ACTIVATE="$SCRIPTS_DIR/venv/bin/activate" VENV_ACTIVATE="$SCRIPTS_DIR/venv/bin/activate"
# Список скриптов для запуска в порядке выполнения # Список скриптов для запуска в порядке выполнения
# SCRIPTS=("vk_load.py" "zk_load.py" "db_update_shortname.py" "evt_prefetch.py" "tg_publish.py" "evtg_publish.py") # Legacy prefetch (не в пайплайне): tools/legacy/evt_prefetch.py
SCRIPTS=("vk_load.py" "zk_load.py" "db_update_shortname.py" "tg_publish.py" "evtg_publish.py") # SCRIPTS=("vk_load.py" "zk_load.py" "db_update_shortname.py" "tools/legacy/evt_prefetch.py" "tg_publish.py" "evtg_publish.py")
# SCRIPTS=("vk_load.py" "zk_load.py" "db_update_shortname.py" "tg_publish.py" "evtg_publish.py")
SCRIPTS=("vk_load.py" "zk_load.py" "db_update_shortname.py" "tg_publish.py")
# Интервалы между скриптами (в секундах) # Интервалы между скриптами (в секундах)
# DELAYS=(2 2 2 2 30) # DELAYS=(2 2 2 2 30)
DELAYS=(2 2 2 30) DELAYS=(2 2 2)
# Лог-файл # Лог-файл
LOG_FILE="$SCRIPTS_DIR/runner.log" LOG_DIR="$SCRIPTS_DIR/logs"
LOG_FILE="$LOG_DIR/runner.log"
# Функция для логирования # Функция для логирования
log_message() { log_message() {
@@ -24,11 +27,20 @@ log_message() {
# Проверка и настройка лог-файла # Проверка и настройка лог-файла
setup_logfile() { setup_logfile() {
# Проверяем, существует ли файл и можно ли в него писать if [ ! -d "$LOG_DIR" ]; then
if ! mkdir -p "$LOG_DIR" 2>/dev/null; then
if sudo mkdir -p "$LOG_DIR" 2>/dev/null; then
sudo chown "$(whoami)" "$LOG_DIR"
sudo chmod 755 "$LOG_DIR"
else
echo "ОШИБКА: Не удалось создать каталог $LOG_DIR!"
exit 1
fi
fi
fi
if [ ! -f "$LOG_FILE" ]; then if [ ! -f "$LOG_FILE" ]; then
# Пытаемся создать файл
if ! touch "$LOG_FILE" 2>/dev/null; then if ! touch "$LOG_FILE" 2>/dev/null; then
# Если не получается, пробуем с sudo
if sudo touch "$LOG_FILE" 2>/dev/null; then if sudo touch "$LOG_FILE" 2>/dev/null; then
sudo chown $(whoami) "$LOG_FILE" sudo chown $(whoami) "$LOG_FILE"
sudo chmod 644 "$LOG_FILE" sudo chmod 644 "$LOG_FILE"
@@ -40,9 +52,7 @@ setup_logfile() {
fi fi
fi fi
# Проверяем права на запись
if [ ! -w "$LOG_FILE" ]; then if [ ! -w "$LOG_FILE" ]; then
# Пытаемся изменить права
if sudo chown $(whoami) "$LOG_FILE" 2>/dev/null || chmod 644 "$LOG_FILE" 2>/dev/null; then if sudo chown $(whoami) "$LOG_FILE" 2>/dev/null || chmod 644 "$LOG_FILE" 2>/dev/null; then
log_message "Изменены права на лог-файл: $LOG_FILE" log_message "Изменены права на лог-файл: $LOG_FILE"
else else
@@ -112,4 +122,3 @@ done
log_message "ВСЕ СКРИПТЫ ВЫПОЛНЕНЫ УСПЕШНО!" log_message "ВСЕ СКРИПТЫ ВЫПОЛНЕНЫ УСПЕШНО!"
log_message "=== ЗАВЕРШЕНИЕ ===" log_message "=== ЗАВЕРШЕНИЕ ==="
+2 -86
View File
@@ -1,87 +1,3 @@
# Настройка автоматического запуска runner.sh каждый час # Перенесено
Есть два способа настроить запуск `runner.sh` каждый час:
## Вариант 1: Systemd Timer (Рекомендуется)
Этот вариант рекомендуется, так как в проекте уже используются systemd сервисы.
### Установка:
1. Скопируйте файлы в systemd:
```bash
sudo cp services/runner.service /etc/systemd/system/
sudo cp services/runner.timer /etc/systemd/system/
```
2. Или используйте скрипт установки:
```bash
chmod +x services/install_runner_timer.sh
sudo bash services/install_runner_timer.sh
```
3. Вручную:
```bash
sudo systemctl daemon-reload
sudo systemctl enable runner.timer
sudo systemctl start runner.timer
```
### Управление:
- Проверить статус: `sudo systemctl status runner.timer`
- Остановить: `sudo systemctl stop runner.timer`
- Запустить: `sudo systemctl start runner.timer`
- Отключить: `sudo systemctl disable runner.timer`
- Просмотр логов: `sudo journalctl -u runner.service -f`
- Список всех timers: `sudo systemctl list-timers`
### Настройка расписания:
Отредактируйте файл `/etc/systemd/system/runner.timer`:
- `OnCalendar=hourly` - каждый час
- `OnCalendar=*-*-* *:00:00` - каждый час в начале часа
- `OnCalendar=*-*-* 0,6,12,18:00:00` - в 0, 6, 12, 18 часов
- `OnCalendar=Mon..Fri 09:00:00` - каждый будний день в 9:00
После изменения: `sudo systemctl daemon-reload`
---
## Вариант 2: Cron
Классический способ для периодических задач.
### Установка:
1. Откройте crontab для редактирования:
```bash
sudo crontab -e
```
2. Добавьте строку:
```bash
0 * * * * /bin/bash /opt/testbot-git/runner.sh >> /opt/testbot-git/runner_cron.log 2>&1
```
Это запустит скрипт в начале каждого часа (0 минут каждого часа).
### Альтернативные варианты расписания:
- `0 * * * *` - каждый час в начале часа (00:00, 01:00, 02:00...)
- `30 * * * *` - каждый час в 30 минут (00:30, 01:30, 02:30...)
- `*/30 * * * *` - каждые 30 минут
- `0 9-17 * * *` - каждый час с 9:00 до 17:00
### Просмотр логов:
Логи будут записываться в `/opt/testbot-git/runner_cron.log` (если указан в crontab).
---
## Рекомендации
- **Systemd Timer** - лучше для интеграции с существующими сервисами, более гибкое управление, лучшие логи
- **Cron** - проще для простых задач, не требует root прав (можно использовать `crontab -e` без sudo)
Документация: [`docs/runner-setup.md`](../docs/runner-setup.md)
+46
View File
@@ -50,3 +50,49 @@ def get_ptb_base_urls() -> Tuple[str, str]:
def bot_api_method_url(token: str, method: str) -> str: def bot_api_method_url(token: str, method: str) -> str:
"""Полный URL метода Bot API через relay (для сырых httpx/requests вызовов).""" """Полный URL метода Bot API через relay (для сырых httpx/requests вызовов)."""
return f"{get_relay_url()}/bot{token}/{method}" return f"{get_relay_url()}/bot{token}/{method}"
# Публикация в два шага (send → edit ссылок): пауза и повторы при гонке с Telegram
EDIT_LINKS_INITIAL_DELAY_SEC = 3
EDIT_LINKS_MAX_ATTEMPTS = 5
EDIT_LINKS_RETRY_INTERVAL_SEC = 2
def extract_text_link_urls(entities) -> list:
"""URL из entities типа text_link (ответ Telegram Bot API или python-telegram-bot)."""
urls = []
for ent in entities or []:
if isinstance(ent, dict):
if ent.get("type") == "text_link" and ent.get("url"):
urls.append(ent["url"])
elif getattr(ent, "type", None) == "text_link":
url = getattr(ent, "url", None)
if url:
urls.append(url)
return urls
def published_message_has_expected_links(entities, expected_urls) -> bool:
"""
Проверка, что в сообщении есть именно footer-ссылки публикации.
Сравнение только по URL в text_link entities (plain text короче HTML).
"""
expected_urls = [u for u in (expected_urls or []) if u]
if not expected_urls:
return True
found = set(extract_text_link_urls(entities))
return all(url in found for url in expected_urls)
def published_message_has_links(text: str, entities=None, expected_urls=None) -> bool:
"""Проверка footer-ссылок; expected_urls обязателен при вызове из publisher."""
if expected_urls is not None:
return published_message_has_expected_links(entities, expected_urls)
return bool(extract_text_link_urls(entities))
def published_text_has_links(text: str) -> bool:
"""Устаревший вызов без expected_urls — только для совместимости."""
return published_message_has_links(text)
+113 -74
View File
@@ -16,7 +16,15 @@ from formatter import get_event_text
from telegram import Bot from telegram import Bot
from telegram.error import TelegramError, TimedOut from telegram.error import TelegramError, TimedOut
from telegram.request import HTTPXRequest from telegram.request import HTTPXRequest
from telegram_relay import get_ptb_base_urls, get_relay_url from telegram_relay import (
get_ptb_base_urls,
get_relay_url,
EDIT_LINKS_INITIAL_DELAY_SEC,
EDIT_LINKS_MAX_ATTEMPTS,
EDIT_LINKS_RETRY_INTERVAL_SEC,
published_message_has_expected_links,
extract_text_link_urls,
)
from season_links import subscription_start_link from season_links import subscription_start_link
# Настройка anyio для правильной работы в отдельном потоке # Настройка anyio для правильной работы в отдельном потоке
@@ -146,6 +154,94 @@ async def download_image(image_url, httpx_client):
logger.error(f"Ошибка при скачивании изображения {image_url}: {e}") logger.error(f"Ошибка при скачивании изображения {image_url}: {e}")
raise raise
async def _edit_post_add_links(
bot, message_id, message_has_image, final_text, vk_post_id, expected_urls
):
"""
Второй шаг публикации: editMessageCaption/Text со ссылками.
Повторяет попытку, пока в ответе API нет всех expected_urls в text_link entities.
"""
await asyncio.sleep(EDIT_LINKS_INITIAL_DELAY_SEC)
for attempt in range(1, EDIT_LINKS_MAX_ATTEMPTS + 1):
try:
if message_has_image:
edited = await bot.edit_message_caption(
chat_id=CHANNEL_ID,
message_id=message_id,
caption=final_text,
parse_mode='HTML',
)
published_text = edited.caption or ""
entities = edited.caption_entities or []
else:
edited = await bot.edit_message_text(
chat_id=CHANNEL_ID,
message_id=message_id,
text=final_text,
parse_mode='HTML',
disable_web_page_preview=True,
)
published_text = edited.text or ""
entities = edited.entities or []
if published_message_has_expected_links(entities, expected_urls):
logger.info(
f"Ссылки добавлены для записи VK ID {vk_post_id} "
f"(попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS})"
)
return True
found = extract_text_link_urls(entities)
logger.warning(
f"editMessage* ok, но footer-ссылки не подтверждены "
f"(попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS}) для VK ID {vk_post_id}; "
f"ожидались {expected_urls}, найдены text_link: {found}"
)
except TimedOut as e:
if attempt >= EDIT_LINKS_MAX_ATTEMPTS:
logger.error(
f"Таймаут редактирования ссылок для VK ID {vk_post_id} "
f"после {EDIT_LINKS_MAX_ATTEMPTS} попыток: {e}"
)
return False
logger.warning(
f"Таймаут edit (попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS}) "
f"для VK ID {vk_post_id}, повтор"
)
except TelegramError as e:
if hasattr(e, 'retry_after') or '429' in str(e):
raise RetryAfterException(getattr(e, 'retry_after', 60))
err = str(e).lower()
if "not modified" in err:
logger.info(
f"Caption уже содержит ссылки для VK ID {vk_post_id} "
f"(message is not modified, попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS})"
)
return True
logger.warning(
f"Ошибка edit ссылок (попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS}) "
f"для VK ID {vk_post_id}: {e}"
)
except RetryAfterException:
raise
except Exception as e:
logger.warning(
f"Неожиданная ошибка edit ссылок (попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS}) "
f"для VK ID {vk_post_id}: {e}"
)
if attempt < EDIT_LINKS_MAX_ATTEMPTS:
await asyncio.sleep(EDIT_LINKS_RETRY_INTERVAL_SEC)
logger.error(
f"Не удалось добавить строку ссылок для VK ID {vk_post_id} "
f"после {EDIT_LINKS_MAX_ATTEMPTS} попыток"
)
return False
async def publish_to_tg(vk_post_id): async def publish_to_tg(vk_post_id):
"""Публикация одной записи по VK post ID""" """Публикация одной записи по VK post ID"""
logger.info(f"Запуск публикации для записи VK ID {vk_post_id}") logger.info(f"Запуск публикации для записи VK ID {vk_post_id}")
@@ -428,79 +524,22 @@ async def publish_to_tg(vk_post_id):
links_line = " | ".join(updated_links) links_line = " | ".join(updated_links)
final_text = f"{text}\n\n{links_line}" final_text = f"{text}\n\n{links_line}"
expected_urls = []
await asyncio.sleep(2) # задержка между обращениями к телеграм if post['vk_post_url'] and (post['vk_post_url'].startswith('http://') or post['vk_post_url'].startswith('https://')):
expected_urls.append(post['vk_post_url'])
# Редактируем сообщение, добавляя ссылки if post['is_event'] and USE_SUBSCRIPTION_BOT:
# Используем message_has_image для определения типа сообщения expected_urls.append(
# Если было отправлено изображение, редактируем caption, иначе - текст subscription_start_link(RESPONDER_BOT_NAME, "post", message_id)
if message_has_image: )
# Сообщение с изображением - редактируем caption с повторными попытками if not await _edit_post_add_links(
edit_success = False bot, message_id, message_has_image, final_text, vk_post_id,
for attempt in range(1, max_retries + 1): expected_urls,
try: ):
await bot.edit_message_caption( logger.error(
chat_id=CHANNEL_ID, f"Запись VK ID {vk_post_id}: сообщение отправлено (id={message_id}), "
message_id=message_id, f"но ссылки не добавлены — published_in_tg не обновляем"
caption=final_text, )
parse_mode='HTML' return False
)
edit_success = True
logger.info(f"Caption успешно отредактирован для записи VK ID {vk_post_id}")
break # Успешное редактирование, выходим из цикла
except TimedOut as e:
if attempt < max_retries:
logger.warning(f"Таймаут при редактировании caption (попытка {attempt}/{max_retries}) для записи VK ID {vk_post_id}. Повтор через {retry_interval} секунд...")
await asyncio.sleep(retry_interval)
else:
logger.warning(f"Таймаут при редактировании caption после {max_retries} попыток для записи VK ID {vk_post_id}. Сообщение опубликовано, но без ссылок.")
# Не критичная ошибка - сообщение уже опубликовано
break
except TelegramError as e:
if hasattr(e, 'retry_after') or '429' in str(e):
retry_after = getattr(e, 'retry_after', 60)
raise RetryAfterException(retry_after)
# Не критичная ошибка - сообщение уже опубликовано
logger.warning(f"Не удалось отредактировать caption для записи VK ID {vk_post_id}: {type(e).__name__}: {e}. Сообщение опубликовано, но без ссылок.")
break
except Exception as e:
# Не критичная ошибка - сообщение уже опубликовано
logger.warning(f"Неожиданная ошибка при редактировании caption для записи VK ID {vk_post_id}: {type(e).__name__}: {e}. Сообщение опубликовано, но без ссылок.")
break
else:
# Текстовое сообщение - редактируем текст с повторными попытками
edit_success = False
for attempt in range(1, max_retries + 1):
try:
await bot.edit_message_text(
chat_id=CHANNEL_ID,
message_id=message_id,
text=final_text,
parse_mode='HTML',
disable_web_page_preview=True
)
edit_success = True
logger.info(f"Текст успешно отредактирован для записи VK ID {vk_post_id}")
break # Успешное редактирование, выходим из цикла
except TimedOut as e:
if attempt < max_retries:
logger.warning(f"Таймаут при редактировании текста (попытка {attempt}/{max_retries}) для записи VK ID {vk_post_id}. Повтор через {retry_interval} секунд...")
await asyncio.sleep(retry_interval)
else:
logger.warning(f"Таймаут при редактировании текста после {max_retries} попыток для записи VK ID {vk_post_id}. Сообщение опубликовано, но без ссылок.")
# Не критичная ошибка - сообщение уже опубликовано
break
except TelegramError as e:
if hasattr(e, 'retry_after') or '429' in str(e):
retry_after = getattr(e, 'retry_after', 60)
raise RetryAfterException(retry_after)
# Не критичная ошибка - сообщение уже опубликовано
logger.warning(f"Не удалось отредактировать текст для записи VK ID {vk_post_id}: {type(e).__name__}: {e}. Сообщение опубликовано, но без ссылок.")
break
except Exception as e:
# Не критичная ошибка - сообщение уже опубликовано
logger.warning(f"Неожиданная ошибка при редактировании текста для записи VK ID {vk_post_id}: {type(e).__name__}: {e}. Сообщение опубликовано, но без ссылок.")
break
await asyncio.sleep(2) # задержка между обращениями к телеграм await asyncio.sleep(2) # задержка между обращениями к телеграм
+41
View File
@@ -0,0 +1,41 @@
# Вспомогательные скрипты
Скрипты вне основного пайплайна (`runner.sh`) и systemd-сервисов. Запускать из **корня проекта** с активированным venv.
## ops/ — эксплуатация
| Файл | Назначение |
|------|------------|
| `genkey.py` | Генерация hex-ключа для `SECRET_KEY` / `WEBHOOK_SECRET` |
| `register_tg_webhook.py` | Ручная регистрация webhook (в норме — `wsgi_bot.py` при старте) |
| `copy_databases.sh` | Клонирование MariaDB между базами |
```bash
python tools/ops/genkey.py
./venv/bin/python tools/ops/register_tg_webhook.py
bash tools/ops/copy_databases.sh <source_db> <target_db> <username>
```
## dev/ — отладка и примеры
| Файл | Назначение |
|------|------------|
| `send_telegram_example.py` | CLI-пример API `/send_message` |
| `volk_telegram_testing.py` | Отладочный тест публикации в Telegram |
| `volk_category_testing.py` | Отладочный тест загрузки категории VOLK |
```bash
python tools/dev/send_telegram_example.py <user_id> "<message>"
python tools/dev/volk_telegram_testing.py
python tools/dev/volk_category_testing.py <category_id>
```
## legacy/ — снятые с пайплайна
| Файл | Назначение |
|------|------------|
| `evt_prefetch.py` | Массовая пометка видимых событий `marked_to_publication=1`. Убран из `runner.sh` в 1.0.0 |
```bash
./venv/bin/python tools/legacy/evt_prefetch.py
```
@@ -1,7 +1,9 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Пример скрипта для отправки сообщений через Telegram бота Пример скрипта для отправки сообщений через Telegram бота.
Использование: python send_telegram_example.py <user_id> <message>
Использование (из корня проекта):
python tools/dev/send_telegram_example.py <user_id> <message>
""" """
import sys import sys
@@ -9,6 +11,12 @@ import requests
import json import json
import os import os
from dotenv import load_dotenv
_PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
load_dotenv(os.path.join(_PROJECT_ROOT, ".env"))
load_dotenv()
def send_message(user_id, message_text, bot_url=None, api_token=None): def send_message(user_id, message_text, bot_url=None, api_token=None):
""" """
Отправляет сообщение пользователю через Telegram бота Отправляет сообщение пользователю через Telegram бота
@@ -64,8 +72,8 @@ def send_message(user_id, message_text, bot_url=None, api_token=None):
def main(): def main():
if len(sys.argv) != 3: if len(sys.argv) != 3:
print("Использование: python send_telegram_example.py <user_id> <message>") print("Использование: python tools/dev/send_telegram_example.py <user_id> <message>")
print("Пример: python send_telegram_example.py 123456789 'Привет! Это тестовое сообщение.'") print("Пример: python tools/dev/send_telegram_example.py 123456789 'Привет! Это тестовое сообщение.'")
sys.exit(1) sys.exit(1)
try: try:
@@ -1,7 +1,9 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Тестовый модуль для проверки загрузки описания категории. Тестовый модуль для проверки загрузки описания категории.
Использование: python volk_category_testing.py <category_id>
Использование (из корня проекта):
python tools/dev/volk_category_testing.py <category_id>
""" """
import os import os
import sys import sys
@@ -15,7 +17,8 @@ from urllib.request import urlopen, Request
from urllib.error import URLError, HTTPError from urllib.error import URLError, HTTPError
from dotenv import load_dotenv from dotenv import load_dotenv
# Загрузка переменных окружения _PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
load_dotenv(os.path.join(_PROJECT_ROOT, ".env"))
load_dotenv() load_dotenv()
# Параметры из .env # Параметры из .env
@@ -260,8 +263,8 @@ def main():
formatter_class=argparse.RawDescriptionHelpFormatter, formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=""" epilog="""
Примеры использования: Примеры использования:
python volk_category_testing.py volk26_nri python tools/dev/volk_category_testing.py volk26_nri
python volk_category_testing.py volk26_nri volk26_other volk26_third python tools/dev/volk_category_testing.py volk26_nri volk26_other volk26_third
""" """
) )
parser.add_argument( parser.add_argument(
@@ -7,12 +7,17 @@ import sys
import json import json
from datetime import datetime from datetime import datetime
from dotenv import load_dotenv from dotenv import load_dotenv
_PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
if _PROJECT_ROOT not in sys.path:
sys.path.insert(0, _PROJECT_ROOT)
load_dotenv(os.path.join(_PROJECT_ROOT, ".env"))
load_dotenv()
from telegram_relay import bot_api_method_url, get_relay_url from telegram_relay import bot_api_method_url, get_relay_url
from season_links import subscription_start_link from season_links import subscription_start_link
# Загрузка переменных окружения
load_dotenv()
# Настройки из переменных окружения # Настройки из переменных окружения
POSTER_BOT_TOKEN = os.getenv('POSTER_BOT_TOKEN') POSTER_BOT_TOKEN = os.getenv('POSTER_BOT_TOKEN')
RESPONDER_BOT_NAME = os.getenv('RESPONDER_BOT_NAME') RESPONDER_BOT_NAME = os.getenv('RESPONDER_BOT_NAME')
@@ -1,10 +1,19 @@
#!/usr/bin/env python3
"""LEGACY — не входит в пайплайн runner.sh с версии 1.0.0.
Проставляет marked_to_publication = 1 видимым событиям (is_visible = 1),
у которых флаг ещё не установлен.
Запуск вручную из корня проекта:
./venv/bin/python tools/legacy/evt_prefetch.py
"""
import os import os
import mysql.connector import mysql.connector
from mysql.connector import Error from mysql.connector import Error
from datetime import datetime, timezone
from dotenv import load_dotenv from dotenv import load_dotenv
# Загрузка переменных окружения _PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
load_dotenv(os.path.join(_PROJECT_ROOT, ".env"))
load_dotenv() load_dotenv()
MDB_HOST = os.getenv('MDB_HOST') MDB_HOST = os.getenv('MDB_HOST')
@@ -12,6 +21,7 @@ MDB_USER = os.getenv('MDB_USER')
MDB_PW = os.getenv('MDB_PW') MDB_PW = os.getenv('MDB_PW')
MDBASE = os.getenv('MDBASE') MDBASE = os.getenv('MDBASE')
def prefetch_all_events(): def prefetch_all_events():
""" """
Проверяет записи в таблице events и устанавливает флаг marked_to_publication Проверяет записи в таблице events и устанавливает флаг marked_to_publication
@@ -20,7 +30,6 @@ def prefetch_all_events():
- не имеют флага marked_to_publication = 1 - не имеют флага marked_to_publication = 1
""" """
try: try:
# Подключение к базе данных
connection = mysql.connector.connect( connection = mysql.connector.connect(
host=MDB_HOST, host=MDB_HOST,
user=MDB_USER, user=MDB_USER,
@@ -28,14 +37,12 @@ def prefetch_all_events():
database=MDBASE database=MDBASE
) )
# Установка временной зоны соединения в UTC
cursor_temp = connection.cursor() cursor_temp = connection.cursor()
cursor_temp.execute("SET time_zone = '+00:00'") cursor_temp.execute("SET time_zone = '+00:00'")
cursor_temp.close() cursor_temp.close()
cursor = connection.cursor() cursor = connection.cursor()
# SQL запрос для обновления записей
update_query = """ update_query = """
UPDATE events UPDATE events
SET marked_to_publication = 1 SET marked_to_publication = 1
@@ -43,15 +50,11 @@ def prefetch_all_events():
AND marked_to_publication = 0 AND marked_to_publication = 0
""" """
# Выполнение запроса
cursor.execute(update_query) cursor.execute(update_query)
updated_count = cursor.rowcount updated_count = cursor.rowcount
# Фиксация изменений
connection.commit() connection.commit()
print(f"Обновлено записей: {updated_count}") print(f"Обновлено записей: {updated_count}")
return updated_count return updated_count
except Error as e: except Error as e:
@@ -67,5 +70,6 @@ def prefetch_all_events():
cursor.close() cursor.close()
connection.close() connection.close()
if __name__ == "__main__": if __name__ == "__main__":
prefetch_all_events() prefetch_all_events()
@@ -1,7 +1,9 @@
#!/bin/bash #!/bin/bash
# Использование: ./copy_db.sh <source_db> <target_db> <username> # Использование (из корня проекта):
# Пример: ./copy_db.sh old_db new_db appuser # bash tools/ops/copy_databases.sh <source_db> <target_db> <username>
# Пример:
# bash tools/ops/copy_databases.sh old_db new_db appuser
set -e # Завершать выполнение при любой ошибке set -e # Завершать выполнение при любой ошибке
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env python3
"""Генерация случайного hex-ключа для SECRET_KEY или WEBHOOK_SECRET.
Запуск из корня проекта:
python tools/ops/genkey.py
"""
import secrets
if __name__ == "__main__":
print(secrets.token_hex(32))
@@ -2,21 +2,24 @@
""" """
Ручная регистрация Telegram webhook через internal_relay (с secret_token). Ручная регистрация Telegram webhook через internal_relay (с secret_token).
Запуск на сервере бота: Запуск из корня проекта:
cd /opt/testbot-git ./venv/bin/python tools/ops/register_tg_webhook.py
./venv/bin/python register_tg_webhook.py
Печатает полный ответ setWebhook по нему видно, принял ли Telegram секрет. Печатает полный ответ setWebhook по нему видно, принял ли Telegram секрет.
""" """
import json import json
import sys import sys
import os
_PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
if _PROJECT_ROOT not in sys.path:
sys.path.insert(0, _PROJECT_ROOT)
from dotenv import load_dotenv from dotenv import load_dotenv
import os
import requests import requests
load_dotenv(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env")) load_dotenv(os.path.join(_PROJECT_ROOT, ".env"))
load_dotenv() load_dotenv()
from telegram_relay import bot_api_method_url, get_relay_url from telegram_relay import bot_api_method_url, get_relay_url
+43 -9
View File
@@ -19,6 +19,14 @@ VK_API_VERSION = os.getenv('VK_API_VERSION', '5.131')
IS_EVENT = os.getenv('IS_EVENT', 'false').lower() in ('true', '1', 'yes', 'on') IS_EVENT = os.getenv('IS_EVENT', 'false').lower() in ('true', '1', 'yes', 'on')
AUTO_PUBLISH = os.getenv('AUTO_PUBLISH', 'false').lower() in ('true', '1', 'yes', 'on') AUTO_PUBLISH = os.getenv('AUTO_PUBLISH', 'false').lower() in ('true', '1', 'yes', 'on')
VK_NO_REPOST_TAG = os.getenv('VK_NO_REPOST_TAG', '') # Тег для отключения автопубликации VK_NO_REPOST_TAG = os.getenv('VK_NO_REPOST_TAG', '') # Тег для отключения автопубликации
# Минимум букв (isalpha), чтобы пост не считался пустым (видео без описания и т.п.)
try:
VK_POST_MIN_LETTERS = int((os.getenv('VK_POST_MIN_LETTERS') or '5').strip().strip('"').strip("'"))
except ValueError:
VK_POST_MIN_LETTERS = 5
if VK_POST_MIN_LETTERS < 0:
VK_POST_MIN_LETTERS = 0
EMPTY_POST_SHORTNAME = "- = Пустой пост = -"
LOG_FILE = os.getenv('LOG_FILE', 'vk_loader.log') # Путь к лог-файлу LOG_FILE = os.getenv('LOG_FILE', 'vk_loader.log') # Путь к лог-файлу
LOG_PREFIX = "VK_loader" # Уникальный префикс для идентификации скрипта LOG_PREFIX = "VK_loader" # Уникальный префикс для идентификации скрипта
@@ -86,6 +94,12 @@ def get_vk_posts():
log_message(error_msg) log_message(error_msg)
raise Exception(error_msg) raise Exception(error_msg)
def count_letters(text):
"""Количество букв в тексте (Unicode isalpha), без цифр и знаков."""
return sum(1 for c in (text or "") if c.isalpha())
def process_post(post): def process_post(post):
"""Извлекает необходимые данные из поста VK, включая обработку репостов""" """Извлекает необходимые данные из поста VK, включая обработку репостов"""
post_id = post['id'] post_id = post['id']
@@ -144,11 +158,15 @@ def process_post(post):
# Формируем текст поста: если это репост, добавляем префикс и информацию об оригинале # Формируем текст поста: если это репост, добавляем префикс и информацию об оригинале
post_text = source_post.get('text', '') post_text = source_post.get('text', '')
own_text = post.get('text', '')
letter_count = count_letters(post_text)
if is_repost: if is_repost:
letter_count += count_letters(own_text)
# Если у репоста есть свой текст, добавляем его перед текстом оригинала # Если у репоста есть свой текст, добавляем его перед текстом оригинала
if post.get('text', '').strip(): if own_text.strip():
post_text = f"{post['text']}\n\n---\n\n{post_text}" post_text = f"{own_text}\n\n---\n\n{post_text}"
post_text = repost_prefix + post_text + repost_info post_text = repost_prefix + post_text + repost_info
is_empty_text = letter_count < VK_POST_MIN_LETTERS
return { return {
'vk_post_id': post_id, 'vk_post_id': post_id,
@@ -161,7 +179,9 @@ def process_post(post):
'poll_options': poll_options, 'poll_options': poll_options,
'poll_multiple': poll_multiple, 'poll_multiple': poll_multiple,
'poll_end_date': poll_end_date, 'poll_end_date': poll_end_date,
'is_repost': is_repost 'is_repost': is_repost,
'is_empty_text': is_empty_text,
'letter_count': letter_count,
} }
def save_to_database(posts): def save_to_database(posts):
@@ -175,9 +195,14 @@ def save_to_database(posts):
try: try:
# Проверка наличия тега VK_NO_REPOST_TAG в тексте поста # Проверка наличия тега VK_NO_REPOST_TAG в тексте поста
post_text = post.get('text', '') post_text = post.get('text', '')
is_empty_text = post.get('is_empty_text', False)
should_auto_publish = AUTO_PUBLISH should_auto_publish = AUTO_PUBLISH
shortname = None
if VK_NO_REPOST_TAG and VK_NO_REPOST_TAG in post_text: if is_empty_text:
should_auto_publish = False
shortname = EMPTY_POST_SHORTNAME
elif VK_NO_REPOST_TAG and VK_NO_REPOST_TAG in post_text:
# Если тег найден, отключаем автопубликацию независимо от AUTO_PUBLISH # Если тег найден, отключаем автопубликацию независимо от AUTO_PUBLISH
should_auto_publish = False should_auto_publish = False
@@ -201,16 +226,21 @@ def save_to_database(posts):
int(post['poll_multiple']), int(post['poll_multiple']),
post['poll_end_date'], post['poll_end_date'],
IS_EVENT, IS_EVENT,
int(should_auto_publish), # Значение с учетом проверки тега int(should_auto_publish),
None, # shortname - пока не используется, устанавливаем NULL shortname,
None # action_number - пока не используется, устанавливаем NULL None # action_number
)) ))
if cursor.rowcount > 0: if cursor.rowcount > 0:
new_posts_count += 1 new_posts_count += 1
# Логируем добавление нового поста с указанием причины отключения автопубликации # Логируем добавление нового поста с указанием причины отключения автопубликации
auto_publish_reason = 'Да' auto_publish_reason = 'Да'
if not should_auto_publish: if not should_auto_publish:
if VK_NO_REPOST_TAG and VK_NO_REPOST_TAG in post_text: if is_empty_text:
auto_publish_reason = (
f'Нет (пустой текст: {post.get("letter_count", 0)} букв '
f'< {VK_POST_MIN_LETTERS})'
)
elif VK_NO_REPOST_TAG and VK_NO_REPOST_TAG in post_text:
auto_publish_reason = 'Нет (найден тег ' + VK_NO_REPOST_TAG + ')' auto_publish_reason = 'Нет (найден тег ' + VK_NO_REPOST_TAG + ')'
else: else:
auto_publish_reason = 'Нет' auto_publish_reason = 'Нет'
@@ -246,7 +276,11 @@ def vk_load_10():
Возвращает количество добавленных постов Возвращает количество добавленных постов
""" """
# Стартовая информация # Стартовая информация
start_msg = f"Старт скрипта | База: {DB_CONFIG['database']}@{DB_CONFIG['host']} | IS_EVENT: {IS_EVENT} | AUTO_PUBLISH: {AUTO_PUBLISH}" start_msg = (
f"Старт скрипта | База: {DB_CONFIG['database']}@{DB_CONFIG['host']} | "
f"IS_EVENT: {IS_EVENT} | AUTO_PUBLISH: {AUTO_PUBLISH} | "
f"VK_POST_MIN_LETTERS: {VK_POST_MIN_LETTERS}"
)
print(start_msg) print(start_msg)
log_message(start_msg) log_message(start_msg)