Compare commits

..
12 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
18 changed files with 762 additions and 318 deletions
+1 -1
View File
@@ -20,4 +20,4 @@ alwaysApply: true
5. Не поднимать версию за косметические правки без изменения поведения.
Текущая базовая версия: **1.0.1**.
Текущая базовая версия: **1.1.4**.
+8 -6
View File
@@ -56,17 +56,17 @@ VK_ACCESS_TOKEN = 'e3732a7ee3732a7ee3732a7ea7e044a75fee373e3732a7e8bf4e48a52d012
VK_API_VERSION = '5.131'
# MySQL database
# MDBASE = 'testposts'
MDBASE = 'testposts'
# MDBASE = 'tempzilant' # debug Zilant database
# Zilant 2026 database
MDBASE = 'zilant_2026'
# MDBASE = 'zilant_2026'
# MDBASE = 'volk_test' # debug VOLK database
# MDBASE = 'volkdata' # VOLK production database
MDB_HOST = 'localhost'
MDB_USER = 'zilantposter'
MDB_PW = 'Z1l@ntp08t3r'
# MDB_USER = 'testuser'
# MDB_PW = 'T38tU83r69'
# MDB_USER = 'zilantposter'
# MDB_PW = 'Z1l@ntp08t3r'
MDB_USER = 'testuser'
MDB_PW = 'T38tU83r69'
# MDB_USER = 'volkuser'
# MDB_PW = 'V0lK@dM1n23'
@@ -104,6 +104,8 @@ RESPONDER_BOT_NAME = "posto_1638_bot"
ORG_MESSAGE_TAG = "#оргинфаЗиланткона"
# tag to detect posts to not repost from VK
VK_NO_REPOST_TAG = "#окоЗиланта"
# Минимум букв в тексте VK-поста (без учёта цифр/знаков). Меньше — «пустой пост», не в публикацию
VK_POST_MIN_LETTERS = 5
# 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
+8
View File
@@ -1,3 +1,11 @@
__pycache__/
*.py[cod]
*$py.class
# Секреты инстанса — только шаблон .env.example в репозитории
.env
.env.local
.env.*.local
# Логи инстанса
logs/
+4 -2
View File
@@ -1,6 +1,6 @@
# Zilant / VOLK — Telegram poster и подписки
**Версия:** 1.0.1 ([`VERSION`](VERSION))
**Версия:** 1.1.4 ([`VERSION`](VERSION))
Автоматическая публикация постов и событий в Telegram-канал, веб-редактор, бот подписок с сезонными deep link.
@@ -23,7 +23,9 @@ sudo systemctl status testedit-git.service # UI :5006
sudo systemctl status runner.timer # hourly pipeline
```
Конфигурация — `.env` (не коммитится). Ключевые переменные: `RELAY_URL`, `WEBHOOK_URL`, `SEASON`, `MDB_*`.
Конфигурация — скопируйте [`.env.example`](.env.example) в `.env` и заполните значения
(файл `.env` в git не коммитится). Ключевые переменные: `WORKMODE`, `RELAY_URL`, `WEBHOOK_URL`, `SEASON`, `MDB_*`.
Генерация секретов: `python tools/ops/genkey.py`.
## Пайплайн (ZILANT)
+1 -1
View File
@@ -1 +1 @@
1.0.1
1.1.4
+109 -2
View File
@@ -30,6 +30,79 @@ ROUTERAI_CONFIG = {
# Настройки журналирования
LOG_FILE = os.getenv('LOG_FILE', 'ai_namer.log') # Путь к лог-файлу
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):
"""
@@ -90,6 +163,8 @@ def _generate_shortname_by_api(text):
"temperature": 0.3,
}
_log_routerai_request(text, prompt)
try:
response = requests.post(url, headers=headers, json=data, timeout=180)
@@ -107,20 +182,36 @@ def _generate_shortname_by_api(text):
except Exception:
pass
_log_routerai_response(response, error_body=error_info.get("body"))
log_message(f"Ошибка 429: Превышен лимит запросов. Ответ API: {json.dumps(error_info, ensure_ascii=False)}")
print("Ошибка 429: Превышен лимит запросов. Подробности в логе.")
# Не sys.exit — эта функция вызывается и из веб-API Flask
raise RuntimeError("RouterAI 429: превышен лимит запросов")
if not response.ok:
_log_routerai_response(response, error_body=response.text)
response.raise_for_status()
result = response.json()
_log_routerai_response(response, result=result)
# Извлекаем только текст ответа (у thinking-моделей ответ в content,
# рассуждения — в 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()
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
# Удаляем возможные кавычки и лишние символы
@@ -149,14 +240,30 @@ def _generate_shortname_by_api(text):
except Exception:
pass
_log_routerai_response(e.response, error_body=error_info.get("body"))
log_message(f"Ошибка 429: Превышен лимит запросов. Ответ API: {json.dumps(error_info, ensure_ascii=False)}")
print("Ошибка 429: Превышен лимит запросов. Подробности в логе.")
raise RuntimeError("RouterAI 429: превышен лимит запросов")
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}"
log_message(error_msg)
print(error_msg)
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:
raise
except Exception as e:
+12
View File
@@ -76,6 +76,13 @@
## Конфигурация (.env)
Шаблон для нового инстанса: [`.env.example`](../.env.example).
```bash
cp .env.example .env
# заполнить CHANGE_ME_*; секреты: python tools/ops/genkey.py
```
| Переменная | Назначение |
|------------|------------|
| `WORKMODE` | `ZILANT` или `VOLK` |
@@ -84,6 +91,11 @@
| `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 |
## Карта файлов
+61
View File
@@ -3,6 +3,67 @@
Формат основан на [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
+3 -1
View File
@@ -61,7 +61,7 @@ sudo crontab -e
```
```bash
0 * * * * /bin/bash /opt/testbot-git/runner.sh >> /opt/testbot-git/runner_cron.log 2>&1
0 * * * * /bin/bash /opt/testbot-git/runner.sh
```
Другие варианты: `30 * * * *`, `*/30 * * * *`, `0 9-17 * * *`.
@@ -73,3 +73,5 @@ sudo crontab -e
`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/` создаётся автоматически).
+1 -1
View File
@@ -1,6 +1,6 @@
# Режим VOLK
Режим включается в `.env`:
Режим включается в `.env` (скопируйте из [`.env.example`](../.env.example)):
```env
WORKMODE = "VOLK"
+198 -203
View File
@@ -6,9 +6,17 @@ import time
import html
import httpx
from datetime import datetime, timezone, timedelta
from urllib.parse import urlparse, unquote
from dotenv import load_dotenv
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
# Загрузка переменных окружения
@@ -92,6 +100,154 @@ def prepare_text(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):
"""Публикация одного события"""
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_message_url = bot_api_method_url(BOT_TOKEN, "sendMessage")
message_is_photo = False
if has_image:
try:
logger.info(f"Попытка отправки фото для события {event_data['number']}: chat_id={CHANNEL_ID}, caption_length={len(initial_text)}, image_url_length={len(image_url)}")
photo_payload = {
logger.info(
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,
'photo': image_url,
'caption': initial_text,
'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:
photo_result = photo_response.json()
if photo_result.get('ok'):
message_data = photo_result.get('result', {})
message_id = message_data.get('message_id')
message_is_photo = True
logger.info(f"Фото успешно отправлено для события {event_data['number']}, message_id={message_id}")
else:
# Ошибка в ответе API - пробуем отправить текстовое сообщение
@@ -370,208 +537,36 @@ async def tg_post_event(httpx_client, event_data):
else:
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:
logger.info(f"Финальный текст идентичен исходному для события {event_data['number']}, редактирование не требуется")
return message_id
await asyncio.sleep(2)
# Редактируем сообщение, добавляя ссылки
logger.info(f"Попытка редактирования сообщения для события {event_data['number']}: message_id={message_id}, final_text_length={len(final_text)}")
edit_caption_url = bot_api_method_url(BOT_TOKEN, "editMessageCaption")
edit_text_url = bot_api_method_url(BOT_TOKEN, "editMessageText")
if has_image:
try:
edit_caption_payload = {
'chat_id': CHANNEL_ID,
'message_id': message_id,
'caption': final_text,
'parse_mode': 'HTML'
}
edit_caption_response = await httpx_client.post(edit_caption_url, json=edit_caption_payload)
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
entity_label = f"события {event_data['number']}"
links_ok = await _edit_event_add_links(
httpx_client, message_is_photo, message_id, final_text, entity_label,
expected_urls,
)
if not links_ok and message_is_photo:
links_ok = await _edit_event_add_links(
httpx_client, False, message_id, final_text, entity_label,
expected_urls,
)
if not links_ok:
logger.error(
f"Событие {event_data['number']}: сообщение отправлено (id={message_id}), "
f"но ссылки не добавлены"
)
return None
return message_id # Возвращаем только message_id
+17 -9
View File
@@ -8,14 +8,16 @@ VENV_ACTIVATE="$SCRIPTS_DIR/venv/bin/activate"
# Список скриптов для запуска в порядке выполнения
# Legacy prefetch (не в пайплайне): tools/legacy/evt_prefetch.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" "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 30)
DELAYS=(2 2 2)
# Лог-файл
LOG_FILE="$SCRIPTS_DIR/runner.log"
LOG_DIR="$SCRIPTS_DIR/logs"
LOG_FILE="$LOG_DIR/runner.log"
# Функция для логирования
log_message() {
@@ -25,11 +27,20 @@ log_message() {
# Проверка и настройка лог-файла
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 ! touch "$LOG_FILE" 2>/dev/null; then
# Если не получается, пробуем с sudo
if sudo touch "$LOG_FILE" 2>/dev/null; then
sudo chown $(whoami) "$LOG_FILE"
sudo chmod 644 "$LOG_FILE"
@@ -41,9 +52,7 @@ setup_logfile() {
fi
fi
# Проверяем права на запись
if [ ! -w "$LOG_FILE" ]; then
# Пытаемся изменить права
if sudo chown $(whoami) "$LOG_FILE" 2>/dev/null || chmod 644 "$LOG_FILE" 2>/dev/null; then
log_message "Изменены права на лог-файл: $LOG_FILE"
else
@@ -113,4 +122,3 @@ done
log_message "ВСЕ СКРИПТЫ ВЫПОЛНЕНЫ УСПЕШНО!"
log_message "=== ЗАВЕРШЕНИЕ ==="
+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:
"""Полный URL метода Bot API через relay (для сырых httpx/requests вызовов)."""
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)
+111 -72
View File
@@ -16,7 +16,15 @@ from formatter import get_event_text
from telegram import Bot
from telegram.error import TelegramError, TimedOut
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
# Настройка anyio для правильной работы в отдельном потоке
@@ -146,6 +154,94 @@ async def download_image(image_url, httpx_client):
logger.error(f"Ошибка при скачивании изображения {image_url}: {e}")
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):
"""Публикация одной записи по 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)
final_text = f"{text}\n\n{links_line}"
await asyncio.sleep(2) # задержка между обращениями к телеграм
# Редактируем сообщение, добавляя ссылки
# Используем message_has_image для определения типа сообщения
# Если было отправлено изображение, редактируем caption, иначе - текст
if message_has_image:
# Сообщение с изображением - редактируем caption с повторными попытками
edit_success = False
for attempt in range(1, max_retries + 1):
try:
await bot.edit_message_caption(
chat_id=CHANNEL_ID,
message_id=message_id,
caption=final_text,
parse_mode='HTML'
expected_urls = []
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:
expected_urls.append(
subscription_start_link(RESPONDER_BOT_NAME, "post", message_id)
)
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
if not await _edit_post_add_links(
bot, message_id, message_has_image, final_text, vk_post_id,
expected_urls,
):
logger.error(
f"Запись VK ID {vk_post_id}: сообщение отправлено (id={message_id}), "
f"но ссылки не добавлены — published_in_tg не обновляем"
)
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
return False
await asyncio.sleep(2) # задержка между обращениями к телеграм
+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')
AUTO_PUBLISH = os.getenv('AUTO_PUBLISH', 'false').lower() in ('true', '1', 'yes', 'on')
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_PREFIX = "VK_loader" # Уникальный префикс для идентификации скрипта
@@ -86,6 +94,12 @@ def get_vk_posts():
log_message(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):
"""Извлекает необходимые данные из поста VK, включая обработку репостов"""
post_id = post['id']
@@ -144,11 +158,15 @@ def process_post(post):
# Формируем текст поста: если это репост, добавляем префикс и информацию об оригинале
post_text = source_post.get('text', '')
own_text = post.get('text', '')
letter_count = count_letters(post_text)
if is_repost:
letter_count += count_letters(own_text)
# Если у репоста есть свой текст, добавляем его перед текстом оригинала
if post.get('text', '').strip():
post_text = f"{post['text']}\n\n---\n\n{post_text}"
if own_text.strip():
post_text = f"{own_text}\n\n---\n\n{post_text}"
post_text = repost_prefix + post_text + repost_info
is_empty_text = letter_count < VK_POST_MIN_LETTERS
return {
'vk_post_id': post_id,
@@ -161,7 +179,9 @@ def process_post(post):
'poll_options': poll_options,
'poll_multiple': poll_multiple,
'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):
@@ -175,9 +195,14 @@ def save_to_database(posts):
try:
# Проверка наличия тега VK_NO_REPOST_TAG в тексте поста
post_text = post.get('text', '')
is_empty_text = post.get('is_empty_text', False)
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
should_auto_publish = False
@@ -201,16 +226,21 @@ def save_to_database(posts):
int(post['poll_multiple']),
post['poll_end_date'],
IS_EVENT,
int(should_auto_publish), # Значение с учетом проверки тега
None, # shortname - пока не используется, устанавливаем NULL
None # action_number - пока не используется, устанавливаем NULL
int(should_auto_publish),
shortname,
None # action_number
))
if cursor.rowcount > 0:
new_posts_count += 1
# Логируем добавление нового поста с указанием причины отключения автопубликации
auto_publish_reason = 'Да'
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 + ')'
else:
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)
log_message(start_msg)