diff --git a/.cursor/rules/version-and-docs.mdc b/.cursor/rules/version-and-docs.mdc index 41e928a..b721c01 100644 --- a/.cursor/rules/version-and-docs.mdc +++ b/.cursor/rules/version-and-docs.mdc @@ -20,4 +20,4 @@ alwaysApply: true 5. Не поднимать версию за косметические правки без изменения поведения. -Текущая базовая версия: **1.1.2**. +Текущая базовая версия: **1.1.3**. diff --git a/README.md b/README.md index 3945315..a70c66a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Zilant / VOLK — Telegram poster и подписки -**Версия:** 1.1.2 ([`VERSION`](VERSION)) +**Версия:** 1.1.3 ([`VERSION`](VERSION)) Автоматическая публикация постов и событий в Telegram-канал, веб-редактор, бот подписок с сезонными deep link. diff --git a/VERSION b/VERSION index 45a1b3f..781dcb0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.2 +1.1.3 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index ec49e2b..13d57f6 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -3,6 +3,12 @@ Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/). Версия проекта — в файле [`VERSION`](../VERSION) (SemVer). +## [1.1.3] - 2026-09-02 + +### Fixed + +- Проверка footer-ссылок после edit: сравнение по конкретным URL (Инфо/Иду/Подписка, ВК), а не по любому `text_link` или словам в тексте тела события — устраняет ложный «успех» при пакетной публикации. + ## [1.1.2] - 2026-09-02 ### Fixed diff --git a/evtg_publish.py b/evtg_publish.py index 853350d..31667e6 100644 --- a/evtg_publish.py +++ b/evtg_publish.py @@ -14,7 +14,8 @@ from telegram_relay import ( EDIT_LINKS_INITIAL_DELAY_SEC, EDIT_LINKS_MAX_ATTEMPTS, EDIT_LINKS_RETRY_INTERVAL_SEC, - published_message_has_links, + published_message_has_expected_links, + extract_text_link_urls, ) from season_links import subscription_start_link @@ -132,8 +133,10 @@ async def download_image(image_url, httpx_client): 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): - """Edit caption/text со ссылками; повтор до подтверждения text_link в ответе API.""" +async def _edit_event_add_links( + httpx_client, has_image, message_id, final_text, entity_label, expected_urls, min_length +): + """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" @@ -176,15 +179,20 @@ async def _edit_event_add_links(httpx_client, has_image, message_id, final_text, result = data.get('result') or {} result_text = result.get(field) or "" entities = result.get(entities_field) or [] - if published_message_has_links(result_text, entities): + if published_message_has_expected_links( + entities, expected_urls, result_text=result_text, min_length=min_length + ): logger.info( f"Ссылки добавлены для {entity_label} " f"(попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS})" ) return True + found = extract_text_link_urls(entities) logger.warning( - f"edit ok, но ссылки не обнаружены (попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS}) " - f"для {entity_label}" + f"edit ok, но footer-ссылки не подтверждены " + f"(попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS}) для {entity_label}; " + f"ожидались {expected_urls}, найдены text_link: {found}, " + f"len(text)={len(result_text)}, min_len={min_length}" ) else: error_code = data.get('error_code') @@ -532,6 +540,16 @@ 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) + ) + min_length = len(final_text) - 15 + # Проверяем, изменился ли текст. Если нет - не редактируем сообщение if final_text == initial_text: logger.info(f"Финальный текст идентичен исходному для события {event_data['number']}, редактирование не требуется") @@ -539,11 +557,13 @@ async def tg_post_event(httpx_client, event_data): entity_label = f"события {event_data['number']}" links_ok = await _edit_event_add_links( - httpx_client, message_is_photo, message_id, final_text, entity_label + httpx_client, message_is_photo, message_id, final_text, entity_label, + expected_urls, min_length, ) if not links_ok and message_is_photo: links_ok = await _edit_event_add_links( - httpx_client, False, message_id, final_text, entity_label + httpx_client, False, message_id, final_text, entity_label, + expected_urls, min_length, ) if not links_ok: logger.error( diff --git a/telegram_relay.py b/telegram_relay.py index c0ea745..3b07911 100644 --- a/telegram_relay.py +++ b/telegram_relay.py @@ -58,32 +58,57 @@ EDIT_LINKS_MAX_ATTEMPTS = 5 EDIT_LINKS_RETRY_INTERVAL_SEC = 2 -def published_message_has_links(text: str, entities=None) -> bool: +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, + *, + result_text: str | None = None, + min_length: int | None = None, +) -> bool: """ - Проверка, что в опубликованном caption/text есть кликабельные ссылки. + Проверка, что в сообщении есть именно footer-ссылки публикации. - Telegram в ответе editMessage* отдаёт plain text (без href=) и entities типа text_link. + Нельзя проверять «любой text_link» или слова «Инфо»/«Подписка» в тексте: + в теле события могут быть свои из replace_vk_links. """ - if entities: - for ent in entities: - if isinstance(ent, dict): - if ent.get("type") == "text_link" and ent.get("url"): - return True - elif getattr(ent, "type", None) == "text_link" and getattr(ent, "url", None): - return True + expected_urls = [u for u in (expected_urls or []) if u] + if not expected_urls: + return True - if not text: - return False + if min_length is not None and result_text is not None: + if len(result_text) < min_length: + return False - link_markers = ( - "Оригинал в ВК", - "Подписка", - "Инфо", - "Иду", - ) - return any(marker in text for marker in link_markers) + 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, + result_text=text, + min_length=None, + ) + return bool(extract_text_link_urls(entities)) def published_text_has_links(text: str) -> bool: - """Обратная совместимость; предпочтительно published_message_has_links.""" + """Устаревший вызов без expected_urls — только для совместимости.""" return published_message_has_links(text) diff --git a/tg_publish.py b/tg_publish.py index 8ccd02d..3daf620 100644 --- a/tg_publish.py +++ b/tg_publish.py @@ -22,7 +22,8 @@ from telegram_relay import ( EDIT_LINKS_INITIAL_DELAY_SEC, EDIT_LINKS_MAX_ATTEMPTS, EDIT_LINKS_RETRY_INTERVAL_SEC, - published_message_has_links, + published_message_has_expected_links, + extract_text_link_urls, ) from season_links import subscription_start_link @@ -154,10 +155,12 @@ async def download_image(image_url, httpx_client): raise -async def _edit_post_add_links(bot, message_id, message_has_image, final_text, vk_post_id): +async def _edit_post_add_links( + bot, message_id, message_has_image, final_text, vk_post_id, expected_urls, min_length +): """ Второй шаг публикации: editMessageCaption/Text со ссылками. - Повторяет попытку, пока в ответе API нет text_link / меток ссылок. + Повторяет попытку, пока в ответе API нет всех expected_urls в text_link entities. """ await asyncio.sleep(EDIT_LINKS_INITIAL_DELAY_SEC) @@ -183,16 +186,21 @@ async def _edit_post_add_links(bot, message_id, message_has_image, final_text, v published_text = edited.text or "" entities = edited.entities or [] - if published_message_has_links(published_text, entities): + if published_message_has_expected_links( + entities, expected_urls, result_text=published_text, min_length=min_length + ): 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, но ссылки не обнаружены в ответе " - f"(попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS}) для VK ID {vk_post_id}" + f"editMessage* ok, но footer-ссылки не подтверждены " + f"(попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS}) для VK ID {vk_post_id}; " + f"ожидались {expected_urls}, найдены text_link: {found}, " + f"len(text)={len(published_text)}, min_len={min_length}" ) except TimedOut as e: if attempt >= EDIT_LINKS_MAX_ATTEMPTS: @@ -519,9 +527,18 @@ async def publish_to_tg(vk_post_id): links_line = " | ".join(updated_links) final_text = f"{text}\n\n{links_line}" + 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) + ) + min_length = len(final_text) - 15 if not await _edit_post_add_links( - bot, message_id, message_has_image, final_text, vk_post_id + bot, message_id, message_has_image, final_text, vk_post_id, + expected_urls, min_length, ): logger.error( f"Запись VK ID {vk_post_id}: сообщение отправлено (id={message_id}), "