Добавить поддержку Яндекс Маркета (goods-feedback API)

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 20:13:38 +03:00
co-authored by Claude Fable 5.1
parent 1098ff4991
commit 4534ab2683
10 changed files with 481 additions and 24 deletions
+11 -1
View File
@@ -18,7 +18,7 @@ NEGATIVE_RATING=3
# --- Прокси (необязательно) --- # --- Прокси (необязательно) ---
# Прокси для запросов к Telegram, например socks5://user:pass@host:1080 # Прокси для запросов к Telegram, например socks5://user:pass@host:1080
TG_PROXY= TG_PROXY=
# Системные прокси для остальных запросов (Ozon, WB, GPT, Google) # Системные прокси для остальных запросов (Ozon, WB, Яндекс Маркет, GPT, Google)
ALL_PROXY= ALL_PROXY=
HTTPS_PROXY= HTTPS_PROXY=
HTTP_PROXY= HTTP_PROXY=
@@ -39,6 +39,7 @@ REVIEWS_SHEET_LINK=
# Справочники товаров (название, категория) по артикулам # Справочники товаров (название, категория) по артикулам
PRODUCTS_WB_SHEET_LINK= PRODUCTS_WB_SHEET_LINK=
PRODUCTS_OZON_SHEET_LINK= PRODUCTS_OZON_SHEET_LINK=
PRODUCTS_YM_SHEET_LINK=
# --- Ozon Seller API --- # --- Ozon Seller API ---
OZON_API_KEY= OZON_API_KEY=
@@ -51,6 +52,15 @@ WB_API_KEY=
# Интервал опроса в секундах (по умолчанию 300) # Интервал опроса в секундах (по умолчанию 300)
WB_CHECK_INTERVAL=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) --- # --- GPT (через gptunnel.ru, OpenAI-совместимый API) ---
GPTUNNEL_API_KEY= GPTUNNEL_API_KEY=
+17 -11
View File
@@ -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) уходят в топик хороших, остальные в топик плохих. Отзывы без текста (только оценка) можно направлять в отдельные топики. - **Раскладка по топикам.** Отзывы с оценкой выше порога (`NEGATIVE_RATING`, по умолчанию 3) уходят в топик хороших, остальные в топик плохих. Отзывы без текста (только оценка) можно направлять в отдельные топики.
- **Анализ через GPT.** Определяет тональность, причину и критичность отзыва по промпту. Критичные отзывы помечаются `#требует_обработки`. - **Анализ через GPT.** Определяет тональность, причину и критичность отзыва по промпту. Критичные отзывы помечаются `#требует_обработки`.
- **Ответы.** Три варианта: автоответ через GPT, автоответ по шаблону для отзывов без текста, либо ручной режим с кнопками «Сгенерировать ответ» / «Написать свой ответ» / «Отправить» / «Удалить ответ». - **Ответы.** Три варианта: автоответ через GPT, автоответ по шаблону для отзывов без текста, либо ручной режим с кнопками «Сгенерировать ответ» / «Написать свой ответ» / «Отправить» / «Удалить ответ».
- **Google Sheets.** Каждый отзыв дописывается строкой в таблицу отзывов. Названия и категории товаров подтягиваются из справочников товаров WB и Ozon. - **Google Sheets.** Каждый отзыв дописывается строкой в таблицу отзывов. Названия и категории товаров подтягиваются из справочников товаров WB, Ozon и Яндекс Маркета.
- **Статистика.** Команды `/stat` (за сегодня) и `/allstat` (за всё время): количество, доля плохих, средний рейтинг, критичные, тональность. - **Статистика.** Команды `/stat` (за сегодня) и `/allstat` (за всё время): количество, доля плохих, средний рейтинг, критичные, тональность.
Настройки (промпты, автоответы, модель, шаблоны) хранятся в базе отдельно для хороших и плохих отзывов и меняются прямо из бота. Они сохраняются между перезапусками и редеплоями, поэтому база должна лежать на постоянном томе. Настройки (промпты, автоответы, модель, шаблоны) хранятся в базе отдельно для хороших и плохих отзывов и меняются прямо из бота. Они сохраняются между перезапусками и редеплоями, поэтому база должна лежать на постоянном томе.
@@ -17,12 +17,12 @@ Telegram-бот, который собирает новые отзывы с Ozon
``` ```
Ozon API ─┐ ┌─> Telegram (топики хороших / плохих / без текста) Ozon API ─┐ ┌─> Telegram (топики хороших / плохих / без текста)
─> воркер ─> SQLite ─> очереди ─┤ WB API ──┼─> воркер ─> SQLite ─> очереди ─┤
WB API ──┘ │ └─> Google Sheets (таблица отзывов) YM API ──┘ │ └─> Google Sheets (таблица отзывов)
└─> GPT: анализ + генерация ответа └─> 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/callbacks.py` | Кнопки: генерация, отправка, удаление ответа, переключатели настроек |
| `app/handlers/commands.py` | Команды `/stat`, `/allstat`, `/settings`, шаблоны, `/max_tokens` | | `app/handlers/commands.py` | Команды `/stat`, `/allstat`, `/settings`, шаблоны, `/max_tokens` |
| `app/handlers/prompt.py` | Просмотр, изменение и сброс промптов | | `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/gpt.py` | Запросы к GPT, разбор JSON анализа |
| `app/services/sheets.py` | Работа с Google Sheets через gspread | | `app/services/sheets.py` | Работа с Google Sheets через gspread |
| `app/services/stats.py` | Подсчёт статистики | | `app/services/stats.py` | Подсчёт статистики |
@@ -45,7 +45,7 @@ WB API ──┘ │ └─> Google Sheets (таб
- [uv](https://docs.astral.sh/uv/) (рекомендуется) или pip - [uv](https://docs.astral.sh/uv/) (рекомендуется) или pip
- Telegram-бот, добавленный в супергруппу с включёнными топиками - Telegram-бот, добавленный в супергруппу с включёнными топиками
- Сервисный аккаунт Google с доступом к Google Sheets API - Сервисный аккаунт 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 | | `BOT_TOKEN` | Токен бота от @BotFather |
| `TG_CHAT_ID`, `GOOD_REVIEWS_THREAD_ID`, `BAD_REVIEWS_THREAD_ID` | Группа и топики | | `TG_CHAT_ID`, `GOOD_REVIEWS_THREAD_ID`, `BAD_REVIEWS_THREAD_ID` | Группа и топики |
| `GOOGLE_SERVICE_ACCOUNT_JSON` | Содержимое `key.json` одной строкой или в base64 | | `GOOGLE_SERVICE_ACCOUNT_JSON` | Содержимое `key.json` одной строкой или в base64 |
| `REVIEWS_SHEET_LINK`, `PRODUCTS_WB_SHEET_LINK`, `PRODUCTS_OZON_SHEET_LINK` | Ссылки на таблицы | | `REVIEWS_SHEET_LINK`, `PRODUCTS_WB_SHEET_LINK`, `PRODUCTS_OZON_SHEET_LINK`, `PRODUCTS_YM_SHEET_LINK` | Ссылки на таблицы |
| `OZON_API_KEY`, `OZON_CLIENT_ID`, `WB_API_KEY`, `GPTUNNEL_API_KEY` | Ключи API | | `OZON_API_KEY`, `OZON_CLIENT_ID`, `WB_API_KEY`, `YM_API_KEY`, `YM_BUSINESS_ID`, `GPTUNNEL_API_KEY` | Ключи API |
| `BRAND_NAME` | Название бренда для промптов | | `BRAND_NAME` | Название бренда для промптов |
Ключ Google удобнее передавать в base64, тогда в значении нет кавычек и переносов строк: Ключ Google удобнее передавать в base64, тогда в значении нет кавычек и переносов строк:
@@ -136,6 +136,12 @@ Compose читает `.env` из корня, если файл есть, и хр
**Справочник товаров Ozon** (`PRODUCTS_OZON_SHEET_LINK`). Ключевой столбец `Ozon ID` или `ozon id акутальный`. Используются `Товары`, `Тип товара`, `Категория 2-го уровня`. **Справочник товаров 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 минут. Если в ссылке на таблицу есть `gid=`, берётся этот лист, иначе первый. Справочники кэшируются на 5 минут.
## Команды бота ## Команды бота
+33 -6
View File
@@ -11,6 +11,7 @@ from app.handlers.reviews import send_queue_worker, sheet_queue_worker
from app.models import Settings from app.models import Settings
from app.services.ozon import OzonClient from app.services.ozon import OzonClient
from app.services.wb import WildberriesClient from app.services.wb import WildberriesClient
from app.services.ym import YandexMarketClient
# Configure logging # Configure logging
logging.basicConfig( logging.basicConfig(
@@ -56,6 +57,25 @@ async def wb_worker():
await asyncio.sleep(config.WB_CHECK_INTERVAL) 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(): async def main():
"""Set up and run the bot.""" """Set up and run the bot."""
session = ( 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( workers = asyncio.ensure_future(
asyncio.gather( asyncio.gather(*worker_tasks)
ozon_worker(),
wb_worker(),
send_queue_worker(),
sheet_queue_worker(),
)
) # future must be saved to prevent GC ) # future must be saved to prevent GC
print(f"Workers future: {workers}") print(f"Workers future: {workers}")
+6
View File
@@ -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_WB_SHEET_LINK = env.str("PRODUCTS_WB_SHEET_LINK", default="")
PRODUCTS_OZON_SHEET_LINK = env.str("PRODUCTS_OZON_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) PRODUCTS_CACHE_TTL = env.int("PRODUCTS_CACHE_TTL", default=60 * 5)
# Ключ сервисного аккаунта Google: либо содержимое JSON (или его base64) # Ключ сервисного аккаунта Google: либо содержимое JSON (или его base64)
@@ -30,6 +31,9 @@ GOOGLE_SERVICE_ACCOUNT_FILE = env.str(
OZON_API_KEY = env.str("OZON_API_KEY", default="") OZON_API_KEY = env.str("OZON_API_KEY", default="")
OZON_CLIENT_ID = env.str("OZON_CLIENT_ID", default="") OZON_CLIENT_ID = env.str("OZON_CLIENT_ID", default="")
WB_API_KEY = env.str("WB_API_KEY", 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="") 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" OZON_BASE_URL = "https://api-seller.ozon.ru"
WB_BASE_URL = "https://feedbacks-api.wildberries.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" GPT_API_URL = "https://gptunnel.ru/v1/chat/completions"
OZON_CHECK_INTERVAL = env.int("OZON_CHECK_INTERVAL", default=60) OZON_CHECK_INTERVAL = env.int("OZON_CHECK_INTERVAL", default=60)
WB_CHECK_INTERVAL = env.int("WB_CHECK_INTERVAL", default=300) WB_CHECK_INTERVAL = env.int("WB_CHECK_INTERVAL", default=300)
YM_CHECK_INTERVAL = env.int("YM_CHECK_INTERVAL", default=300)
ANALYSIS_PROMPT = f""" ANALYSIS_PROMPT = f"""
Вы - представитель бренда {BRAND_NAME} и анализируете отзывы клиентов. Вы - представитель бренда {BRAND_NAME} и анализируете отзывы клиентов.
+56 -1
View File
@@ -10,7 +10,7 @@ from tortoise.exceptions import DoesNotExist
from app import config from app import config
from app.models import Review, Settings 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__) logger = logging.getLogger(__name__)
router = Router() 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:")) @router.callback_query(F.data.startswith("generate_answer:"))
async def callback_generate_answer(call: CallbackQuery) -> None: async def callback_generate_answer(call: CallbackQuery) -> None:
review_id = call.data.split(":", 1)[1] review_id = call.data.split(":", 1)[1]
@@ -344,6 +371,34 @@ async def callback_send_answer(call: CallbackQuery) -> None:
f"❌ Ошибка отправки ответа на Wildberries: {result.get('error')}" 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("Ответ отправлен") await call.answer("Ответ отправлен")
review.responded_at = datetime.now() review.responded_at = datetime.now()
await review.save() await review.save()
+10 -1
View File
@@ -28,12 +28,21 @@ async def cmd_stats(message: Message) -> None:
if is_all_time if is_all_time
else await stats_service.get_daily_stats("wb") 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 "за сегодня" period_text = "за все время" if is_all_time else "за сегодня"
stats_text = f"📊 <b>Статистика {period_text}</b>\n\n" stats_text = f"📊 <b>Статистика {period_text}</b>\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 += ( stats_text += (
f"<b>{platform_name}:</b>\n" f"<b>{platform_name}:</b>\n"
f"Всего отзывов: {stats['total_reviews']}\n" f"Всего отзывов: {stats['total_reviews']}\n"
+9 -2
View File
@@ -2,6 +2,7 @@ import asyncio
import html import html
import logging import logging
from asyncio import Queue from asyncio import Queue
from urllib.parse import quote
from datetime import datetime from datetime import datetime
from aiogram import Router, types from aiogram import Router, types
@@ -15,6 +16,9 @@ from app.services.sheets import add_rows_to_sheet
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = Router() router = Router()
PLATFORM_NAMES = {"ozon": "Ozon", "wb": "Wildberries", "ym": "Яндекс Маркет"}
PLATFORM_TAGS = {"ozon": "🟦 #Ozon", "wb": "🟪 #Wildberries", "ym": "🟨 #ЯндексМаркет"}
send_queue = Queue() send_queue = Queue()
sheet_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 Тэги # Порядок столбцов таблицы (12 колонок): 1–8 данные, 9 тональность, 10 причина, 11 критичность, 12 Тэги
return [ return [
formatted_date, formatted_date,
"Ozon" if review.platform == "ozon" else "Wildberries", PLATFORM_NAMES.get(review.platform, review.platform),
review.product_name or "-", review.product_name or "-",
review.product_category or "-", review.product_category or "-",
review.product_subcategory or "-", review.product_subcategory or "-",
@@ -192,6 +196,9 @@ async def send_review(review: Review) -> None:
) )
if review.platform == "ozon": if review.platform == "ozon":
product_url = f"https://www.ozon.ru/product/{review.product_id}/" 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: else:
product_url = ( product_url = (
f"https://www.wildberries.ru/catalog/{review.product_id}/detail.aspx" 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"{'✅ Положительный' if review.is_good else '⚠️ Отрицательный'} отзыв №{count + 1}\n"
f"📅 Дата: {formatted_date}\n" f"📅 Дата: {formatted_date}\n"
f"📦 Товар: <code>{escaped_product_id}</code> #{category_hashtag}\n" f"📦 Товар: <code>{escaped_product_id}</code> #{category_hashtag}\n"
f"{'🟪 #Wildberries' if review.platform == 'wb' else '🟦 #Ozon'}\n" f"{PLATFORM_TAGS.get(review.platform, '#' + review.platform)}\n"
f"🔗 <a href='{product_url}'>Ссылка</a>\n" f"🔗 <a href='{product_url}'>Ссылка</a>\n"
f"⭐ Оценка: <code>{review.rating}/5</code>\n" f"⭐ Оценка: <code>{review.rating}/5</code>\n"
f"💬 Отзыв: {escaped_text}\n\n" f"💬 Отзыв: {escaped_text}\n\n"
+1 -1
View File
@@ -4,7 +4,7 @@ from tortoise import Model, fields
class Review(Model): class Review(Model):
id = fields.IntField(pk=True) id = fields.IntField(pk=True)
external_id = fields.TextField() external_id = fields.TextField()
platform = fields.TextField() # "ozon" or "wb" platform = fields.TextField() # "ozon", "wb" or "ym" (Яндекс Маркет)
rating = fields.IntField() rating = fields.IntField()
is_good = fields.BooleanField() is_good = fields.BooleanField()
text = fields.TextField() text = fields.TextField()
+8 -1
View File
@@ -2,5 +2,12 @@ from app.services.gpt import GPTClient
from app.services.ozon import OzonClient from app.services.ozon import OzonClient
from app.services.stats import StatsService from app.services.stats import StatsService
from app.services.wb import WildberriesClient from app.services.wb import WildberriesClient
from app.services.ym import YandexMarketClient
__all__ = ["OzonClient", "WildberriesClient", "GPTClient", "StatsService"] __all__ = [
"OzonClient",
"WildberriesClient",
"YandexMarketClient",
"GPTClient",
"StatsService",
]
+330
View File
@@ -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)}