From 79768a4faca6275de55c5bc803c2353ce3b4f09e Mon Sep 17 00:00:00 2001 From: ruslan Date: Wed, 6 May 2026 13:28:54 +0300 Subject: [PATCH] first commit --- README.md | 0 context.md | 32 + promt.md | 2 + remote_copy/__pycache__/app.cpython-312.pyc | Bin 0 -> 47110 bytes remote_copy/app.py | 935 +++++++++++++++++++ remote_copy/export_last_answers.py | 61 ++ remote_copy/export_last_answers_container.py | 60 ++ remote_copy/static/styles.css | 339 +++++++ remote_copy/templates/admin.html | 53 ++ remote_copy/templates/cabinet.html | 84 ++ remote_copy/templates/index.html | 196 ++++ remote_copy/templates/login.html | 29 + remote_copy/templates/register.html | 36 + 13 files changed, 1827 insertions(+) create mode 100644 README.md create mode 100644 context.md create mode 100644 promt.md create mode 100644 remote_copy/__pycache__/app.cpython-312.pyc create mode 100644 remote_copy/app.py create mode 100644 remote_copy/export_last_answers.py create mode 100644 remote_copy/export_last_answers_container.py create mode 100644 remote_copy/static/styles.css create mode 100644 remote_copy/templates/admin.html create mode 100644 remote_copy/templates/cabinet.html create mode 100644 remote_copy/templates/index.html create mode 100644 remote_copy/templates/login.html create mode 100644 remote_copy/templates/register.html 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 0000000000000000000000000000000000000000..c61d0a8fff5b84449ef7f7bc8cb385fc4e0de649 GIT binary patch literal 47110 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 "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 %} +
+ + + + +
+

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

+
+ +