import os import logging import asyncio import pymysql import time import html import httpx from datetime import datetime, timezone from dotenv import load_dotenv from formatter import get_event_text # Загрузка переменных окружения load_dotenv() # Настройки из переменных окружения BOT_TOKEN = os.getenv('POSTER_BOT_TOKEN') CHANNEL_ID = os.getenv('CHANNEL_ID') MDB_HOST = os.getenv('MDB_HOST') MDB_USER = os.getenv('MDB_USER') MDB_PW = os.getenv('MDB_PW') MDBASE = os.getenv('MDBASE') LOG_FILE = os.getenv('LOG_FILE', 'volk_cat_publisher.log') # Параметры длины сообщений MAX_CAPTION_LENGTH = int(os.getenv('MAX_CAPTION_LENGTH', 1000)) MAX_TEXT_LENGTH = int(os.getenv('MAX_TEXT_LENGTH', 4000)) # Режим публикации без звука PUBLISH_SILENTLY = os.getenv('PUBLISH_SILENTLY', 'false').lower() in ('true', '1', 'yes', 'on') # Настройка логирования logger = logging.getLogger('TG_CAT_post') logger.setLevel(logging.INFO) formatter = logging.Formatter('[%(asctime)s] [%(name)s] %(message)s', datefmt='%Y-%m-%d %H:%M:%S') console_handler = logging.StreamHandler() console_handler.setFormatter(formatter) logger.addHandler(console_handler) class RetryFileHandler(logging.FileHandler): def emit(self, record): for _ in range(5): try: super().emit(record) return except (IOError, PermissionError): time.sleep(0.5) print(f"Failed to write to log file after 5 attempts: {record.msg}") file_handler = RetryFileHandler(LOG_FILE, encoding='utf-8') file_handler.setFormatter(formatter) logger.addHandler(file_handler) # Класс для обработки RetryAfter (FloodWait 429) class RetryAfterException(Exception): """Исключение для обработки FloodWait (429) от Telegram API""" def __init__(self, retry_after): self.retry_after = retry_after super().__init__(f"RetryAfter: {retry_after}") def prepare_text(text): """Подготовка текстовых полей с экранированием HTML-сущностей""" if not text: return "" # Экранируем специальные символы HTML text = html.escape(str(text)) return text async def tg_post_category(httpx_client, category_data): """Публикация одной категории""" try: # Форматирование текста title = prepare_text(category_data['TITLE'] or "") description_raw = category_data['description'] # Формируем базовый текст (название категории) caption_parts = [] if title: caption_parts.append(f"{title}") base_text = "\n".join(caption_parts) base_text_length = len(base_text) # Определяем максимальную длину в зависимости от типа сообщения image_url = category_data['image_url'] has_image = image_url and image_url.strip() max_length = MAX_CAPTION_LENGTH if has_image else MAX_TEXT_LENGTH # Вычисляем доступную длину для описания категории available_length = max_length - base_text_length - 3 # -3 для символов переноса строки if available_length < 0: available_length = 0 # Получаем обработанный текст с учетом доступной длины description = get_event_text(description_raw, available_length) if description_raw else "" # Формируем первоначальный текст initial_text = f"{base_text}\n\n{description}" if description else base_text # Логируем детальную информацию о данных для публикации logger.info(f"=== Детали публикации категории ID {category_data['ID']} ===") logger.info(f"Длина initial_text: {len(initial_text)} символов") logger.info(f"Длина base_text: {base_text_length} символов") logger.info(f"Длина description: {len(description)} символов") logger.info(f"Максимальная длина (max_length): {max_length} символов") logger.info(f"Доступная длина для description: {available_length} символов") logger.info(f"Есть изображение: {has_image}") if has_image: logger.info(f"URL изображения: {image_url}") logger.info(f"Длина URL изображения: {len(image_url)} символов") logger.info(f"Текст для публикации: {initial_text[:200]}..." if len(initial_text) > 200 else f"Текст для публикации: {initial_text}") # Публикация сообщения send_photo_url = f"https://api.telegram.org/bot{BOT_TOKEN}/sendPhoto" send_message_url = f"https://api.telegram.org/bot{BOT_TOKEN}/sendMessage" if has_image: try: logger.info(f"Попытка отправки фото для категории {category_data['ID']}: chat_id={CHANNEL_ID}, caption_length={len(initial_text)}, image_url_length={len(image_url)}") photo_payload = { 'chat_id': CHANNEL_ID, 'photo': image_url, 'caption': initial_text, 'parse_mode': 'HTML', 'disable_notification': PUBLISH_SILENTLY } photo_response = await httpx_client.post(send_photo_url, json=photo_payload) 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') logger.info(f"Фото успешно отправлено для категории {category_data['ID']}, message_id={message_id}") else: # Ошибка в ответе API - пробуем отправить текстовое сообщение error_code = photo_result.get('error_code') if error_code == 429: # FloodWait - пробрасываем как RetryAfter retry_after = photo_result.get('parameters', {}).get('retry_after', 60) raise Exception(f"RetryAfter:{retry_after}") raise Exception(f"API error: {photo_result.get('description', 'Unknown error')}") else: if photo_response.status_code == 429: # FloodWait retry_after = 60 try: error_data = photo_response.json() retry_after = error_data.get('parameters', {}).get('retry_after', 60) except: pass raise Exception(f"RetryAfter:{retry_after}") raise Exception(f"HTTP {photo_response.status_code}: {photo_response.text}") except httpx.TimeoutException as e: logger.error(f"ТАЙМАУТ при отправке изображения для категории ID {category_data['ID']}") 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" image_url: {image_url}") logger.error(f" caption_length: {len(initial_text)}") logger.error(f" parse_mode: HTML") raise except Exception as e: error_msg = str(e) if error_msg.startswith("RetryAfter:"): retry_after = int(error_msg.split(":")[1]) raise Exception(f"RetryAfter:{retry_after}") logger.warning(f"Не удалось отправить изображение для категории ID {category_data['ID']}: {e}. Отправляем текстовое сообщение.") logger.error(f"Полная информация об ошибке Telegram при отправке изображения для категории {category_data['ID']}:") 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"Попытка отправки текстового сообщения вместо фото для категории {category_data['ID']}") try: text_payload = { 'chat_id': CHANNEL_ID, 'text': initial_text, 'parse_mode': 'HTML', 'disable_notification': PUBLISH_SILENTLY, 'disable_web_page_preview': True } text_response = await httpx_client.post(send_message_url, json=text_payload) if text_response.status_code == 200: text_result = text_response.json() if text_result.get('ok'): message_data = text_result.get('result', {}) message_id = message_data.get('message_id') logger.info(f"Текстовое сообщение успешно отправлено для категории {category_data['ID']}, message_id={message_id}") else: error_code = text_result.get('error_code') if error_code == 429: retry_after = text_result.get('parameters', {}).get('retry_after', 60) raise Exception(f"RetryAfter:{retry_after}") raise Exception(f"API error: {text_result.get('description', 'Unknown error')}") else: if text_response.status_code == 429: retry_after = 60 try: error_data = 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 {text_response.status_code}: {text_response.text}") except httpx.TimeoutException as e: logger.error(f"ТАЙМАУТ при отправке текстового сообщения для категории ID {category_data['ID']}") 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" text_length: {len(initial_text)}") logger.error(f" parse_mode: HTML") raise except Exception as e: error_msg = str(e) if error_msg.startswith("RetryAfter:"): raise raise else: logger.info(f"Попытка отправки текстового сообщения для категории {category_data['ID']}: chat_id={CHANNEL_ID}, text_length={len(initial_text)}") try: text_payload = { 'chat_id': CHANNEL_ID, 'text': initial_text, 'parse_mode': 'HTML', 'disable_notification': PUBLISH_SILENTLY, 'disable_web_page_preview': True } text_response = await httpx_client.post(send_message_url, json=text_payload) if text_response.status_code == 200: text_result = text_response.json() if text_result.get('ok'): message_data = text_result.get('result', {}) message_id = message_data.get('message_id') logger.info(f"Текстовое сообщение успешно отправлено для категории {category_data['ID']}, message_id={message_id}") else: error_code = text_result.get('error_code') if error_code == 429: retry_after = text_result.get('parameters', {}).get('retry_after', 60) raise Exception(f"RetryAfter:{retry_after}") raise Exception(f"API error: {text_result.get('description', 'Unknown error')}") else: if text_response.status_code == 429: retry_after = 60 try: error_data = 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 {text_response.status_code}: {text_response.text}") except httpx.TimeoutException as e: logger.error(f"ТАЙМАУТ при отправке текстового сообщения для категории ID {category_data['ID']}") 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" text_length: {len(initial_text)}") logger.error(f" parse_mode: HTML") raise return message_id # Возвращаем только message_id except httpx.TimeoutException as e: logger.error(f"ТАЙМАУТ при публикации категории ID {category_data['ID']}") 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" ID: {category_data.get('ID')}") logger.error(f" has_image: {bool(category_data.get('image_url'))}") logger.error(f" image_url_length: {len(category_data.get('image_url', '') or '')}") logger.error(f" initial_text_length: {len(initial_text) if 'initial_text' in locals() else 'N/A'}") return None except Exception as e: error_msg = str(e) if error_msg.startswith("RetryAfter:"): # Обработка ошибки FloodWait (429) retry_after = int(error_msg.split(":")[1]) logger.error(f"Получена ошибка FloodWait (429) при публикации категории ID {category_data['ID']}") logger.error(f"Необходимо подождать {retry_after} секунд перед следующей попыткой") logger.error(f"Полная информация об ошибке RetryAfter для категории {category_data['ID']}:") logger.error(f" Тип ошибки: RetryAfter") logger.error(f" Сообщение: {error_msg}") logger.error(f" retry_after: {retry_after}") raise RetryAfterException(retry_after) logger.error(f"Ошибка Telegram при публикации категории ID {category_data['ID']}: {e}") logger.error(f"Полная информация об ошибке Telegram для категории {category_data['ID']}:") 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)}") return None except Exception as e: logger.error(f"Неожиданная ошибка при публикации категории ID {category_data['ID']}: {e}") return None async def tg_post_category_by_id(id_category): """Публикация категории по ID""" logger.info(f"Запуск публикации для категории ID {id_category}") conn = None try: conn = pymysql.connect( host=MDB_HOST, user=MDB_USER, password=MDB_PW, database=MDBASE, charset='utf8mb4', cursorclass=pymysql.cursors.DictCursor ) with conn.cursor() as cursor: cursor.execute(""" SELECT AUTO_ID, ID, TITLE, description, image_url, tg_id FROM categories WHERE ID = %s AND marked_for_publication = True AND is_tg_published = False """, (id_category,)) category = cursor.fetchone() if not category: logger.info(f"Категория ID {id_category} не найдена или уже опубликована") return old_tg_id = category.get('tg_id') # Сохраняем старый ID сообщения # Создаем httpx клиент с отключенным HTTP/2 httpx_client = httpx.AsyncClient( http2=False, timeout=20.0, follow_redirects=True ) try: tg_id = await tg_post_category(httpx_client, category) # Получаем новый message_id finally: await httpx_client.aclose() if tg_id: # Используем UTC время для записи в базу данных current_time_utc = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") cursor.execute(""" UPDATE categories SET is_tg_published = True, tg_id = %s, tg_published_date = %s WHERE ID = %s """, (tg_id, current_time_utc, id_category)) conn.commit() logger.info(f"Категория ID {category['ID']} успешно опубликована") else: logger.error(f"Не удалось опубликовать категорию ID {id_category}") except Exception as e: # Проверяем, не RetryAfter ли это error_msg = str(e) if error_msg.startswith("RetryAfter:") or (hasattr(e, 'retry_after')): retry_after = getattr(e, 'retry_after', int(error_msg.split(":")[1]) if ":" in error_msg else 60) logger.error(f"Полная информация об ошибке RetryAfter в tg_post_category_by_id для категории ID {id_category}:") logger.error(f" Тип ошибки: RetryAfter") logger.error(f" Сообщение: {error_msg}") logger.error(f" retry_after: {retry_after}") logger.error(f" Полное представление: {repr(e)}") raise RetryAfterException(retry_after) # Если это не RetryAfter, обрабатываем как обычную ошибку if "RetryAfter" not in error_msg: logger.error(f"Ошибка при публикации: {e}") logger.error(f"Полная информация об ошибке в tg_post_category_by_id для категории ID {id_category}:") 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)}") except Exception as e: logger.error(f"Ошибка при публикации: {e}") logger.error(f"Полная информация об ошибке в tg_post_category_by_id для категории ID {id_category}:") logger.error(f" Тип ошибки: {type(e).__name__}") logger.error(f" Сообщение: {str(e)}") logger.error(f" Все атрибуты ошибки: {vars(e)}") logger.error(f" Полное представление: {repr(e)}") finally: if conn: conn.close() async def tg_post_all_categories(): """Публикация всех неопубликованных категорий (максимум 5 за один вызов)""" logger.info("Запуск скрипта публикации всех категорий") conn = None try: conn = pymysql.connect( host=MDB_HOST, user=MDB_USER, password=MDB_PW, database=MDBASE, charset='utf8mb4', cursorclass=pymysql.cursors.DictCursor ) with conn.cursor() as cursor: cursor.execute(""" SELECT ID FROM categories WHERE marked_for_publication = True AND is_tg_published = False LIMIT 5 """) categories = cursor.fetchall() category_count = 0 for category in categories: try: await tg_post_category_by_id(category['ID']) category_count += 1 # Задержка между публикациями разных категорий if category_count < len(categories): await asyncio.sleep(2) except RetryAfterException as e: logger.error(f"Прерываем публикацию из-за ошибки FloodWait. Ожидание: {e.retry_after} секунд") logger.error(f"Полная информация об ошибке RetryAfter в tg_post_all_categories для категории ID {category['ID']}:") logger.error(f" Тип ошибки: RetryAfter") logger.error(f" Сообщение: {str(e)}") logger.error(f" retry_after: {e.retry_after}") logger.error(f" Полное представление: {repr(e)}") break except Exception as e: logger.error(f"Ошибка при публикации категории ID {category['ID']}: {e}") logger.error(f"Полная информация об ошибке в tg_post_all_categories для категории ID {category['ID']}:") 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)}") # Продолжаем публикацию следующих категорий, несмотря на ошибку except Exception as e: logger.error(f"Ошибка при публикации категории ID {category['ID']}: {e}") logger.error(f"Полная информация об ошибке в tg_post_all_categories для категории ID {category['ID']}:") logger.error(f" Тип ошибки: {type(e).__name__}") logger.error(f" Сообщение: {str(e)}") logger.error(f" Все атрибуты ошибки: {vars(e)}") logger.error(f" Полное представление: {repr(e)}") # Продолжаем публикацию следующих категорий, несмотря на ошибку logger.info(f"Опубликовано категорий в этом запуске: {category_count}") except Exception as e: logger.error(f"Ошибка при публикации: {e}") finally: if conn: conn.close() logger.info("Завершение работы скрипта") if __name__ == "__main__": asyncio.run(tg_post_all_categories())