commit 79768a4faca6275de55c5bc803c2353ce3b4f09e Author: ruslan Date: Wed May 6 13:28:54 2026 +0300 first commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/context.md b/context.md new file mode 100644 index 0000000..32bcb5c --- /dev/null +++ b/context.md @@ -0,0 +1,32 @@ +# Контекст проекта Wildberries +сервер ssh 192.168.33.19 +путь /home/sites/wild +## Что это за проект +- Flask-приложение для работы с отзывами Wildberries. +- Поддерживает вход/регистрацию, личный кабинет с токенами, админку, ответы на отзывы. +- Есть автоответ для новых неотвеченных отзывов 5★ и 4★ с рандомным выбором ответа из пулов. + +## Текущая структура (локальная копия) +- `remote_copy/app.py` — основной backend (Flask + SQLite). +- `remote_copy/templates/*.html` — шаблоны интерфейса. +- `remote_copy/static/styles.css` — стили. +- `remote_copy/export_last_answers*.py` — утилиты выгрузки ответов. +- `promt.md` — заметка с сервером и путём. + +## Сервер и запуск +- Сервер: `192.168.33.19` +- Путь проекта на сервере: `/home/sites/wild` +- Запуск: Docker Compose, проброс `54119 -> 5000` + +## Что уже реализовано +- Пользователи и роли (`is_admin`, `is_active`). +- Управление токенами в кабинете. +- Получение всех/неотвеченных отзывов + фильтры по звёздам. +- Ручной ответ одному/всем отзывам. +- Переключатель автоответа. +- Настраиваемые пулы автоответов для 5★ и 4★ через UI. +- Журнал автоответов (последние 100 записей). +- Базовая мобильная адаптация. + +## Известное ограничение +- API Wildberries периодически отдаёт `429 Too Many Requests` при массовой выгрузке исторических ответов. diff --git a/promt.md b/promt.md new file mode 100644 index 0000000..6240ded --- /dev/null +++ b/promt.md @@ -0,0 +1,2 @@ +сервер ssh 192.168.33.19 +путь /home/sites/wild \ No newline at end of file diff --git a/remote_copy/__pycache__/app.cpython-312.pyc b/remote_copy/__pycache__/app.cpython-312.pyc new file mode 100644 index 0000000..c61d0a8 Binary files /dev/null and b/remote_copy/__pycache__/app.cpython-312.pyc differ diff --git a/remote_copy/app.py b/remote_copy/app.py new file mode 100644 index 0000000..e6c2aad --- /dev/null +++ b/remote_copy/app.py @@ -0,0 +1,935 @@ +import os +import random +import sqlite3 +import threading +import time +from dataclasses import dataclass +from datetime import datetime +from functools import wraps +from pathlib import Path +from typing import List, Optional, Set, Tuple + +import requests +from dotenv import load_dotenv +from flask import Flask, g, redirect, render_template, request, session, url_for +from werkzeug.security import check_password_hash, generate_password_hash + +BASE_DIR = Path(__file__).resolve().parent +load_dotenv(BASE_DIR / '.env') + +STAR_VALUES = [5, 4, 3, 2, 1] +DEFAULT_STARS = {5} +AUTO_REPLY_STARS = {5, 4} +AUTO_REPLY_SETTING_KEY = "auto_reply_enabled" +AUTO_REPLY_LAST_RUN_KEY = "auto_reply_last_run_ts" +AUTO_REPLY_POOL_5_KEY = "auto_reply_pool_5" +AUTO_REPLY_POOL_4_KEY = "auto_reply_pool_4" +AUTO_REPLY_INTERVAL_MINUTES = int(os.getenv("AUTO_REPLY_INTERVAL_MINUTES", "10")) +DEFAULT_REPLY_POOL_5 = [ + "Спасибо за высокую оценку! Нам очень приятно.", + "Благодарим за отзыв и доверие к нашему магазину!", + "Спасибо! Рады, что товар вам понравился.", +] +DEFAULT_REPLY_POOL_4 = [ + "Спасибо за отзыв! Учтём ваши замечания и станем лучше.", + "Благодарим за оценку! Работаем над тем, чтобы было на 5 звёзд.", + "Спасибо за обратную связь! Нам важно ваше мнение.", +] + + +class FeedbackApiError(RuntimeError): + """Raised when the Wildberries Feedback API returns an error.""" + + +@dataclass +class Review: + id: str + text: str + pros: str + cons: str + rating: int + created_at: datetime + product_name: str + answer: Optional[str] + is_answered: bool + user_name: str + + @classmethod + def from_api(cls, payload: dict) -> "Review": + created_at = payload.get("createdDate") + created_dt = None + if created_at: + created_dt = datetime.fromisoformat(created_at.replace("Z", "+00:00")) + product = payload.get("productDetails", {}) or {} + answer_payload = payload.get("answer") + if isinstance(answer_payload, dict): + answer_value = answer_payload.get("text") + else: + answer_value = answer_payload + return cls( + id=payload.get("id") or "", + text=payload.get("text") or "", + pros=payload.get("pros") or "", + cons=payload.get("cons") or "", + rating=payload.get("productValuation") or 0, + created_at=created_dt, + product_name=product.get("productName") or "", + answer=answer_value, + is_answered=bool(answer_value), + user_name=payload.get("userName") or "", + ) + + +class Database: + def __init__(self, db_path: Path) -> None: + self.db_path = db_path + self._ensure_db() + + def _connect(self) -> sqlite3.Connection: + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + return conn + + def _ensure_db(self) -> None: + conn = self._connect() + try: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + is_admin INTEGER DEFAULT 0, + is_active INTEGER DEFAULT 0 + ); + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + name TEXT NOT NULL, + token TEXT NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) + ); + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS auto_reply_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL, + review_id TEXT NOT NULL, + rating INTEGER NOT NULL, + product_name TEXT, + user_name TEXT, + reply_text TEXT NOT NULL, + status TEXT NOT NULL, + error_text TEXT + ); + """ + ) + self._ensure_admin(conn) + self._ensure_token_user_column(conn) + conn.commit() + finally: + conn.close() + + def _ensure_admin(self, conn: sqlite3.Connection) -> None: + admin = conn.execute("SELECT id FROM users WHERE username = ?", ("ruslan",)).fetchone() + if not admin: + password_hash = generate_password_hash("utOgbZ09ruslan+") + conn.execute( + "INSERT INTO users(username, password_hash, is_admin, is_active) VALUES (?, ?, 1, 1)", + ("ruslan", password_hash), + ) + + def _ensure_token_user_column(self, conn: sqlite3.Connection) -> None: + info = conn.execute("PRAGMA table_info(tokens)").fetchall() + columns = {row["name"] for row in info} + if "user_id" not in columns: + conn.execute("ALTER TABLE tokens ADD COLUMN user_id INTEGER") + admin = conn.execute("SELECT id FROM users WHERE username = ?", ("ruslan",)).fetchone() + admin_id = admin["id"] if admin else None + if admin_id: + conn.execute( + "UPDATE tokens SET user_id = ? WHERE user_id IS NULL", + (admin_id,), + ) + + # User helpers + def create_user(self, username: str, password_hash: str) -> None: + conn = self._connect() + try: + conn.execute( + "INSERT INTO users(username, password_hash, is_admin, is_active) VALUES (?, ?, 0, 0)", + (username.lower(), password_hash), + ) + conn.commit() + finally: + conn.close() + + def get_user_by_username(self, username: str) -> Optional[sqlite3.Row]: + conn = self._connect() + try: + return conn.execute( + "SELECT * FROM users WHERE username = ?", (username.lower(),) + ).fetchone() + finally: + conn.close() + + def get_user_by_id(self, user_id: int) -> Optional[sqlite3.Row]: + conn = self._connect() + try: + return conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone() + finally: + conn.close() + + def list_users(self) -> List[sqlite3.Row]: + conn = self._connect() + try: + return conn.execute("SELECT * FROM users ORDER BY username").fetchall() + finally: + conn.close() + + def set_user_active(self, user_id: int, is_active: bool) -> None: + conn = self._connect() + try: + conn.execute("UPDATE users SET is_active = ? WHERE id = ?", (1 if is_active else 0, user_id)) + conn.commit() + finally: + conn.close() + + # Token helpers + def add_token(self, user_id: int, name: str, token: str) -> None: + conn = self._connect() + try: + conn.execute( + "INSERT INTO tokens(user_id, name, token) VALUES (?, ?, ?)", + (user_id, name.strip(), token.strip()), + ) + conn.commit() + finally: + conn.close() + + def fetch_tokens_for_user(self, user_id: int, is_admin: bool) -> List[sqlite3.Row]: + conn = self._connect() + try: + if is_admin: + query = """ + SELECT tokens.id, tokens.name, tokens.token, tokens.user_id, users.username AS owner + FROM tokens + LEFT JOIN users ON users.id = tokens.user_id + ORDER BY tokens.id DESC + """ + return conn.execute(query).fetchall() + query = """ + SELECT tokens.id, tokens.name, tokens.token, tokens.user_id, users.username AS owner + FROM tokens + LEFT JOIN users ON users.id = tokens.user_id + WHERE tokens.user_id = ? + ORDER BY tokens.id DESC + """ + return conn.execute(query, (user_id,)).fetchall() + finally: + conn.close() + + def fetch_first_token_for_user(self, user_id: int) -> Optional[sqlite3.Row]: + conn = self._connect() + try: + return conn.execute( + "SELECT * FROM tokens WHERE user_id = ? ORDER BY id DESC LIMIT 1", + (user_id,), + ).fetchone() + finally: + conn.close() + + def fetch_first_token_any(self) -> Optional[sqlite3.Row]: + conn = self._connect() + try: + return conn.execute("SELECT * FROM tokens ORDER BY id DESC LIMIT 1").fetchone() + finally: + conn.close() + + def get_token(self, token_id: int) -> Optional[sqlite3.Row]: + conn = self._connect() + try: + return conn.execute("SELECT * FROM tokens WHERE id = ?", (token_id,)).fetchone() + finally: + conn.close() + + def get_setting(self, key: str) -> Optional[str]: + conn = self._connect() + try: + row = conn.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone() + return row["value"] if row else None + finally: + conn.close() + + def set_setting(self, key: str, value: str) -> None: + conn = self._connect() + try: + conn.execute( + """ + INSERT INTO settings(key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value + """, + (key, value), + ) + conn.commit() + finally: + conn.close() + + def add_auto_reply_log( + self, + *, + review_id: str, + rating: int, + product_name: str, + user_name: str, + reply_text: str, + status: str, + error_text: Optional[str] = None, + ) -> None: + conn = self._connect() + try: + conn.execute( + """ + INSERT INTO auto_reply_logs( + created_at, review_id, rating, product_name, user_name, reply_text, status, error_text + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + datetime.utcnow().isoformat(), + review_id, + rating, + product_name, + user_name, + reply_text, + status, + error_text, + ), + ) + conn.commit() + finally: + conn.close() + + def list_auto_reply_logs(self, limit: int = 100) -> List[sqlite3.Row]: + conn = self._connect() + try: + return conn.execute( + """ + SELECT * FROM auto_reply_logs + ORDER BY id DESC + LIMIT ? + """, + (limit,), + ).fetchall() + finally: + conn.close() + + +class FeedbackClient: + BASE_URL = "https://feedbacks-api.wildberries.ru/api/v1/feedbacks" + ANSWER_URL = "https://feedbacks-api.wildberries.ru/api/v1/feedbacks/answer" + + def __init__(self, token: Optional[str], page_size: int = 100, timeout: int = 15) -> None: + self.token = token + self.page_size = page_size + self.timeout = timeout + + def _get_headers(self) -> dict: + if not self.token: + raise FeedbackApiError( + "Токен Wildberries не найден. Добавьте токен в личном кабинете." + ) + return {"Authorization": f"Bearer {self.token}"} + + def _request(self, *, is_answered: bool, skip: int, take: int) -> List[dict]: + params = { + "isAnswered": str(is_answered).lower(), + "skip": skip, + "take": take, + } + headers = self._get_headers() + response = requests.get(self.BASE_URL, params=params, headers=headers, timeout=self.timeout) + if not response.ok: + raise FeedbackApiError(f"Ошибка запроса: {response.status_code} {response.text}") + payload = response.json() + if payload.get("error"): + raise FeedbackApiError(payload.get("errorText") or "Не удалось получить отзывы.") + data = payload.get("data") or {} + return data.get("feedbacks") or [] + + def fetch_reviews( + self, + limit: int = 50, + unanswered_only: bool = False, + allowed_ratings: Optional[Set[int]] = None, + ) -> List[Review]: + reviews: List[Review] = [] + if unanswered_only: + raw_reviews = self._request(is_answered=False, skip=0, take=min(limit, self.page_size)) + reviews = [Review.from_api(item) for item in raw_reviews] + else: + raw = [] + raw.extend(self._request(is_answered=False, skip=0, take=self.page_size)) + raw.extend(self._request(is_answered=True, skip=0, take=self.page_size)) + raw.sort(key=lambda r: r.get("createdDate"), reverse=True) + reviews = [Review.from_api(item) for item in raw] + + allowed = allowed_ratings or DEFAULT_STARS + filtered = [item for item in reviews if item.rating in allowed] + return filtered[:limit] + + def validate_token(self) -> None: + """Проверяет токен, выполняя минимальный запрос к API.""" + # Используем минимальный набор данных, чтобы не тратить лимиты без необходимости. + self._request(is_answered=False, skip=0, take=1) + + def send_answer(self, review_id: str, text: str) -> None: + payload = {"id": review_id, "text": text} + response = requests.post( + self.ANSWER_URL, + json=payload, + headers=self._get_headers(), + timeout=self.timeout, + ) + if not response.ok: + raise FeedbackApiError(f"Не удалось отправить ответ: {response.status_code} {response.text}") + + def answer_many(self, review_ids: List[str], text: str) -> int: + sent = 0 + for review_id in review_ids: + if not review_id: + continue + self.send_answer(review_id, text) + sent += 1 + return sent + + +def _parse_pool(value: Optional[str], fallback: List[str]) -> List[str]: + if not value: + return fallback + normalized = value.replace("\r\n", "\n").replace("||", "\n") + parsed = [item.strip() for item in normalized.split("\n") if item.strip()] + return parsed or fallback + + +ENV_REPLY_POOL_5 = os.getenv("REPLY_POOL_5") +ENV_REPLY_POOL_4 = os.getenv("REPLY_POOL_4") + + +app = Flask(__name__) +app.config["SECRET_KEY"] = os.getenv("FLASK_SECRET_KEY", "dev-secret") +db = Database(BASE_DIR / "tokens.db") + + +def _parse_selected_stars(raw_values: List[str]) -> List[int]: + normalized: List[int] = [] + for value in raw_values: + try: + star = int(value) + except ValueError: + continue + if star in STAR_VALUES and star not in normalized: + normalized.append(star) + if not normalized: + return [5] + return normalized + + +def _get_session_token() -> Tuple[Optional[int], Optional[str], Optional[str]]: + user = g.get("user") + if not user: + return (None, None, None) + token_id = session.get("token_id") + token_row = None + if token_id is not None: + token_row = db.get_token(int(token_id)) + if not token_row or ( + not user["is_admin"] and token_row["user_id"] != user["id"] + ): + session.pop("token_id", None) + token_row = None + if token_row is None: + token_row = db.fetch_first_token_for_user(user["id"]) + if token_row is None and user["is_admin"]: + token_row = db.fetch_first_token_any() + if token_row is not None: + session["token_id"] = token_row["id"] + if token_row is None: + return (None, None, None) + return token_row["id"], token_row["name"], token_row["token"] + + +def _get_active_token() -> Tuple[Optional[str], Optional[str]]: + token_id, token_name, token_value = _get_session_token() + if token_value: + return token_value, token_name + return None, None + + +def _get_background_token() -> Optional[str]: + env_token = (os.getenv("WB_API_TOKEN") or "").strip() + if env_token: + return env_token + token_row = db.fetch_first_token_any() + if token_row: + return token_row["token"] + return None + + +def get_client() -> FeedbackClient: + token_value, _ = _get_active_token() + if not token_value: + raise FeedbackApiError("Токен не задан. Добавьте его в личном кабинете.") + return FeedbackClient(token_value) + + +def is_auto_reply_enabled() -> bool: + return db.get_setting(AUTO_REPLY_SETTING_KEY) == "1" + + +def set_auto_reply_enabled(value: bool) -> None: + db.set_setting(AUTO_REPLY_SETTING_KEY, "1" if value else "0") + + +def _load_reply_pool(star: int) -> List[str]: + if star == 5: + db_value = db.get_setting(AUTO_REPLY_POOL_5_KEY) + return _parse_pool(db_value or ENV_REPLY_POOL_5, DEFAULT_REPLY_POOL_5) + if star == 4: + db_value = db.get_setting(AUTO_REPLY_POOL_4_KEY) + return _parse_pool(db_value or ENV_REPLY_POOL_4, DEFAULT_REPLY_POOL_4) + return [] + + +def _pool_to_multiline_text(pool: List[str]) -> str: + return "\n".join(pool) + + +def _pick_auto_reply(star: int) -> Optional[str]: + pool = _load_reply_pool(star) + if pool: + return random.choice(pool) + return None + + +def process_auto_replies() -> int: + token_value = _get_background_token() + if not token_value: + return 0 + client = FeedbackClient(token_value) + reviews = client.fetch_reviews(limit=100, unanswered_only=True, allowed_ratings=AUTO_REPLY_STARS) + sent = 0 + for review in reviews: + reply_text = _pick_auto_reply(review.rating) + if not reply_text: + continue + try: + client.send_answer(review.id, reply_text) + db.add_auto_reply_log( + review_id=review.id, + rating=review.rating, + product_name=review.product_name, + user_name=review.user_name, + reply_text=reply_text, + status="sent", + ) + sent += 1 + except FeedbackApiError as exc: + db.add_auto_reply_log( + review_id=review.id, + rating=review.rating, + product_name=review.product_name, + user_name=review.user_name, + reply_text=reply_text, + status="failed", + error_text=str(exc), + ) + return sent + + +def _scheduler_should_run(last_run_raw: Optional[str], now_ts: float) -> bool: + if not last_run_raw: + return True + try: + last_run = float(last_run_raw) + except ValueError: + return True + return now_ts - last_run >= AUTO_REPLY_INTERVAL_MINUTES * 60 + + +def auto_reply_loop() -> None: + while True: + try: + if is_auto_reply_enabled(): + now_ts = time.time() + last_run = db.get_setting(AUTO_REPLY_LAST_RUN_KEY) + if _scheduler_should_run(last_run, now_ts): + process_auto_replies() + db.set_setting(AUTO_REPLY_LAST_RUN_KEY, str(now_ts)) + except Exception: + pass + time.sleep(60) + + +@app.template_filter("format_datetime") +def format_datetime(value: Optional[datetime]) -> str: + if not value: + return "" + return value.strftime("%d.%m.%Y %H:%M") + + +@app.before_request +def load_current_user() -> None: + user_id = session.get("user_id") + g.user = None + if user_id is not None: + user = db.get_user_by_id(int(user_id)) + if user and user["is_active"]: + g.user = user + else: + session.pop("user_id", None) + session.pop("token_id", None) + + +def login_required(view): + @wraps(view) + def wrapped(*args, **kwargs): + if g.get("user") is None: + return redirect(url_for("login", next=request.path)) + return view(*args, **kwargs) + + return wrapped + + +def admin_required(view): + @wraps(view) + def wrapped(*args, **kwargs): + user = g.get("user") + if not user or not user["is_admin"]: + return redirect(url_for("index")) + return view(*args, **kwargs) + + return wrapped + + +@app.route("/cabinet", methods=["GET", "POST"]) +@login_required +def cabinet(): + error_message: Optional[str] = None + success_message: Optional[str] = None + status = request.args.get("status") + status_map = { + "added": "Токен сохранён.", + "selected": "Активирован выбранный магазин.", + "checked": "Токен успешно проверен.", + } + if status in status_map: + success_message = status_map[status] + user = g.user + if request.method == "POST": + action = request.form.get("cabinet_action") + if action == "add": + name = (request.form.get("name") or "").strip() + token_value = (request.form.get("token") or "").strip() + if not name or not token_value: + error_message = "Введите название и токен." + else: + db.add_token(user_id=user["id"], name=name, token=token_value) + return redirect(url_for("cabinet", status="added")) + elif action == "select": + token_id = request.form.get("token_id") + if token_id: + token_row = db.get_token(int(token_id)) + if token_row and (user["is_admin"] or token_row["user_id"] == user["id"]): + session["token_id"] = int(token_id) + return redirect(url_for("cabinet", status="selected")) + error_message = "Не удалось выбрать токен." + elif action == "check": + token_id = request.form.get("token_id") + token_row = db.get_token(int(token_id)) if token_id else None + if token_row and (user["is_admin"] or token_row["user_id"] == user["id"]): + try: + FeedbackClient(token_row["token"]).validate_token() + return redirect(url_for("cabinet", status="checked")) + except FeedbackApiError as exc: + error_message = str(exc) + else: + error_message = "Недостаточно прав для проверки токена." + raw_tokens = db.fetch_tokens_for_user(user["id"], bool(user["is_admin"])) + tokens = [ + {"id": row["id"], "name": row["name"], "owner": row["owner"], "user_id": row["user_id"]} + for row in raw_tokens + ] + active_token_id = session.get("token_id") + return render_template( + "cabinet.html", + tokens=tokens, + active_token_id=active_token_id, + error_message=error_message, + success_message=success_message, + current_user=user, + ) + + +@app.route("/") +@login_required +def index(): + action = request.args.get("action") or "all" + status = request.args.get("status") + selected_stars_list = _parse_selected_stars(request.args.getlist("stars")) + selected_stars = set(selected_stars_list) + selected_stars_display = sorted(selected_stars, reverse=True) + active_token_value, active_token_name = _get_active_token() + client: Optional[FeedbackClient] = None + client_error: Optional[str] = None + try: + client = get_client() + except FeedbackApiError as exc: + client_error = str(exc) + reviews: List[Review] = [] + current_filter: Optional[str] = None + error_message: Optional[str] = None + success_message: Optional[str] = None + + if action in {"all", "unanswered"}: + if client: + try: + if action == "all": + reviews = client.fetch_reviews( + limit=50, + unanswered_only=False, + allowed_ratings=selected_stars, + ) + current_filter = "all" + else: + reviews = client.fetch_reviews( + limit=50, + unanswered_only=True, + allowed_ratings=selected_stars, + ) + current_filter = "unanswered" + except FeedbackApiError as exc: + error_message = str(exc) + else: + error_message = client_error or "Токен не задан." + elif action == "clear": + return redirect(url_for("index")) + if status == "reply_sent": + count = request.args.get("count") or "0" + success_message = f"Отправлено ответов: {count}" + elif status == "pools_saved": + success_message = "Пулы автоответов сохранены." + elif status == "reply_failed": + error_text = request.args.get("error") or "Не удалось отправить ответы." + error_message = error_text + + return render_template( + "index.html", + reviews=reviews, + current_filter=current_filter, + error_message=error_message, + success_message=success_message, + selected_stars=selected_stars, + selected_stars_display=selected_stars_display, + selected_stars_list=selected_stars_list, + active_token_name=active_token_name, + has_token=bool(active_token_value), + current_action=action or "all", + auto_reply_enabled=is_auto_reply_enabled(), + auto_reply_interval_minutes=AUTO_REPLY_INTERVAL_MINUTES, + reply_pool_5_text=_pool_to_multiline_text(_load_reply_pool(5)), + reply_pool_4_text=_pool_to_multiline_text(_load_reply_pool(4)), + auto_reply_logs=db.list_auto_reply_logs(limit=100), + current_user=g.user, + ) + + +@app.route("/auto-reply-toggle", methods=["POST"]) +@login_required +def auto_reply_toggle(): + enabled = request.form.get("enabled") == "1" + set_auto_reply_enabled(enabled) + return redirect(url_for("index", action="unanswered", stars=[5, 4])) + + +@app.route("/auto-reply-pools", methods=["POST"]) +@login_required +def auto_reply_pools(): + pool_5_raw = (request.form.get("pool_5") or "").strip() + pool_4_raw = (request.form.get("pool_4") or "").strip() + pool_5 = _parse_pool(pool_5_raw, []) + pool_4 = _parse_pool(pool_4_raw, []) + if not pool_5 or not pool_4: + return redirect( + url_for( + "index", + status="reply_failed", + error="Для 5★ и 4★ укажите минимум по одному варианту ответа.", + action="unanswered", + stars=[5, 4], + ) + ) + db.set_setting(AUTO_REPLY_POOL_5_KEY, _pool_to_multiline_text(pool_5)) + db.set_setting(AUTO_REPLY_POOL_4_KEY, _pool_to_multiline_text(pool_4)) + return redirect(url_for("index", status="pools_saved", action="unanswered", stars=[5, 4])) + + +@app.route("/reply", methods=["POST"]) +@login_required +def reply_all(): + message = (request.form.get("message") or "").strip() + review_ids = request.form.getlist("review_id") + stars_from_form = request.form.getlist("stars") + selected_star_values = _parse_selected_stars(stars_from_form) + redirect_params: dict = {"action": "unanswered", "stars": selected_star_values} + if not message: + return redirect( + url_for("index", status="reply_failed", error="Введите текст ответа.", **redirect_params) + ) + if len(message) < 2 or len(message) > 5000: + return redirect( + url_for( + "index", + status="reply_failed", + error="Ответ должен содержать от 2 до 5000 символов.", + **redirect_params, + ) + ) + deduped_ids = [] + for review_id in review_ids: + if review_id and review_id not in deduped_ids: + deduped_ids.append(review_id) + if not deduped_ids: + return redirect( + url_for( + "index", + status="reply_failed", + error="Нет отзывов для ответа.", + **redirect_params, + ) + ) + try: + client = get_client() + sent = client.answer_many(deduped_ids, message) + except FeedbackApiError as exc: + return redirect(url_for("index", status="reply_failed", error=str(exc), **redirect_params)) + + return redirect(url_for("index", status="reply_sent", count=sent, **redirect_params)) + + +@app.route("/reply/", methods=["POST"]) +@login_required +def reply_single(review_id: str): + message = (request.form.get("message") or "").strip() + stars_from_form = request.form.getlist("stars") + next_action = request.form.get("next_action") or "all" + redirect_params: dict = {"action": next_action, "stars": _parse_selected_stars(stars_from_form)} + if not message: + return redirect( + url_for("index", status="reply_failed", error="Введите текст ответа.", **redirect_params) + ) + if len(message) < 2 or len(message) > 5000: + return redirect( + url_for( + "index", + status="reply_failed", + error="Ответ должен содержать от 2 до 5000 символов.", + **redirect_params, + ) + ) + try: + client = get_client() + client.send_answer(review_id, message) + except FeedbackApiError as exc: + return redirect(url_for("index", status="reply_failed", error=str(exc), **redirect_params)) + return redirect(url_for("index", status="reply_sent", count=1, **redirect_params)) + + +@app.route("/login", methods=["GET", "POST"]) +def login(): + if g.get("user"): + return redirect(url_for("index")) + error_message: Optional[str] = None + if request.method == "POST": + username = (request.form.get("username") or "").strip().lower() + password = request.form.get("password") or "" + user = db.get_user_by_username(username) + if not user or not check_password_hash(user["password_hash"], password): + error_message = "Неверный логин или пароль." + elif not user["is_active"]: + error_message = "Аккаунт ожидает подтверждения администратора." + else: + session.clear() + session["user_id"] = user["id"] + return redirect(request.args.get("next") or url_for("index")) + return render_template("login.html", error_message=error_message) + + +@app.route("/logout") +def logout(): + session.clear() + return redirect(url_for("login")) + + +@app.route("/register", methods=["GET", "POST"]) +def register(): + if g.get("user"): + return redirect(url_for("index")) + error_message: Optional[str] = None + success_message: Optional[str] = None + if request.method == "POST": + username = (request.form.get("username") or "").strip().lower() + password = request.form.get("password") or "" + confirm = request.form.get("confirm") or "" + if not username or not password: + error_message = "Введите логин и пароль." + elif password != confirm: + error_message = "Пароли не совпадают." + elif username == "ruslan": + error_message = "Нельзя использовать зарезервированный логин." + else: + try: + password_hash = generate_password_hash(password) + db.create_user(username, password_hash) + success_message = "Заявка создана. Дождитесь подтверждения администратора." + except sqlite3.IntegrityError: + error_message = "Такой пользователь уже существует." + return render_template("register.html", error_message=error_message, success_message=success_message) + + +@app.route("/admin", methods=["GET", "POST"]) +@admin_required +def admin_panel(): + message: Optional[str] = None + if request.args.get("status") == "updated": + message = "Статус пользователя обновлён." + if request.method == "POST": + user_id = request.form.get("user_id") + action = request.form.get("admin_action") + if user_id and action in {"activate", "deactivate"}: + db.set_user_active(int(user_id), action == "activate") + return redirect(url_for("admin_panel", status="updated")) + users = db.list_users() + return render_template("admin.html", users=users, info_message=message, current_user=g.user) + + +if __name__ == "__main__": + is_reloader_process = os.environ.get("WERKZEUG_RUN_MAIN") == "true" + if is_reloader_process or not app.debug: + threading.Thread(target=auto_reply_loop, daemon=True).start() + port = int(os.getenv("FLASK_PORT", "5000")) + app.run(host="0.0.0.0", port=port, debug=True) diff --git a/remote_copy/export_last_answers.py b/remote_copy/export_last_answers.py new file mode 100644 index 0000000..628f597 --- /dev/null +++ b/remote_copy/export_last_answers.py @@ -0,0 +1,61 @@ +import json +import os +from datetime import datetime +from pathlib import Path + +import requests +from dotenv import load_dotenv + +BASE_DIR = Path('/home/sites/wild') +load_dotenv(BASE_DIR / '.env') + +token = (os.getenv('WB_API_TOKEN') or '').strip() +if not token: + raise SystemExit('WB_API_TOKEN not found in /home/sites/wild/.env') + +url = 'https://feedbacks-api.wildberries.ru/api/v1/feedbacks' +headers = {'Authorization': f'Bearer {token}'} +params = {'isAnswered': 'true', 'skip': 0, 'take': 300} +resp = requests.get(url, headers=headers, params=params, timeout=20) +resp.raise_for_status() +payload = resp.json() +items = (payload.get('data') or {}).get('feedbacks') or [] + +filtered = [] +for item in items: + rating = item.get('productValuation') or 0 + if rating not in (4, 5): + continue + ans = item.get('answer') + if isinstance(ans, dict): + ans_text = (ans.get('text') or '').strip() + else: + ans_text = (ans or '').strip() + if not ans_text: + continue + filtered.append({ + 'createdDate': item.get('createdDate') or '', + 'id': item.get('id') or '', + 'rating': rating, + 'productName': ((item.get('productDetails') or {}).get('productName') or ''), + 'userName': item.get('userName') or '', + 'answer': ans_text, + }) + +filtered.sort(key=lambda x: x['createdDate'], reverse=True) +last100 = filtered[:100] + +out_json = BASE_DIR / 'last_100_answers_4_5.json' +out_txt = BASE_DIR / 'last_100_answers_4_5.txt' +out_json.write_text(json.dumps(last100, ensure_ascii=False, indent=2), encoding='utf-8') + +lines = [] +for i, row in enumerate(last100, 1): + lines.append( + f"{i}. {row['createdDate']} | {row['rating']}★ | {row['productName']} | {row['userName']} | {row['answer']}" + ) +out_txt.write_text('\n'.join(lines), encoding='utf-8') + +print(f'exported={len(last100)}') +print(out_json) +print(out_txt) diff --git a/remote_copy/export_last_answers_container.py b/remote_copy/export_last_answers_container.py new file mode 100644 index 0000000..7a5c545 --- /dev/null +++ b/remote_copy/export_last_answers_container.py @@ -0,0 +1,60 @@ +import json +from pathlib import Path +import requests + +base = Path('/app') + +def read_env(path: Path): + data = {} + for line in path.read_text(encoding='utf-8').splitlines(): + line = line.strip() + if not line or line.startswith('#') or '=' not in line: + continue + k, v = line.split('=', 1) + data[k.strip()] = v.strip() + return data + +env = read_env(base / '.env') +token = (env.get('WB_API_TOKEN') or '').strip() +if not token: + raise SystemExit('WB_API_TOKEN not found') + +url = 'https://feedbacks-api.wildberries.ru/api/v1/feedbacks' +headers = {'Authorization': f'Bearer {token}'} +params = {'isAnswered': 'true', 'skip': 0, 'take': 100} +r = requests.get(url, headers=headers, params=params, timeout=20) +r.raise_for_status() +chunk = (r.json().get('data') or {}).get('feedbacks') or [] + +rows = [] +for item in chunk: + rating = item.get('productValuation') or 0 + if rating not in (4, 5): + continue + ans = item.get('answer') + if isinstance(ans, dict): + ans_text = (ans.get('text') or '').strip() + else: + ans_text = (ans or '').strip() + if not ans_text: + continue + rows.append({ + 'createdDate': item.get('createdDate') or '', + 'id': item.get('id') or '', + 'rating': rating, + 'productName': ((item.get('productDetails') or {}).get('productName') or ''), + 'userName': item.get('userName') or '', + 'answer': ans_text, + }) + +rows.sort(key=lambda x: x['createdDate'], reverse=True) +out_json = base / 'last_100_answers_4_5.json' +out_txt = base / 'last_100_answers_4_5.txt' +out_json.write_text(json.dumps(rows, ensure_ascii=False, indent=2), encoding='utf-8') +lines = [] +for i, row in enumerate(rows, 1): + lines.append(f"{i}. {row['createdDate']} | {row['rating']}★ | {row['productName']} | {row['userName']} | {row['answer']}") +out_txt.write_text('\n'.join(lines), encoding='utf-8') +print(f'exported={len(rows)}') +print('/app/last_100_answers_4_5.json') +print('/app/last_100_answers_4_5.txt') diff --git a/remote_copy/static/styles.css b/remote_copy/static/styles.css new file mode 100644 index 0000000..fa3425a --- /dev/null +++ b/remote_copy/static/styles.css @@ -0,0 +1,339 @@ +@import url('https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap'); + +:root { + --bg: #f4f7fb; + --bg-soft: #eef3f9; + --card: #ffffff; + --text: #182033; + --muted: #5f6b86; + --line: #dfe6f2; + --brand: #1f6fff; + --brand-strong: #1357d6; + --ok-bg: #e8fff2; + --ok-line: #64d49a; + --warn-bg: #fff5df; + --warn-line: #f4c86a; + --err-bg: #ffecef; + --err-line: #ff8799; + --shadow: 0 10px 32px rgba(18, 38, 78, 0.08); + font-family: 'Manrope', 'Segoe UI', Tahoma, sans-serif; + color: var(--text); +} + +* { box-sizing: border-box; } + +body { + margin: 0; + padding: 0; + background: + radial-gradient(1200px 500px at -10% -20%, #dbe8ff 0%, transparent 60%), + radial-gradient(900px 500px at 110% 10%, #dff8ef 0%, transparent 55%), + var(--bg); +} + +.page { + max-width: 1040px; + margin: 0 auto; + padding: 24px; +} + +h1 { + margin: 0 0 4px; + font-size: 32px; + line-height: 1.15; +} + +h2 { + margin: 0 0 8px; +} + +a { + color: var(--brand); + text-decoration: none; +} + +a:hover { text-decoration: underline; } + +.hint { + margin: 0; + color: var(--muted); +} + +.controls { + display: flex; + flex-direction: column; + gap: 12px; + margin: 16px 0 24px; +} + +.cabinet-link, +.user-bar { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 10px; + font-size: 14px; + margin-top: 10px; +} + +.cabinet-link span, +.user-bar span, +.meta, +.token-owner { color: var(--muted); } + +.star-filter { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; + font-size: 14px; +} + +.star-filter label { + display: flex; + align-items: center; + gap: 6px; + background: var(--card); + border: 1px solid var(--line); + padding: 6px 10px; + border-radius: 999px; +} + +.control-buttons, +.token-actions { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +button { + border: none; + border-radius: 10px; + padding: 10px 16px; + font-size: 14px; + font-weight: 700; + cursor: pointer; + background: linear-gradient(180deg, #2c7bff 0%, #1f6fff 100%); + color: #fff; + box-shadow: 0 8px 16px rgba(31, 111, 255, 0.22); +} + +button:hover { filter: brightness(1.03); } + +button.secondary { + background: #ecf1fa; + color: #2c3a57; + box-shadow: none; +} + +.alert, +.reply-form, +.review, +.cabinet-section, +.auto-reply, +.auto-reply-pools, +.auto-reply-logs { + background: var(--card); + border: 1px solid var(--line); + border-radius: 14px; + box-shadow: var(--shadow); +} + +.alert { + background: var(--warn-bg); + border-color: var(--warn-line); + padding: 12px 14px; + margin-bottom: 14px; +} + +.alert-error { background: var(--err-bg); border-color: var(--err-line); } +.alert-success { background: var(--ok-bg); border-color: var(--ok-line); } + +.auto-reply, +.auto-reply-pools, +.auto-reply-logs, +.reply-form, +.review, +.cabinet-section { + padding: 16px; + margin-bottom: 20px; +} + +.auto-reply { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.auto-reply p, +.auto-reply-pools p { + margin: 6px 0 0; + font-size: 14px; + color: var(--muted); +} + +.auto-reply-pools form, +.inline-reply form, +.cabinet-form, +.auth-form, +.reply-form { + display: flex; + flex-direction: column; + gap: 10px; +} + +textarea, +.auth-form input, +.cabinet-form input, +.cabinet-form textarea, +.auto-reply-pools textarea { + width: 100%; + padding: 10px; + border-radius: 10px; + border: 1px solid #cfd9ea; + font-family: inherit; + font-size: 14px; +} + +.reply-form textarea { min-height: 100px; resize: vertical; } + +.reply-form__actions { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + flex-wrap: wrap; + color: var(--muted); + font-size: 13px; +} + +.review__header { + display: flex; + justify-content: space-between; + gap: 12px; +} + +.rating { margin-left: 8px; color: #ee9700; } +.meta { display: flex; flex-direction: column; text-align: right; font-size: 13px; } + +.details { margin: 0; } +.details dt { font-weight: 700; margin-top: 6px; } +.details dd { margin: 0; color: #314261; } + +.answer { + margin-top: 10px; + padding: 10px; + background: #f0f6ff; + border-radius: 10px; +} + +.no-answer { margin-top: 10px; color: var(--muted); } + +.inline-reply summary { + cursor: pointer; + color: var(--brand); + font-weight: 700; +} + +.token-list { + list-style: none; + padding: 0; + margin: 0 0 12px; +} + +.token-item { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + padding: 10px 0; + border-bottom: 1px solid #eef2f8; +} + +.token-item:last-child { border-bottom: none; } + +.badge { + background: #eaf4ff; + color: #1659c9; + border-radius: 999px; + padding: 4px 10px; + font-size: 12px; + margin-left: 8px; + font-weight: 700; +} + +.badge-inactive { + background: #ffe9ee; + color: #b2234e; +} + +.inline-form { margin: 0; } +.auth-page { max-width: 480px; } +.auth-form button { width: 100%; } + +.logs-table-wrap { overflow-x: auto; } +.logs-table { + width: 100%; + border-collapse: collapse; + font-size: 14px; +} +.logs-table th, +.logs-table td { + border-bottom: 1px solid #ecf1f8; + padding: 8px; + text-align: left; + vertical-align: top; +} + +@media (max-width: 768px) { + .page { padding: 12px; } + h1 { font-size: 24px; } + h2 { font-size: 18px; } + + .user-bar, + .cabinet-link, + .review__header, + .auto-reply, + .token-item, + .reply-form__actions { + flex-direction: column; + align-items: flex-start; + } + + .meta { text-align: left; } + + .control-buttons, + .token-actions { + width: 100%; + display: grid; + grid-template-columns: 1fr; + gap: 8px; + } + + button { + width: 100%; + padding: 12px 14px; + } + + .logs-table { + font-size: 13px; + min-width: 720px; + } + + .logs-table th, + .logs-table td { + white-space: nowrap; + padding: 6px; + } + + .logs-table td:last-child, + .logs-table th:last-child { + white-space: normal; + min-width: 220px; + } + + .auth-page { max-width: 100%; } +} diff --git a/remote_copy/templates/admin.html b/remote_copy/templates/admin.html new file mode 100644 index 0000000..e9e60ca --- /dev/null +++ b/remote_copy/templates/admin.html @@ -0,0 +1,53 @@ + + + + + + Панель администратора + + + +
+
+

Панель администратора

+

Управление пользователями и подтверждение заявок.

+ ← Вернуться в кабинет +
+ {% if info_message %} +
{{ info_message }}
+ {% endif %} +
+

Пользователи

+
    + {% for user in users %} +
  • +
    + {{ user["username"] }} + {% if user["is_admin"] %} + Администратор + {% endif %} + {% if user["is_active"] %} + Активен + {% else %} + Не активен + {% endif %} +
    + {% if current_user["id"] != user["id"] %} +
    + + {% if user["is_active"] %} + + + {% else %} + + + {% endif %} +
    + {% endif %} +
  • + {% endfor %} +
+
+
+ + diff --git a/remote_copy/templates/cabinet.html b/remote_copy/templates/cabinet.html new file mode 100644 index 0000000..b5fe7e2 --- /dev/null +++ b/remote_copy/templates/cabinet.html @@ -0,0 +1,84 @@ + + + + + + Личный кабинет + + + +
+
+

Личный кабинет

+

Здесь можно управлять токенами магазинов и текущим пользователем.

+
+ {{ current_user["username"] }} +
+ {% if current_user["is_admin"] %} + Панель администратора + {% endif %} + Вернуться к отзывам +
+
+
+ + {% if error_message %} +
{{ error_message }}
+ {% endif %} + {% if success_message %} +
{{ success_message }}
+ {% endif %} + +
+

Сохранённые магазины

+ {% if tokens %} +
    + {% for token in tokens %} +
  • +
    + {{ token.name }} + {% if current_user["is_admin"] and token.owner %} + ({{ token.owner }}) + {% endif %} + {% if token.id == active_token_id %} + Активен + {% endif %} +
    +
    +
    + + + +
    +
    + + + +
    +
    +
  • + {% endfor %} +
+ {% else %} +

Пока нет сохранённых токенов.

+ {% endif %} +
+ +
+

Добавить магазин

+
+ + + + +
+
+
+ + diff --git a/remote_copy/templates/index.html b/remote_copy/templates/index.html new file mode 100644 index 0000000..156e78c --- /dev/null +++ b/remote_copy/templates/index.html @@ -0,0 +1,196 @@ + + + + + + Отзывы Wildberries + + + +
+
+

Отзывы Wildberries

+

Используйте кнопки ниже, чтобы загрузить свежие отзывы или оставить только неотвеченные.

+
+ Вы вошли как {{ current_user["username"] }} + Выйти +
+
+ + {% if error_message %} +
{{ error_message }}
+ {% endif %} + {% if success_message %} +
{{ success_message }}
+ {% endif %} + + + +
+
+ Выберите оценки: + {% for star in [5,4,3,2,1] %} + + {% endfor %} +
+
+ + + + +
+
+ +
+
+ Автоответ: + {% if auto_reply_enabled %} + Включён + {% else %} + Выключен + {% endif %} +

При включении сервис каждые {{ auto_reply_interval_minutes }} минут проверяет новые отзывы 5★ и 4★ и отвечает случайным текстом из пула.

+
+
+ + +
+
+ +
+

Пулы автоответов

+

Один вариант ответа в каждой строке. Для каждого нового отзыва 5★/4★ текст выбирается случайно.

+
+ + + + + +
+
+ +
+

Журнал автоответов (последние 100)

+ {% if auto_reply_logs %} +
+ + + + + + + + + + + + + {% for log in auto_reply_logs %} + + + + + + + + + {% endfor %} + +
ДатаОценкаТоварПокупательСтатусОтвет
{{ log["created_at"] }}{{ log["rating"] }}★{{ log["product_name"] or "-" }}{{ log["user_name"] or "-" }}{{ "Отправлен" if log["status"] == "sent" else "Ошибка" }}{{ log["reply_text"] }}
+
+ {% else %} +

Пока нет записей автоответа.

+ {% endif %} +
+ + {% if reviews %} +
+ {% if current_filter == 'unanswered' %} +

Неотвеченные отзывы

+ {% else %} +

Все отзывы

+ {% endif %} +

Показано {{ reviews|length }} отзывов. Выбраны оценки: {{ selected_stars_display|join(', ') }}★.

+
+ {% if current_filter == 'unanswered' and has_token %} +
+ + + {% for star in selected_stars_display %} + + {% endfor %} + {% for review in reviews %} + + {% endfor %} +
+ Допустимая длина ответа: 2–5000 символов. + +
+
+ {% endif %} + +
+ {% for review in reviews %} +
+
+
+ {{ review.product_name or 'Без названия' }} + ★ {{ review.rating }} +
+
+ {{ review.user_name or 'Покупатель' }} + {{ review.created_at|format_datetime }} +
+
+ {% if review.text %} +

{{ review.text }}

+ {% endif %} +
+ {% if review.pros %} +
Достоинства
+
{{ review.pros }}
+ {% endif %} + {% if review.cons %} +
Недостатки
+
{{ review.cons }}
+ {% endif %} +
+
+ {% if review.answer %} +

Ответ: {{ review.answer }}

+ {% else %} +

Без ответа

+ {% endif %} +
+ {% if has_token %} +
+ Ответить +
+ + {% for star in selected_stars_display %} + + {% endfor %} + + +
+
+ {% endif %} +
+ {% endfor %} +
+ {% elif current_filter %} +
Отзывы с выбранными оценками ({{ selected_stars_display|join(', ') }}★) не найдены.
+ {% endif %} +
+ + diff --git a/remote_copy/templates/login.html b/remote_copy/templates/login.html new file mode 100644 index 0000000..b8e1c0b --- /dev/null +++ b/remote_copy/templates/login.html @@ -0,0 +1,29 @@ + + + + + + Вход + + + +
+

Вход в систему

+ {% if error_message %} +
{{ error_message }}
+ {% endif %} +
+ + + +
+

Нет аккаунта? Запросить доступ

+
+ + diff --git a/remote_copy/templates/register.html b/remote_copy/templates/register.html new file mode 100644 index 0000000..fa60546 --- /dev/null +++ b/remote_copy/templates/register.html @@ -0,0 +1,36 @@ + + + + + + Регистрация + + + +
+

Запрос доступа

+ {% if error_message %} +
{{ error_message }}
+ {% endif %} + {% if success_message %} +
{{ success_message }}
+ {% endif %} +
+ + + + +
+

Уже есть аккаунт? Войти

+
+ +