From 4534ab2683c9261ba70e5d981c3c7d95fc454f1d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 20:13:38 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20=D0=BF=D0=BE=D0=B4=D0=B4=D0=B5=D1=80=D0=B6=D0=BA=D1=83?= =?UTF-8?q?=20=D0=AF=D0=BD=D0=B4=D0=B5=D0=BA=D1=81=20=D0=9C=D0=B0=D1=80?= =?UTF-8?q?=D0=BA=D0=B5=D1=82=D0=B0=20(goods-feedback=20API)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - app/services/ym.py: клиент Partner API — getGoodsFeedbacks с пагинацией и фильтром по дате, updateGoodsFeedbackComment (ответ), deleteGoodsFeedbackComment (удаление), обработка новых отзывов по аналогии с WB/Ozon - воркер ym_worker, включается при заданных YM_API_KEY и YM_BUSINESS_ID - кнопки отправки/удаления ответа, платформа в сообщении и в таблице, статистика /stat и /allstat - переменные YM_API_KEY, YM_BUSINESS_ID, YM_CHECK_INTERVAL, PRODUCTS_YM_SHEET_LINK - README и .env.example Co-Authored-By: Claude Fable 5.1 --- .env.example | 12 +- README.md | 28 ++-- app/__main__.py | 39 ++++- app/config.py | 6 + app/handlers/callbacks.py | 57 ++++++- app/handlers/commands.py | 11 +- app/handlers/reviews.py | 11 +- app/models/review.py | 2 +- app/services/__init__.py | 9 +- app/services/ym.py | 330 ++++++++++++++++++++++++++++++++++++++ 10 files changed, 481 insertions(+), 24 deletions(-) create mode 100644 app/services/ym.py diff --git a/.env.example b/.env.example index 6a3e762..e53056a 100644 --- a/.env.example +++ b/.env.example @@ -18,7 +18,7 @@ NEGATIVE_RATING=3 # --- Прокси (необязательно) --- # Прокси для запросов к Telegram, например socks5://user:pass@host:1080 TG_PROXY= -# Системные прокси для остальных запросов (Ozon, WB, GPT, Google) +# Системные прокси для остальных запросов (Ozon, WB, Яндекс Маркет, GPT, Google) ALL_PROXY= HTTPS_PROXY= HTTP_PROXY= @@ -39,6 +39,7 @@ REVIEWS_SHEET_LINK= # Справочники товаров (название, категория) по артикулам PRODUCTS_WB_SHEET_LINK= PRODUCTS_OZON_SHEET_LINK= +PRODUCTS_YM_SHEET_LINK= # --- Ozon Seller API --- OZON_API_KEY= @@ -51,6 +52,15 @@ WB_API_KEY= # Интервал опроса в секундах (по умолчанию 300) WB_CHECK_INTERVAL=300 +# --- Яндекс Маркет (Partner API, goods-feedback) --- +# Api-Key с доступом «Общение с покупателями» (communication) и идентификатор +# кабинета (businessId, виден в URL кабинета партнёра). Если не заданы, опрос +# Яндекс Маркета не запускается. +YM_API_KEY= +YM_BUSINESS_ID= +# Интервал опроса в секундах (по умолчанию 300) +YM_CHECK_INTERVAL=300 + # --- GPT (через gptunnel.ru, OpenAI-совместимый API) --- GPTUNNEL_API_KEY= diff --git a/README.md b/README.md index 8ae8f8c..ab14f25 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,14 @@ -# Бот для отзывов Ozon и Wildberries +# Бот для отзывов Ozon, Wildberries и Яндекс Маркета -Telegram-бот, который собирает новые отзывы с Ozon и Wildberries, публикует их в группу с топиками, анализирует через GPT и помогает на них отвечать: автоматически или по кнопке. +Telegram-бот, который собирает новые отзывы с Ozon, Wildberries и Яндекс Маркета, публикует их в группу с топиками, анализирует через GPT и помогает на них отвечать: автоматически или по кнопке. ## Что умеет -- **Опрос маркетплейсов.** Фоновые воркеры по таймеру запрашивают новые отзывы через Ozon Seller API и WB Feedbacks API. Каждый отзыв сохраняется в SQLite, повторно не обрабатывается. +- **Опрос маркетплейсов.** Фоновые воркеры по таймеру запрашивают новые отзывы через Ozon Seller API, WB Feedbacks API и Яндекс Маркет Partner API (goods-feedback). Каждый отзыв сохраняется в SQLite, повторно не обрабатывается. - **Раскладка по топикам.** Отзывы с оценкой выше порога (`NEGATIVE_RATING`, по умолчанию 3) уходят в топик хороших, остальные в топик плохих. Отзывы без текста (только оценка) можно направлять в отдельные топики. - **Анализ через GPT.** Определяет тональность, причину и критичность отзыва по промпту. Критичные отзывы помечаются `#требует_обработки`. - **Ответы.** Три варианта: автоответ через GPT, автоответ по шаблону для отзывов без текста, либо ручной режим с кнопками «Сгенерировать ответ» / «Написать свой ответ» / «Отправить» / «Удалить ответ». -- **Google Sheets.** Каждый отзыв дописывается строкой в таблицу отзывов. Названия и категории товаров подтягиваются из справочников товаров WB и Ozon. +- **Google Sheets.** Каждый отзыв дописывается строкой в таблицу отзывов. Названия и категории товаров подтягиваются из справочников товаров WB, Ozon и Яндекс Маркета. - **Статистика.** Команды `/stat` (за сегодня) и `/allstat` (за всё время): количество, доля плохих, средний рейтинг, критичные, тональность. Настройки (промпты, автоответы, модель, шаблоны) хранятся в базе отдельно для хороших и плохих отзывов и меняются прямо из бота. Они сохраняются между перезапусками и редеплоями, поэтому база должна лежать на постоянном томе. @@ -17,12 +17,12 @@ Telegram-бот, который собирает новые отзывы с Ozon ``` Ozon API ─┐ ┌─> Telegram (топики хороших / плохих / без текста) - ├─> воркер ─> SQLite ─> очереди ─┤ -WB API ──┘ │ └─> Google Sheets (таблица отзывов) +WB API ──┼─> воркер ─> SQLite ─> очереди ─┤ +YM API ──┘ │ └─> Google Sheets (таблица отзывов) └─> GPT: анализ + генерация ответа ``` -Точка входа `app/__main__.py` поднимает четыре бесконечные задачи: опрос Ozon, опрос WB, отправка сообщений в Telegram, запись в Google Sheets. Затем запускает polling Telegram. Бот реагирует только на сообщения из чата `TG_CHAT_ID` в разрешённых топиках. +Точка входа `app/__main__.py` поднимает бесконечные задачи: опрос Ozon, опрос WB, опрос Яндекс Маркета (если заданы `YM_API_KEY` и `YM_BUSINESS_ID`), отправка сообщений в Telegram, запись в Google Sheets. Затем запускает polling Telegram. Бот реагирует только на сообщения из чата `TG_CHAT_ID` в разрешённых топиках. Структура кода: @@ -33,7 +33,7 @@ WB API ──┘ │ └─> Google Sheets (таб | `app/handlers/callbacks.py` | Кнопки: генерация, отправка, удаление ответа, переключатели настроек | | `app/handlers/commands.py` | Команды `/stat`, `/allstat`, `/settings`, шаблоны, `/max_tokens` | | `app/handlers/prompt.py` | Просмотр, изменение и сброс промптов | -| `app/services/ozon.py`, `wb.py` | Клиенты API маркетплейсов и обработка новых отзывов | +| `app/services/ozon.py`, `wb.py`, `ym.py` | Клиенты API маркетплейсов и обработка новых отзывов | | `app/services/gpt.py` | Запросы к GPT, разбор JSON анализа | | `app/services/sheets.py` | Работа с Google Sheets через gspread | | `app/services/stats.py` | Подсчёт статистики | @@ -45,7 +45,7 @@ WB API ──┘ │ └─> Google Sheets (таб - [uv](https://docs.astral.sh/uv/) (рекомендуется) или pip - Telegram-бот, добавленный в супергруппу с включёнными топиками - Сервисный аккаунт Google с доступом к Google Sheets API -- Ключи API Ozon Seller, Wildberries (категория «Отзывы и вопросы») и gptunnel.ru +- Ключи API Ozon Seller, Wildberries (категория «Отзывы и вопросы»), Яндекс Маркета (Api-Key с доступом «Общение с покупателями», необязательно) и gptunnel.ru ## Установка и запуск @@ -94,8 +94,8 @@ WB API ──┘ │ └─> Google Sheets (таб | `BOT_TOKEN` | Токен бота от @BotFather | | `TG_CHAT_ID`, `GOOD_REVIEWS_THREAD_ID`, `BAD_REVIEWS_THREAD_ID` | Группа и топики | | `GOOGLE_SERVICE_ACCOUNT_JSON` | Содержимое `key.json` одной строкой или в base64 | - | `REVIEWS_SHEET_LINK`, `PRODUCTS_WB_SHEET_LINK`, `PRODUCTS_OZON_SHEET_LINK` | Ссылки на таблицы | - | `OZON_API_KEY`, `OZON_CLIENT_ID`, `WB_API_KEY`, `GPTUNNEL_API_KEY` | Ключи API | + | `REVIEWS_SHEET_LINK`, `PRODUCTS_WB_SHEET_LINK`, `PRODUCTS_OZON_SHEET_LINK`, `PRODUCTS_YM_SHEET_LINK` | Ссылки на таблицы | + | `OZON_API_KEY`, `OZON_CLIENT_ID`, `WB_API_KEY`, `YM_API_KEY`, `YM_BUSINESS_ID`, `GPTUNNEL_API_KEY` | Ключи API | | `BRAND_NAME` | Название бренда для промптов | Ключ Google удобнее передавать в base64, тогда в значении нет кавычек и переносов строк: @@ -136,6 +136,12 @@ Compose читает `.env` из корня, если файл есть, и хр **Справочник товаров Ozon** (`PRODUCTS_OZON_SHEET_LINK`). Ключевой столбец `Ozon ID` или `ozon id акутальный`. Используются `Товары`, `Тип товара`, `Категория 2-го уровня`. +**Справочник товаров Яндекс Маркета** (`PRODUCTS_YM_SHEET_LINK`, необязательно). Ключевой столбец `SKU`, `Ваш SKU`, `offerId` или `Артикул` — это SKU продавца (`offerId` в API). Используются столбцы `Название`/`Наименование`/`Название товара` и `Категория`/`Категория продавца`. Если таблица не задана или товар не найден, название и категория записываются как «неизвестно». + +## Яндекс Маркет + +Используются методы Partner API `getGoodsFeedbacks` (список отзывов, с пагинацией и фильтром по дате), `updateGoodsFeedbackComment` (создание комментария к отзыву) и `deleteGoodsFeedbackComment` (удаление ответа по кнопке). Текст отзыва собирается из комментария, достоинств и недостатков. Фото из отзыва отправляются в Telegram вместе с сообщением. API отдаёт отзывы не старше 6 месяцев, поэтому при первом запуске бот заберёт отзывы за этот период. + Если в ссылке на таблицу есть `gid=`, берётся этот лист, иначе первый. Справочники кэшируются на 5 минут. ## Команды бота diff --git a/app/__main__.py b/app/__main__.py index b72c411..7325ade 100644 --- a/app/__main__.py +++ b/app/__main__.py @@ -11,6 +11,7 @@ from app.handlers.reviews import send_queue_worker, sheet_queue_worker from app.models import Settings from app.services.ozon import OzonClient from app.services.wb import WildberriesClient +from app.services.ym import YandexMarketClient # Configure logging logging.basicConfig( @@ -56,6 +57,25 @@ async def wb_worker(): await asyncio.sleep(config.WB_CHECK_INTERVAL) +async def ym_worker(): + ym_client = YandexMarketClient() + + while True: + try: + logger.info("Checking for new Yandex Market reviews...") + ym_result = await ym_client.process_new_reviews() + logger.info( + f"Processed {ym_result['processed']} Yandex Market reviews, {ym_result['new']} new" + ) + except Exception as e: + logger.error( + f"Error during Yandex Market review check: {e.__class__.__name__}: {e}" + ) + + logger.info(f"Next check in {config.YM_CHECK_INTERVAL} seconds") + await asyncio.sleep(config.YM_CHECK_INTERVAL) + + async def main(): """Set up and run the bot.""" session = ( @@ -130,13 +150,20 @@ async def main(): }, ) + worker_tasks = [ + ozon_worker(), + wb_worker(), + send_queue_worker(), + sheet_queue_worker(), + ] + # Опрос Яндекс Маркета включается только при заданных ключе и businessId + if config.YM_API_KEY and config.YM_BUSINESS_ID: + worker_tasks.append(ym_worker()) + else: + logger.info("YM_API_KEY / YM_BUSINESS_ID not set, Yandex Market polling disabled") + workers = asyncio.ensure_future( - asyncio.gather( - ozon_worker(), - wb_worker(), - send_queue_worker(), - sheet_queue_worker(), - ) + asyncio.gather(*worker_tasks) ) # future must be saved to prevent GC print(f"Workers future: {workers}") diff --git a/app/config.py b/app/config.py index 0b34242..9d4bd25 100644 --- a/app/config.py +++ b/app/config.py @@ -18,6 +18,7 @@ REVIEWS_SHEET_LINK = env.str("REVIEWS_SHEET_LINK", default="") PRODUCTS_WB_SHEET_LINK = env.str("PRODUCTS_WB_SHEET_LINK", default="") PRODUCTS_OZON_SHEET_LINK = env.str("PRODUCTS_OZON_SHEET_LINK", default="") +PRODUCTS_YM_SHEET_LINK = env.str("PRODUCTS_YM_SHEET_LINK", default="") PRODUCTS_CACHE_TTL = env.int("PRODUCTS_CACHE_TTL", default=60 * 5) # Ключ сервисного аккаунта Google: либо содержимое JSON (или его base64) @@ -30,6 +31,9 @@ GOOGLE_SERVICE_ACCOUNT_FILE = env.str( OZON_API_KEY = env.str("OZON_API_KEY", default="") OZON_CLIENT_ID = env.str("OZON_CLIENT_ID", default="") WB_API_KEY = env.str("WB_API_KEY", default="") +# Яндекс Маркет: Api-Key и идентификатор кабинета (businessId) +YM_API_KEY = env.str("YM_API_KEY", default="") +YM_BUSINESS_ID = env.int("YM_BUSINESS_ID", default=0) GPTUNNEL_API_KEY = env.str("GPTUNNEL_API_KEY", default="") # Название бренда, от имени которого бот анализирует отзывы и отвечает на них @@ -37,10 +41,12 @@ BRAND_NAME = env.str("BRAND_NAME", default="Magic Stories") OZON_BASE_URL = "https://api-seller.ozon.ru" WB_BASE_URL = "https://feedbacks-api.wildberries.ru" +YM_BASE_URL = "https://api.partner.market.yandex.ru" GPT_API_URL = "https://gptunnel.ru/v1/chat/completions" OZON_CHECK_INTERVAL = env.int("OZON_CHECK_INTERVAL", default=60) WB_CHECK_INTERVAL = env.int("WB_CHECK_INTERVAL", default=300) +YM_CHECK_INTERVAL = env.int("YM_CHECK_INTERVAL", default=300) ANALYSIS_PROMPT = f""" Вы - представитель бренда {BRAND_NAME} и анализируете отзывы клиентов. diff --git a/app/handlers/callbacks.py b/app/handlers/callbacks.py index ffa0c66..c3d3271 100644 --- a/app/handlers/callbacks.py +++ b/app/handlers/callbacks.py @@ -10,7 +10,7 @@ from tortoise.exceptions import DoesNotExist from app import config from app.models import Review, Settings -from app.services import GPTClient, OzonClient, WildberriesClient +from app.services import GPTClient, OzonClient, WildberriesClient, YandexMarketClient logger = logging.getLogger(__name__) router = Router() @@ -147,6 +147,33 @@ async def callback_wb_delete(call: CallbackQuery) -> None: ) +@router.callback_query(F.data.startswith("ym_delete:")) +async def callback_ym_delete(call: CallbackQuery) -> None: + comment_id = call.data.split(":", 1)[1] + + ym_client = YandexMarketClient() + result = await ym_client.delete_comment(comment_id) + + if "error" not in result: + try: + review = await Review.filter(response_id=comment_id, platform="ym").first() + if review: + review.response_text = None + review.response_id = None + review.responded_at = None + await review.save() + except Exception as e: + logger.error(f"Error updating review after comment deletion: {e}") + + await call.answer("Комментарий успешно удалён на Яндекс Маркете.") + await call.message.reply("🗑 Ответ успешно удален с Яндекс Маркета") + else: + await call.answer("Не удалось удалить комментарий.") + await call.message.reply( + f"❌ Ошибка удаления комментария: {result.get('error')}" + ) + + @router.callback_query(F.data.startswith("generate_answer:")) async def callback_generate_answer(call: CallbackQuery) -> None: review_id = call.data.split(":", 1)[1] @@ -344,6 +371,34 @@ async def callback_send_answer(call: CallbackQuery) -> None: f"❌ Ошибка отправки ответа на Wildberries: {result.get('error')}" ) + elif review.platform == "ym": + ym_client = YandexMarketClient() + result = await ym_client.post_comment( + review.external_id, review.response_text + ) + + if "comment_id" in result: + review.response_id = result["comment_id"] + await review.save() + + markup = InlineKeyboardBuilder() + markup.add( + InlineKeyboardButton( + text="Удалить", + callback_data=f"ym_delete:{result['comment_id']}", + ) + ) + + await call.message.answer( + f"✅ Ответ на #review{review_id} успешно отправлен на Яндекс Маркет", + reply_to_message_id=review.message_id, + reply_markup=markup.as_markup(), + ) + else: + await call.message.reply( + f"❌ Ошибка отправки ответа на Яндекс Маркет: {result.get('error')}" + ) + await call.answer("Ответ отправлен") review.responded_at = datetime.now() await review.save() diff --git a/app/handlers/commands.py b/app/handlers/commands.py index ef50187..c1df958 100644 --- a/app/handlers/commands.py +++ b/app/handlers/commands.py @@ -28,12 +28,21 @@ async def cmd_stats(message: Message) -> None: if is_all_time else await stats_service.get_daily_stats("wb") ) + ym_stats = ( + await stats_service.get_all_time_stats("ym") + if is_all_time + else await stats_service.get_daily_stats("ym") + ) period_text = "за все время" if is_all_time else "за сегодня" stats_text = f"📊 Статистика {period_text}\n\n" - for platform_name, stats in [("Ozon", ozon_stats), ("Wildberries", wb_stats)]: + for platform_name, stats in [ + ("Ozon", ozon_stats), + ("Wildberries", wb_stats), + ("Яндекс Маркет", ym_stats), + ]: stats_text += ( f"{platform_name}:\n" f"Всего отзывов: {stats['total_reviews']}\n" diff --git a/app/handlers/reviews.py b/app/handlers/reviews.py index 99a9217..a89ba54 100644 --- a/app/handlers/reviews.py +++ b/app/handlers/reviews.py @@ -2,6 +2,7 @@ import asyncio import html import logging from asyncio import Queue +from urllib.parse import quote from datetime import datetime from aiogram import Router, types @@ -15,6 +16,9 @@ from app.services.sheets import add_rows_to_sheet logger = logging.getLogger(__name__) router = Router() +PLATFORM_NAMES = {"ozon": "Ozon", "wb": "Wildberries", "ym": "Яндекс Маркет"} +PLATFORM_TAGS = {"ozon": "🟦 #Ozon", "wb": "🟪 #Wildberries", "ym": "🟨 #ЯндексМаркет"} + send_queue = Queue() sheet_queue = Queue() @@ -52,7 +56,7 @@ def render_review_to_sheet(review: Review) -> list[str]: # Порядок столбцов таблицы (12 колонок): 1–8 данные, 9 тональность, 10 причина, 11 критичность, 12 Тэги return [ formatted_date, - "Ozon" if review.platform == "ozon" else "Wildberries", + PLATFORM_NAMES.get(review.platform, review.platform), review.product_name or "-", review.product_category or "-", review.product_subcategory or "-", @@ -192,6 +196,9 @@ async def send_review(review: Review) -> None: ) if review.platform == "ozon": product_url = f"https://www.ozon.ru/product/{review.product_id}/" + elif review.platform == "ym": + # У Маркета нет публичной ссылки по SKU продавца — ведём в поиск по нему + product_url = f"https://market.yandex.ru/search?text={quote(review.product_id)}" else: product_url = ( f"https://www.wildberries.ru/catalog/{review.product_id}/detail.aspx" @@ -219,7 +226,7 @@ async def send_review(review: Review) -> None: + f"{'✅ Положительный' if review.is_good else '⚠️ Отрицательный'} отзыв №{count + 1}\n" f"📅 Дата: {formatted_date}\n" f"📦 Товар: {escaped_product_id} #{category_hashtag}\n" - f"{'🟪 #Wildberries' if review.platform == 'wb' else '🟦 #Ozon'}\n" + f"{PLATFORM_TAGS.get(review.platform, '#' + review.platform)}\n" f"🔗 Ссылка\n" f"⭐ Оценка: {review.rating}/5\n" f"💬 Отзыв: {escaped_text}\n\n" diff --git a/app/models/review.py b/app/models/review.py index f72d6a1..20cbd61 100644 --- a/app/models/review.py +++ b/app/models/review.py @@ -4,7 +4,7 @@ from tortoise import Model, fields class Review(Model): id = fields.IntField(pk=True) external_id = fields.TextField() - platform = fields.TextField() # "ozon" or "wb" + platform = fields.TextField() # "ozon", "wb" or "ym" (Яндекс Маркет) rating = fields.IntField() is_good = fields.BooleanField() text = fields.TextField() diff --git a/app/services/__init__.py b/app/services/__init__.py index d5c3443..d067abe 100644 --- a/app/services/__init__.py +++ b/app/services/__init__.py @@ -2,5 +2,12 @@ from app.services.gpt import GPTClient from app.services.ozon import OzonClient from app.services.stats import StatsService from app.services.wb import WildberriesClient +from app.services.ym import YandexMarketClient -__all__ = ["OzonClient", "WildberriesClient", "GPTClient", "StatsService"] +__all__ = [ + "OzonClient", + "WildberriesClient", + "YandexMarketClient", + "GPTClient", + "StatsService", +] diff --git a/app/services/ym.py b/app/services/ym.py new file mode 100644 index 0000000..1982036 --- /dev/null +++ b/app/services/ym.py @@ -0,0 +1,330 @@ +import asyncio +import logging +import time +from datetime import datetime, timedelta +from functools import lru_cache +from typing import Any + +import httpx + +from app import config +from app.handlers.reviews import add_to_message_queue +from app.models import Review, Settings +from app.services.gpt import GPTClient +from app.services.sheets import read_sheet + +logger = logging.getLogger(__name__) + +# API отдаёт отзывы максимум за 6 месяцев; берём чуть меньше, чтобы не упереться в границу +_MAX_LOOKBACK = timedelta(days=180 - 1) +# Защита от бесконечной пагинации при первом запуске +_MAX_PAGES = 40 + + +def get_cache_key() -> int: + return int(time.time()) // config.PRODUCTS_CACHE_TTL + + +@lru_cache(maxsize=1) +def get_products_dict(_: int) -> dict[str, dict[str, Any]]: + logger.info("Requesting Yandex Market products list") + if not config.PRODUCTS_YM_SHEET_LINK: + return {} + sheet = read_sheet(config.PRODUCTS_YM_SHEET_LINK) + return { + str( + product.get("SKU") + or product.get("Ваш SKU") + or product.get("offerId") + or product.get("Артикул") + ): product + for product in sheet + } + + +def _parse_datetime(value: str | None) -> datetime: + """createdAt приходит в ISO 8601 (с Z или смещением).""" + if not value: + return datetime.now(config.TIMEZONE) + try: + dt = datetime.fromisoformat(value.replace("Z", "+00:00")) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=config.TIMEZONE) + return dt.astimezone(config.TIMEZONE) + except ValueError: + return datetime.now(config.TIMEZONE) + + +class YandexMarketClient: + def __init__(self): + self.base_url = config.YM_BASE_URL + self.business_id = config.YM_BUSINESS_ID + self.headers = { + "Api-Key": config.YM_API_KEY, + "Content-Type": "application/json", + } + + @staticmethod + def _error_from_response(response: httpx.Response) -> str | None: + """Возвращает текст ошибки, если API ответил status=ERROR.""" + try: + data = response.json() + except ValueError: + return None + if data.get("status") == "ERROR": + errors = data.get("errors") or [] + return "; ".join( + f"{e.get('code')}: {e.get('message')}" for e in errors + ) or "unknown error" + return None + + async def get_reviews( + self, date_from: datetime | None = None, limit: int = 50 + ) -> list[dict[str, Any]]: + """Все отзывы, начиная с date_from (не включительно), с обходом страниц.""" + url = f"{self.base_url}/v2/businesses/{self.business_id}/goods-feedback" + body: dict[str, Any] = {"reactionStatus": "ALL"} + if date_from is not None: + body["dateTimeFrom"] = date_from.astimezone(config.TIMEZONE).isoformat( + timespec="seconds" + ) + + all_feedbacks: list[dict[str, Any]] = [] + page_token: str | None = None + + try: + async with httpx.AsyncClient(timeout=30) as client: + for _ in range(_MAX_PAGES): + params: dict[str, Any] = {"limit": limit} + if page_token: + params["page_token"] = page_token + response = await client.post( + url, headers=self.headers, params=params, json=body + ) + response.raise_for_status() + data = response.json() + if data.get("status") == "ERROR": + logger.error( + f"Yandex Market returned error: {data.get('errors')}" + ) + break + result = data.get("result", {}) or {} + all_feedbacks.extend(result.get("feedbacks", []) or []) + page_token = (result.get("paging") or {}).get("nextPageToken") + if not page_token: + break + except httpx.HTTPError as e: + logger.error(f"Error fetching Yandex Market reviews: {e}") + + return all_feedbacks + + async def post_comment(self, review_id: str, text: str) -> dict[str, Any]: + """Создаёт комментарий продавца к отзыву. Возвращает {"comment_id": ...} или {"error": ...}.""" + url = ( + f"{self.base_url}/v2/businesses/{self.business_id}" + f"/goods-feedback/comments/update" + ) + text = (text or "").strip() + if not text: + return {"error": "empty comment text"} + data = {"feedbackId": int(review_id), "comment": {"text": text[:4096]}} + + try: + async with httpx.AsyncClient(timeout=30) as client: + response = await client.post(url, headers=self.headers, json=data) + error = self._error_from_response(response) + if error: + logger.error(f"Error posting comment to Yandex Market: {error}") + return {"error": error} + response.raise_for_status() + result = response.json().get("result", {}) or {} + comment_id = result.get("id") + if comment_id is None: + return {"error": "no comment id in response"} + return {"comment_id": str(comment_id), "status": result.get("status")} + except httpx.HTTPError as e: + logger.error(f"Error posting comment to Yandex Market: {e}") + return {"error": str(e)} + + async def delete_comment(self, comment_id: str) -> dict[str, Any]: + url = ( + f"{self.base_url}/v2/businesses/{self.business_id}" + f"/goods-feedback/comments/delete" + ) + data = {"id": int(comment_id)} + + try: + async with httpx.AsyncClient(timeout=30) as client: + response = await client.post(url, headers=self.headers, json=data) + error = self._error_from_response(response) + if error: + logger.error(f"Error deleting comment from Yandex Market: {error}") + return {"error": error} + response.raise_for_status() + return {"success": True} + except httpx.HTTPError as e: + logger.error(f"Error deleting comment from Yandex Market: {e}") + return {"error": str(e)} + + @staticmethod + def format_datetime(iso_datetime: str) -> str: + return _parse_datetime(iso_datetime).strftime("%d.%m.%Y %H:%M:%S") + " МСК" + + @staticmethod + def _review_text(feedback: dict[str, Any]) -> str: + description = feedback.get("description") or {} + parts = [] + if description.get("comment"): + parts.append(str(description["comment"]).strip()) + if description.get("advantages"): + parts.append("Достоинства: " + str(description["advantages"]).strip()) + if description.get("disadvantages"): + parts.append("Недостатки: " + str(description["disadvantages"]).strip()) + return "\n".join(parts).strip() + + async def process_new_reviews(self) -> dict[str, int]: + gpt_client = GPTClient() + + now = datetime.now(config.TIMEZONE) + date_from: datetime | None = None + try: + latest_review = ( + await Review.filter(platform="ym").order_by("-published_at").first() + ) + if latest_review: + date_from = latest_review.published_at.astimezone(config.TIMEZONE) + except Exception as e: + logger.error(f"Error getting latest review: {e}") + + min_from = now - _MAX_LOOKBACK + if date_from is None or date_from < min_from: + date_from = min_from + + reviews = await self.get_reviews(date_from) + if not reviews: + logger.info("No reviews fetched from Yandex Market") + return {"processed": 0, "new": 0} + + # API отдаёт от новых к старым; обрабатываем от старых к новым + reviews.sort(key=lambda r: r.get("createdAt", "")) + + processed = 0 + for feedback in reviews: + review_id = str(feedback.get("feedbackId", "") or feedback.get("id", "")) + if not review_id: + logger.error(f"YM: no feedback id in review {feedback}") + continue + + existing = await Review.filter( + external_id=review_id, platform="ym" + ).exists() + if existing: + continue + + logger.info(f"YM: processing review {feedback}") + + text = self._review_text(feedback) + published_at = _parse_datetime(feedback.get("createdAt")) + + identifiers = feedback.get("identifiers") or {} + product_id = str( + identifiers.get("offerId") or identifiers.get("modelId") or "" + ) + if not product_id: + logger.error(f"YM: no product id in review {feedback}") + continue + + product = get_products_dict(get_cache_key()).get(product_id) + if product: + product.setdefault( + "Название", + product.get("Наименование") or product.get("Название товара"), + ) + product.setdefault("Категория", product.get("Категория продавца")) + product.setdefault("Подкатегория", "неизвестно") + else: + logger.warning(f"YM: no product found for id {product_id}") + product = { + "Название": "неизвестно", + "Категория": "неизвестно", + "Подкатегория": "неизвестно", + } + + media = feedback.get("media") or {} + images = [url for url in (media.get("photos") or []) if url] + + statistics = feedback.get("statistics") or {} + rating = int(statistics.get("rating") or 0) + + review_data = { + "external_id": review_id, + "platform": "ym", + "product_id": product_id, + "product_name": product.get("Название") or "неизвестно", + "product_category": product.get("Категория") or "неизвестно", + "product_subcategory": product.get("Подкатегория") or "неизвестно", + "rating": rating, + "is_good": rating > config.NEGATIVE_RATING, + "text": text, + "published_at": published_at, + "processed": False, + "images": images if images else None, + } + + review_obj = await Review.create(**review_data) + + settings = await Settings.get_from_context(review=review_obj) + + if not review_obj.text and review_obj.is_good and not settings.auto_response_empty_enabled: + add_to_message_queue(review_obj) + await asyncio.sleep(0.1) + processed += 1 + continue + + if settings.analysis_enabled and review_obj.text: + try: + analysis = await gpt_client.analyze_review(settings, review_obj) + + await review_obj.update_from_dict( + gpt_client.extract_analysis_data(analysis) + ) + await review_obj.save() + except Exception as e: + logger.error(f"Error analyzing review {review_id}: {e}") + + if settings.auto_response_enabled and review_obj.text: + try: + response = await gpt_client.generate_answer(settings, review_obj) + if response: + result = await self.post_comment(review_id, response) + if "error" not in result: + review_obj.response_text = response + review_obj.response_id = result.get("comment_id") + review_obj.responded_at = datetime.now(config.TIMEZONE) + await review_obj.save() + except Exception as e: + logger.error(f"Error auto-responding to review {review_id}: {e}") + + if settings.auto_response_empty_enabled and not review_obj.text: + try: + template = ( + (settings.template_empty_high_rating or config.TEMPLATE_EMPTY_HIGH_RATING) + if review_obj.rating > config.NEGATIVE_RATING + else (settings.template_empty_low_rating or config.TEMPLATE_EMPTY_LOW_RATING) + ) + result = await self.post_comment(review_id, template) + if "error" not in result: + review_obj.response_text = template + review_obj.response_id = result.get("comment_id") + review_obj.responded_at = datetime.now(config.TIMEZONE) + await review_obj.save() + except Exception as e: + logger.error( + f"Error auto-responding (template) to review {review_id}: {e}" + ) + + add_to_message_queue(review_obj) + processed += 1 + await asyncio.sleep(0.1) + + return {"processed": processed, "new": len(reviews)} -- 2.54.0