текущая версия (рабочая)
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
from app.services.gpt import GPTClient
|
||||
from app.services.ozon import OzonClient
|
||||
from app.services.stats import StatsService
|
||||
from app.services.wb import WildberriesClient
|
||||
|
||||
__all__ = ["OzonClient", "WildberriesClient", "GPTClient", "StatsService"]
|
||||
@@ -0,0 +1,102 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app import config
|
||||
from app.models import Settings, Review
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GPTClient:
|
||||
def __init__(self):
|
||||
self.api_url = config.GPT_API_URL
|
||||
self.headers = {
|
||||
"Authorization": f"Bearer {config.GPTUNNEL_API_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
async def _make_request(
|
||||
self, messages: list, model: str = "gpt-4o", max_tokens: int = 100, retry: bool = False
|
||||
) -> str:
|
||||
data = {
|
||||
"model": model,
|
||||
"max_tokens": max_tokens,
|
||||
"messages": messages,
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
self.api_url, headers=self.headers, json=data
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
logger.info("GPT response: %s", result)
|
||||
answer = (
|
||||
result.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||
)
|
||||
return answer
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"Error making GPT request: {e.__class__.__name__}: {e}")
|
||||
if retry:
|
||||
return ""
|
||||
logger.info("Waiting 15 seconds and retrying")
|
||||
await asyncio.sleep(15)
|
||||
return await self._make_request(messages, model, max_tokens, retry=True)
|
||||
|
||||
async def analyze_review(self, settings: Settings, review: Review) -> str:
|
||||
review_text = (
|
||||
f"Комментарий: {review.text}\n"
|
||||
f"Название товара: {review.product_name or '-'}\n"
|
||||
f"Оценка пользователя: {review.rating}"
|
||||
)
|
||||
messages = [
|
||||
{"role": "system", "content": settings.analysis_prompt},
|
||||
{
|
||||
"role": "user",
|
||||
"content": review_text,
|
||||
},
|
||||
]
|
||||
|
||||
return await self._make_request(messages, model=settings.gpt_model, max_tokens=settings.max_tokens)
|
||||
|
||||
async def generate_answer(self, settings: Settings, review: Review) -> str:
|
||||
review_text = (
|
||||
f"Комментарий: {review.text}\n"
|
||||
f"Название товара: {review.product_name or '-'}\n"
|
||||
f"Оценка пользователя: {review.rating}"
|
||||
)
|
||||
messages = [
|
||||
{"role": "system", "content": settings.answer_prompt},
|
||||
{
|
||||
"role": "user",
|
||||
"content": f"{review_text}\nОтвет представителя бренда:",
|
||||
},
|
||||
]
|
||||
|
||||
return await self._make_request(
|
||||
messages, model=settings.gpt_model, max_tokens=settings.max_tokens
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def extract_analysis_data(analysis_text: str) -> dict[str, Any]:
|
||||
try:
|
||||
analysis_text = analysis_text.replace("```json", "").replace("```", "")
|
||||
data = json.loads(analysis_text)
|
||||
|
||||
tone = (data.get("tone") or "").lower()
|
||||
tone_reason = data.get("reason") or ""
|
||||
is_critical = data.get("is_critical") or None
|
||||
except (json.JSONDecodeError, ValueError, AttributeError) as e:
|
||||
logger.error(f"Error parsing analysis JSON: {e}. Raw text: {analysis_text}")
|
||||
raise e
|
||||
|
||||
return {
|
||||
"tone": tone,
|
||||
"tone_reason": tone_reason,
|
||||
"is_critical": is_critical,
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime
|
||||
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__)
|
||||
|
||||
|
||||
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 Ozon products list")
|
||||
sheet = read_sheet(config.PRODUCTS_OZON_SHEET_LINK)
|
||||
return {
|
||||
str(product.get("ozon id акутальный") or product.get("Ozon ID")): product
|
||||
for product in sheet
|
||||
}
|
||||
|
||||
|
||||
class OzonClient:
|
||||
def __init__(self):
|
||||
self.base_url = config.OZON_BASE_URL
|
||||
self.headers = {
|
||||
"Client-Id": config.OZON_CLIENT_ID,
|
||||
"Api-Key": config.OZON_API_KEY,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
async def get_reviews(self, limit: int = 50) -> list[dict[str, Any]]:
|
||||
url = f"{self.base_url}/v1/review/list"
|
||||
data = {"limit": limit, "sort_dir": "DESC", "status": "ALL"}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(url, headers=self.headers, json=data)
|
||||
response.raise_for_status()
|
||||
return response.json().get("reviews", [])
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"Error fetching Ozon reviews: {e}")
|
||||
return []
|
||||
|
||||
async def post_comment(
|
||||
self, review_id: str, text: str, mark_processed: bool = True
|
||||
) -> dict[str, Any]:
|
||||
url = f"{self.base_url}/v1/review/comment/create"
|
||||
data = {
|
||||
"review_id": review_id,
|
||||
"text": text,
|
||||
"mark_review_as_processed": mark_processed,
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(url, headers=self.headers, json=data)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"Error posting comment to Ozon: {e}")
|
||||
return {"error": str(e)}
|
||||
|
||||
async def delete_comment(self, comment_id: str) -> dict[str, Any]:
|
||||
url = f"{self.base_url}/v1/review/comment/delete"
|
||||
data = {"comment_id": comment_id}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(url, headers=self.headers, json=data)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"Error deleting comment from Ozon: {e}")
|
||||
return {"error": str(e)}
|
||||
|
||||
@staticmethod
|
||||
def format_datetime(iso_datetime: str) -> str:
|
||||
dt = datetime.fromisoformat(iso_datetime.replace("Z", "+00:00"))
|
||||
dt = dt.astimezone(config.TIMEZONE)
|
||||
return dt.strftime("%d.%m.%Y %H:%M:%S") + " МСК"
|
||||
|
||||
async def process_new_reviews(self) -> dict[str, int]:
|
||||
gpt_client = GPTClient()
|
||||
|
||||
try:
|
||||
latest_review = (
|
||||
await Review.filter(platform="ozon").order_by("-published_at").first()
|
||||
)
|
||||
latest_time = (
|
||||
latest_review.published_at.isoformat()
|
||||
if latest_review
|
||||
else "2000-01-01T00:00:00Z"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting latest review: {e}")
|
||||
latest_time = "2000-01-01T00:00:00Z"
|
||||
|
||||
reviews = await self.get_reviews()
|
||||
if not reviews:
|
||||
logger.info("No reviews fetched from Ozon")
|
||||
return {"processed": 0, "new": 0}
|
||||
|
||||
new_reviews = [r for r in reviews if r.get("published_at", "") > latest_time]
|
||||
if not new_reviews:
|
||||
logger.info("No new reviews found on Ozon")
|
||||
return {"processed": 0, "new": 0}
|
||||
|
||||
new_reviews.sort(key=lambda r: r.get("published_at", ""))
|
||||
|
||||
processed = 0
|
||||
for review in new_reviews:
|
||||
review_id = review.get("id")
|
||||
|
||||
existing = await Review.filter(
|
||||
external_id=review_id, platform="ozon"
|
||||
).exists()
|
||||
if existing:
|
||||
continue
|
||||
|
||||
logger.info(f"Ozon: processing review {review}")
|
||||
|
||||
published_at = datetime.fromisoformat(
|
||||
review.get("published_at").replace("Z", "+00:00")
|
||||
).astimezone(config.TIMEZONE)
|
||||
|
||||
product_id = str(review.get("sku", ""))
|
||||
if not product_id:
|
||||
logger.error(f"Ozon: no product id for review {review}")
|
||||
continue
|
||||
|
||||
product = get_products_dict(get_cache_key()).get(product_id)
|
||||
if product:
|
||||
product.setdefault("Название", product.get("Товары"))
|
||||
product.setdefault("Категория", product.get("Тип товара"))
|
||||
product.setdefault("Подкатегория", product.get("Категория 2-го уровня"))
|
||||
else:
|
||||
logger.warning(f"Ozon: no product found for id {product_id}")
|
||||
product = {
|
||||
"Название": "неизвестно",
|
||||
"Категория": "неизвестно",
|
||||
"Подкатегория": "неизвестно",
|
||||
}
|
||||
|
||||
review_data = {
|
||||
"external_id": review_id,
|
||||
"platform": "ozon",
|
||||
"product_id": str(review.get("sku", "")),
|
||||
"rating": review.get("rating", 0),
|
||||
"is_good": review.get("rating", 0) > config.NEGATIVE_RATING,
|
||||
"text": review.get("text", ""),
|
||||
"product_name": product.get("Название", ""),
|
||||
"product_category": product.get("Категория", ""),
|
||||
"product_subcategory": product.get("Подкатегория", ""),
|
||||
"published_at": published_at,
|
||||
"processed": False,
|
||||
}
|
||||
|
||||
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(new_reviews)}
|
||||
@@ -0,0 +1,79 @@
|
||||
import logging
|
||||
import re
|
||||
from functools import lru_cache
|
||||
from typing import Any
|
||||
|
||||
import gspread
|
||||
from gspread.utils import InsertDataOption, ValueInputOption
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
sheets_api = gspread.service_account(filename="google-auth/key.json")
|
||||
# Таймаут HTTP-запросов (сек): защита от зависания при проблемах с сетью/Google API
|
||||
sheets_api.set_timeout(30)
|
||||
|
||||
# HTTP коды, при которых стоит сбросить кэш листа (устаревший объект)
|
||||
_CACHE_INVALIDATE_CODES = frozenset({401, 403, 404, 429})
|
||||
|
||||
|
||||
def parse_google_sheets_url(url: str) -> tuple[str, int | None]:
|
||||
spreadsheet_match = re.search(r"/d/([a-zA-Z0-9-_]+)", url)
|
||||
if not spreadsheet_match:
|
||||
raise ValueError("Invalid Google Sheets URL: Could not find spreadsheet ID")
|
||||
spreadsheet_id = spreadsheet_match.group(1)
|
||||
|
||||
worksheet_match = re.search(r"[?&]gid=(\d+)", url)
|
||||
worksheet_id = int(worksheet_match.group(1)) if worksheet_match else None
|
||||
|
||||
return spreadsheet_id, worksheet_id
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_sheet(sheet_link: str) -> gspread.Worksheet:
|
||||
spreadsheet_id, worksheet_id = parse_google_sheets_url(sheet_link)
|
||||
|
||||
spreadsheet = sheets_api.open_by_key(spreadsheet_id)
|
||||
if worksheet_id is not None:
|
||||
return spreadsheet.get_worksheet_by_id(worksheet_id)
|
||||
return spreadsheet.sheet1
|
||||
|
||||
|
||||
def add_rows_to_sheet(sheet_link: str, rows: list[list]) -> None:
|
||||
"""Append rows to Google Sheet. Raises on failure so caller can retry."""
|
||||
sheet = get_sheet(sheet_link)
|
||||
try:
|
||||
sheet.append_rows(
|
||||
rows,
|
||||
value_input_option=ValueInputOption.user_entered,
|
||||
insert_data_option=InsertDataOption.insert_rows,
|
||||
table_range="A1",
|
||||
)
|
||||
except gspread.exceptions.APIError as e:
|
||||
http_code = getattr(e, "response", None)
|
||||
status_code = http_code.status_code if http_code else None
|
||||
error_detail = getattr(e, "error", {})
|
||||
error_msg = error_detail.get("message", str(e)) if isinstance(error_detail, dict) else str(e)
|
||||
logger.exception(
|
||||
"SHEETS_API_ERROR: type=APIError http_code=%s message=%r error=%s",
|
||||
status_code,
|
||||
error_msg,
|
||||
error_detail,
|
||||
extra={"sheet_link": sheet_link, "rows_count": len(rows)},
|
||||
)
|
||||
if status_code in _CACHE_INVALIDATE_CODES:
|
||||
get_sheet.cache_clear()
|
||||
logger.info("SHEETS_CACHE_CLEARED: сброшен кэш get_sheet после ошибки %s", status_code)
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
"SHEETS_ERROR: type=%s message=%s sheet_link=%s rows_count=%s",
|
||||
type(e).__name__,
|
||||
str(e),
|
||||
sheet_link,
|
||||
len(rows),
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
def read_sheet(sheet_link: str) -> list[dict[str, Any]]:
|
||||
sheet = get_sheet(sheet_link)
|
||||
return sheet.get_all_records()
|
||||
@@ -0,0 +1,80 @@
|
||||
import logging
|
||||
from datetime import datetime, time
|
||||
|
||||
from app import config
|
||||
from app.models import Review
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class StatsService:
|
||||
async def get_daily_stats(self, platform: str) -> dict:
|
||||
target_date = datetime.now(config.TIMEZONE).date()
|
||||
|
||||
day_start = datetime.combine(target_date, time.min, tzinfo=config.TIMEZONE)
|
||||
day_end = datetime.combine(target_date, time.max, tzinfo=config.TIMEZONE)
|
||||
|
||||
reviews = await Review.filter(
|
||||
platform=platform, published_at__gte=day_start, published_at__lte=day_end
|
||||
)
|
||||
|
||||
return self._calculate_stats(platform, reviews)
|
||||
|
||||
async def get_all_time_stats(self, platform: str) -> dict:
|
||||
reviews = await Review.filter(platform=platform)
|
||||
return self._calculate_stats(platform, reviews)
|
||||
|
||||
def _calculate_stats(self, platform: str, reviews: list) -> dict:
|
||||
good_reviews = 0
|
||||
bad_reviews = 0
|
||||
critical_reviews = 0
|
||||
|
||||
rating_counts = {1: 0, 2: 0, 3: 0, 4: 0, 5: 0}
|
||||
tone_counts = {"positive": 0, "neutral": 0, "negative": 0}
|
||||
|
||||
for review in reviews:
|
||||
rating = min(max(review.rating, 1), 5)
|
||||
rating_counts[rating] += 1
|
||||
|
||||
if rating <= 3:
|
||||
bad_reviews += 1
|
||||
if review.is_critical:
|
||||
critical_reviews += 1
|
||||
else:
|
||||
good_reviews += 1
|
||||
|
||||
if review.tone in tone_counts:
|
||||
tone_counts[review.tone] += 1
|
||||
|
||||
total_reviews = good_reviews + bad_reviews
|
||||
bad_ratio = bad_reviews / total_reviews if total_reviews > 0 else 0
|
||||
critical_ratio = critical_reviews / total_reviews if total_reviews > 0 else 0
|
||||
critical_to_bad_ratio = critical_reviews / bad_reviews if bad_reviews > 0 else 0
|
||||
|
||||
total_ratings = sum(rating_counts.values())
|
||||
weighted_sum = sum(count * rating for rating, count in rating_counts.items())
|
||||
average_rating = weighted_sum / total_ratings if total_ratings > 0 else 0
|
||||
|
||||
return {
|
||||
"platform": platform,
|
||||
"good_reviews": good_reviews,
|
||||
"bad_reviews": bad_reviews,
|
||||
"total_reviews": total_reviews,
|
||||
"bad_ratio": bad_ratio,
|
||||
"critical_reviews": critical_reviews,
|
||||
"critical_ratio": critical_ratio,
|
||||
"critical_to_bad_ratio": critical_to_bad_ratio,
|
||||
"ratings": {
|
||||
"1": rating_counts[1],
|
||||
"2": rating_counts[2],
|
||||
"3": rating_counts[3],
|
||||
"4": rating_counts[4],
|
||||
"5": rating_counts[5],
|
||||
},
|
||||
"tone": {
|
||||
"positive": tone_counts["positive"],
|
||||
"neutral": tone_counts["neutral"],
|
||||
"negative": tone_counts["negative"],
|
||||
},
|
||||
"average_rating": average_rating,
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime
|
||||
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__)
|
||||
|
||||
|
||||
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 WB products list")
|
||||
sheet = read_sheet(config.PRODUCTS_WB_SHEET_LINK)
|
||||
return {
|
||||
str(product.get("SKU") or product.get("Артикул WB")): product
|
||||
for product in sheet
|
||||
}
|
||||
|
||||
|
||||
class WildberriesClient:
|
||||
def __init__(self):
|
||||
self.base_url = config.WB_BASE_URL
|
||||
self.headers = {
|
||||
"Authorization": config.WB_API_KEY,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
async def get_reviews(
|
||||
self, date_from: int, date_to: int, limit: int = 30
|
||||
) -> list[dict[str, Any]]:
|
||||
url = f"{self.base_url}/api/v1/feedbacks"
|
||||
all_feedbacks = []
|
||||
|
||||
for is_answered in [True, False]:
|
||||
params = {
|
||||
"isAnswered": is_answered,
|
||||
"take": limit,
|
||||
"skip": 0,
|
||||
"order": "dateDesc",
|
||||
"dateFrom": date_from,
|
||||
"dateTo": date_to,
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
url, headers=self.headers, params=params
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
feedbacks = data.get("data", {}).get("feedbacks", [])
|
||||
all_feedbacks.extend(feedbacks)
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"Error fetching Wildberries reviews: {e}")
|
||||
|
||||
return all_feedbacks
|
||||
|
||||
async def post_comment(self, review_id: str, text: str) -> dict[str, Any]:
|
||||
url = f"{self.base_url}/api/v1/feedbacks/answer"
|
||||
data = {"id": review_id, "text": text}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(url, headers=self.headers, json=data)
|
||||
response.raise_for_status()
|
||||
|
||||
if response.status_code == 204:
|
||||
return {"success": True}
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Status code: {response.status_code}",
|
||||
}
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"Error posting comment to Wildberries: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def delete_comment(self, reply_id: str) -> dict[str, Any]:
|
||||
url = f"https://www.wildberries.ru/api/comments/replies/{reply_id}"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.delete(url, headers=self.headers)
|
||||
|
||||
if response.status_code == 204:
|
||||
return {"success": True}
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Status code: {response.status_code}",
|
||||
}
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"Error deleting comment from Wildberries: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
@staticmethod
|
||||
def format_datetime(iso_datetime: str) -> str:
|
||||
"""Format ISO datetime to a human-readable format."""
|
||||
try:
|
||||
dt = datetime.strptime(iso_datetime, "%Y-%m-%dT%H:%M:%SZ")
|
||||
dt = dt.replace(tzinfo=None) # Make naive before applying timezone
|
||||
dt = dt.astimezone(config.TIMEZONE)
|
||||
return dt.strftime("%d.%m.%Y %H:%M:%S")
|
||||
except ValueError:
|
||||
return iso_datetime
|
||||
|
||||
async def process_new_reviews(self) -> dict[str, int]:
|
||||
gpt_client = GPTClient()
|
||||
|
||||
current_time = int(datetime.now(config.TIMEZONE).timestamp())
|
||||
try:
|
||||
latest_review = (
|
||||
await Review.filter(platform="wb").order_by("-published_at").first()
|
||||
)
|
||||
start_time = (
|
||||
int(latest_review.published_at.timestamp())
|
||||
if latest_review
|
||||
else int(datetime(2000, 1, 1, tzinfo=config.TIMEZONE).timestamp())
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting latest review: {e}")
|
||||
start_time = int(datetime(2000, 1, 1, tzinfo=config.TIMEZONE).timestamp())
|
||||
|
||||
reviews = await self.get_reviews(start_time, current_time)
|
||||
if not reviews:
|
||||
logger.info("No reviews fetched from Wildberries")
|
||||
return {"processed": 0, "new": 0}
|
||||
|
||||
processed = 0
|
||||
for feedback in reviews:
|
||||
review_id = feedback.get("id")
|
||||
|
||||
existing = await Review.filter(
|
||||
external_id=review_id, platform="wb"
|
||||
).exists()
|
||||
if existing:
|
||||
continue
|
||||
|
||||
logger.info(f"WB: processing review {feedback}")
|
||||
|
||||
text = (
|
||||
(feedback.get("text", "") or "")
|
||||
+ "\n"
|
||||
+ (feedback.get("pros", "") or "")
|
||||
+ "\n"
|
||||
+ (feedback.get("cons", "") or "")
|
||||
).strip()
|
||||
|
||||
try:
|
||||
published_at = datetime.strptime(
|
||||
feedback.get("createdDate"), "%Y-%m-%dT%H:%M:%SZ"
|
||||
)
|
||||
published_at = published_at.replace(
|
||||
tzinfo=None
|
||||
) # Make naive before applying timezone
|
||||
published_at = published_at.astimezone(config.TIMEZONE)
|
||||
except (ValueError, TypeError):
|
||||
published_at = datetime.now(config.TIMEZONE)
|
||||
|
||||
product_id = str(feedback.get("productDetails", {}).get("nmId", ""))
|
||||
if not product_id:
|
||||
logger.error(f"WB: no product id in review {feedback}")
|
||||
continue
|
||||
|
||||
product = get_products_dict(get_cache_key()).get(product_id)
|
||||
if product:
|
||||
product.setdefault("Название", product.get("Наименование"))
|
||||
product.setdefault("Категория", product.get("Категория продавца"))
|
||||
product.setdefault("Подкатегория", "неизвестно")
|
||||
else:
|
||||
logger.warning(f"WB: no product found for id {product_id}")
|
||||
product = {
|
||||
"Название": feedback.get("productDetails", {}).get(
|
||||
"productName", ""
|
||||
),
|
||||
"Категория": "неизвестно",
|
||||
"Подкатегория": "неизвестно",
|
||||
}
|
||||
|
||||
# Extract image URLs from the feedback
|
||||
images = []
|
||||
if feedback.get("photoLinks"):
|
||||
for photo in feedback["photoLinks"]:
|
||||
if photo.get("fullSize"):
|
||||
images.append(photo["fullSize"])
|
||||
|
||||
bables_raw = feedback.get("bables")
|
||||
bables = (
|
||||
list(bables_raw)
|
||||
if isinstance(bables_raw, list)
|
||||
else ([bables_raw] if bables_raw is not None else None)
|
||||
)
|
||||
|
||||
review_data = {
|
||||
"external_id": review_id,
|
||||
"platform": "wb",
|
||||
"product_id": product_id,
|
||||
"product_name": product.get("Название", ""),
|
||||
"product_category": product.get("Категория", ""),
|
||||
"product_subcategory": product.get("Подкатегория", ""),
|
||||
"rating": feedback.get("productValuation", 0),
|
||||
"is_good": feedback.get("productValuation", 0) > config.NEGATIVE_RATING,
|
||||
"text": text,
|
||||
"published_at": published_at,
|
||||
"processed": False,
|
||||
"images": images if images else None,
|
||||
"bables": bables,
|
||||
}
|
||||
|
||||
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 result.get("success"):
|
||||
review_obj.response_text = response
|
||||
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 result.get("success"):
|
||||
review_obj.response_text = template
|
||||
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)}
|
||||
Reference in New Issue
Block a user