From 53f1bb2e71b3098ec1142e41c77b6e67d76b9f9c Mon Sep 17 00:00:00 2001 From: ruslan Date: Fri, 15 May 2026 17:46:35 +0300 Subject: [PATCH] Sync project structure and apply feature updates - Move files from remote_copy/ to root (proper project structure) - Add Docker setup: Dockerfile, docker-compose.yml with volume mounts - Auto-reply: fix skip logic (only text field, not pros/cons) - Auto-reply: fix _paginate to handle cooldown gracefully mid-pagination - Auto-reply: fix fetch timestamp not saved on API error (infinite retry loop) - Auto-reply: reduce EMPTY_REMAINING_FALLBACK from 600s to 150s default - UI: add auto-reply queue display with article number (nm_id) - UI: replace button with CSS tumbler toggle for auto-reply - UI: add review_created_at and review_id columns to journal - UI: add skipped/sent/error status colors to journal Co-Authored-By: Claude Sonnet 4.6 --- Dockerfile | 14 + remote_copy/app.py => app.py | 636 ++++- docker-compose.yml | 16 + ..._last_answers.py => export_last_answers.py | 0 ...ner.py => export_last_answers_container.py | 0 remote_copy/__pycache__/app.cpython-312.pyc | Bin 47110 -> 0 bytes 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 - requirements.txt | 3 + static/styles.css | 1285 +++++++++ static/wb_api.html | 1985 ++++++++++++++ static/wb_user_comm.html | 2346 +++++++++++++++++ templates/admin.html | 111 + templates/cabinet.html | 158 ++ templates/index.html | 444 ++++ templates/login.html | 39 + templates/register.html | 46 + 21 files changed, 7037 insertions(+), 783 deletions(-) create mode 100644 Dockerfile rename remote_copy/app.py => app.py (56%) create mode 100644 docker-compose.yml rename remote_copy/export_last_answers.py => export_last_answers.py (100%) rename remote_copy/export_last_answers_container.py => export_last_answers_container.py (100%) delete mode 100644 remote_copy/__pycache__/app.cpython-312.pyc delete mode 100644 remote_copy/static/styles.css delete mode 100644 remote_copy/templates/admin.html delete mode 100644 remote_copy/templates/cabinet.html delete mode 100644 remote_copy/templates/index.html delete mode 100644 remote_copy/templates/login.html delete mode 100644 remote_copy/templates/register.html create mode 100644 requirements.txt create mode 100644 static/styles.css create mode 100644 static/wb_api.html create mode 100644 static/wb_user_comm.html create mode 100644 templates/admin.html create mode 100644 templates/cabinet.html create mode 100644 templates/index.html create mode 100644 templates/login.html create mode 100644 templates/register.html diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9923456 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.11-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + FLASK_PORT=5000 + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +CMD ["python", "app.py"] diff --git a/remote_copy/app.py b/app.py similarity index 56% rename from remote_copy/app.py rename to app.py index e6c2aad..111044f 100644 --- a/remote_copy/app.py +++ b/app.py @@ -1,17 +1,21 @@ +import logging +import json import os import random import sqlite3 import threading import time from dataclasses import dataclass -from datetime import datetime +from datetime import datetime, timezone from functools import wraps +from logging.handlers import RotatingFileHandler from pathlib import Path from typing import List, Optional, Set, Tuple +from zoneinfo import ZoneInfo import requests from dotenv import load_dotenv -from flask import Flask, g, redirect, render_template, request, session, url_for +from flask import Flask, g, jsonify, redirect, render_template, request, session, url_for from werkzeug.security import check_password_hash, generate_password_hash BASE_DIR = Path(__file__).resolve().parent @@ -24,7 +28,28 @@ 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_QUEUE_KEY = "auto_reply_queue_json" +AUTO_REPLY_LAST_FETCH_KEY = "auto_reply_last_fetch_ts" AUTO_REPLY_INTERVAL_MINUTES = int(os.getenv("AUTO_REPLY_INTERVAL_MINUTES", "10")) +AUTO_REPLY_INTERVAL_SECONDS = int( + os.getenv("AUTO_REPLY_INTERVAL_SECONDS", str(AUTO_REPLY_INTERVAL_MINUTES * 60)) +) +AUTO_REPLY_FETCH_INTERVAL_SECONDS = int( + os.getenv("AUTO_REPLY_FETCH_INTERVAL_SECONDS", "300") +) +AUTO_REPLY_BATCH_LIMIT = max(1, int(os.getenv("AUTO_REPLY_BATCH_LIMIT", "25"))) +API_GLOBAL_COOLDOWN_UNTIL_KEY = "api_global_cooldown_until_ts" +WB_REQUESTS_PER_SECOND = float(os.getenv("WB_REQUESTS_PER_SECOND", "0.8")) +WB_MIN_REQUEST_INTERVAL_SECONDS = max(1.1, 1.0 / max(0.1, WB_REQUESTS_PER_SECOND)) +APP_TIMEZONE = ZoneInfo(os.getenv("APP_TIMEZONE", "Europe/Moscow")) +API_LOG_BODY_MAX = max(200, int(os.getenv("API_LOG_BODY_MAX", "1200"))) +APP_LOG_FILE = os.getenv("APP_LOG_FILE", str(BASE_DIR / "app.log")) +APP_LOG_LEVEL = os.getenv("APP_LOG_LEVEL", "INFO").upper() +UI_FETCH_WHEN_AUTO_ENABLED = os.getenv("UI_FETCH_WHEN_AUTO_ENABLED", "0") == "1" +EMPTY_REMAINING_FALLBACK_COOLDOWN_SECONDS = int( + os.getenv("EMPTY_REMAINING_FALLBACK_COOLDOWN_SECONDS", "60") +) +AUTO_REPLY_LOOP_SLEEP_SECONDS = max(1, int(os.getenv("AUTO_REPLY_LOOP_SLEEP_SECONDS", "1"))) DEFAULT_REPLY_POOL_5 = [ "Спасибо за высокую оценку! Нам очень приятно.", "Благодарим за отзыв и доверие к нашему магазину!", @@ -37,6 +62,44 @@ DEFAULT_REPLY_POOL_4 = [ ] +def _setup_logger() -> logging.Logger: + logger = logging.getLogger("wildberries-app") + if logger.handlers: + return logger + level = getattr(logging, APP_LOG_LEVEL, logging.INFO) + logger.setLevel(level) + formatter = logging.Formatter( + "%(asctime)s %(levelname)s %(name)s %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + file_handler = RotatingFileHandler(APP_LOG_FILE, maxBytes=5_000_000, backupCount=5) + file_handler.setFormatter(formatter) + stream_handler = logging.StreamHandler() + stream_handler.setFormatter(formatter) + logger.addHandler(file_handler) + logger.addHandler(stream_handler) + logger.propagate = False + return logger + + +logger = _setup_logger() + + +def _short_text(value: Optional[str], max_len: int = API_LOG_BODY_MAX) -> str: + text = value or "" + if len(text) <= max_len: + return text + return f"{text[:max_len]}..." + + +def _sanitize_headers(headers: dict) -> dict: + safe = dict(headers) + auth = safe.get("Authorization") + if auth: + safe["Authorization"] = f"***{auth[-6:]}" if len(auth) >= 6 else "***" + return safe + + class FeedbackApiError(RuntimeError): """Raised when the Wildberries Feedback API returns an error.""" @@ -50,6 +113,7 @@ class Review: rating: int created_at: datetime product_name: str + nm_id: int answer: Optional[str] is_answered: bool user_name: str @@ -74,6 +138,7 @@ class Review: rating=payload.get("productValuation") or 0, created_at=created_dt, product_name=product.get("productName") or "", + nm_id=int(product.get("nmId") or 0), answer=answer_value, is_answered=bool(answer_value), user_name=payload.get("userName") or "", @@ -133,6 +198,7 @@ class Database: rating INTEGER NOT NULL, product_name TEXT, user_name TEXT, + review_text TEXT, reply_text TEXT NOT NULL, status TEXT NOT NULL, error_text TEXT @@ -141,6 +207,7 @@ class Database: ) self._ensure_admin(conn) self._ensure_token_user_column(conn) + self._ensure_auto_reply_log_review_text(conn) conn.commit() finally: conn.close() @@ -167,6 +234,16 @@ class Database: (admin_id,), ) + def _ensure_auto_reply_log_review_text(self, conn: sqlite3.Connection) -> None: + info = conn.execute("PRAGMA table_info(auto_reply_logs)").fetchall() + columns = {row["name"] for row in info} + if "review_text" not in columns: + conn.execute("ALTER TABLE auto_reply_logs ADD COLUMN review_text TEXT") + if "nm_id" not in columns: + conn.execute("ALTER TABLE auto_reply_logs ADD COLUMN nm_id INTEGER") + if "review_created_at" not in columns: + conn.execute("ALTER TABLE auto_reply_logs ADD COLUMN review_created_at TEXT") + # User helpers def create_user(self, username: str, password_hash: str) -> None: conn = self._connect() @@ -222,6 +299,17 @@ class Database: finally: conn.close() + def update_token(self, token_id: int, name: str, token: str) -> None: + conn = self._connect() + try: + conn.execute( + "UPDATE tokens SET name = ?, token = ? WHERE id = ?", + (name.strip(), token.strip(), token_id), + ) + conn.commit() + finally: + conn.close() + def fetch_tokens_for_user(self, user_id: int, is_admin: bool) -> List[sqlite3.Row]: conn = self._connect() try: @@ -296,7 +384,10 @@ class Database: review_id: str, rating: int, product_name: str, + nm_id: int = 0, user_name: str, + review_text: str, + review_created_at: str = "", reply_text: str, status: str, error_text: Optional[str] = None, @@ -306,15 +397,18 @@ class Database: conn.execute( """ INSERT INTO auto_reply_logs( - created_at, review_id, rating, product_name, user_name, reply_text, status, error_text - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + created_at, review_id, rating, product_name, nm_id, user_name, review_text, review_created_at, reply_text, status, error_text + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( - datetime.utcnow().isoformat(), + datetime.utcnow().replace(microsecond=0).isoformat(), review_id, rating, product_name, + nm_id, user_name, + review_text, + review_created_at, reply_text, status, error_text, @@ -347,45 +441,148 @@ class FeedbackClient: self.token = token self.page_size = page_size self.timeout = timeout + self._last_request_ts = 0.0 def _get_headers(self) -> dict: if not self.token: raise FeedbackApiError( "Токен Wildberries не найден. Добавьте токен в личном кабинете." ) - return {"Authorization": f"Bearer {self.token}"} + return {"Authorization": self.token} + + def _throttle(self) -> None: + now = time.time() + wait_for = WB_MIN_REQUEST_INTERVAL_SECONDS - (now - self._last_request_ts) + if wait_for > 0: + time.sleep(wait_for) + self._last_request_ts = time.time() + + @staticmethod + def _extract_retry_seconds(response: requests.Response) -> Optional[float]: + for header_name in ("X-Ratelimit-Retry", "Retry-After", "X-Ratelimit-Reset"): + raw = response.headers.get(header_name) + if not raw: + continue + try: + value = float(raw) + except ValueError: + continue + if value >= 0: + return value + return None def _request(self, *, is_answered: bool, skip: int, take: int) -> List[dict]: + _check_api_cooldown_or_raise() 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: + safe_headers = _sanitize_headers(headers) + max_attempts = 4 + response = None + for attempt in range(1, max_attempts + 1): + self._throttle() + logger.info( + "WB API request method=GET url=%s params=%s attempt=%s/%s headers=%s", + self.BASE_URL, + params, + attempt, + max_attempts, + safe_headers, + ) + response = requests.get(self.BASE_URL, params=params, headers=headers, timeout=self.timeout) + logger.info( + "WB API response method=GET url=%s status=%s headers=%s body=%s", + self.BASE_URL, + response.status_code, + dict(response.headers), + _short_text(response.text), + ) + if response.ok: + remaining = response.headers.get("X-Ratelimit-Remaining") + reset_seconds = self._extract_retry_seconds(response) + if remaining == "0": + if reset_seconds is not None and reset_seconds > 0: + _set_api_cooldown(reset_seconds + 30) + else: + _set_api_cooldown(EMPTY_REMAINING_FALLBACK_COOLDOWN_SECONDS) + logger.warning( + "WB API remaining=0 without reset headers. Fallback cooldown=%s sec", + EMPTY_REMAINING_FALLBACK_COOLDOWN_SECONDS, + ) + break + if response.status_code == 429 and attempt < max_attempts: + retry_seconds = self._extract_retry_seconds(response) + if retry_seconds is not None and retry_seconds > 0: + _set_api_cooldown(retry_seconds + 30) + if retry_seconds is not None and retry_seconds > 30: + logger.warning( + "WB API long rate limit on GET. retry_after_seconds=%s. Stop retries for this call.", + retry_seconds, + ) + raise FeedbackApiError( + f"Лимит WB API. Повторите позже (примерно через {int(retry_seconds)} сек)." + ) + delay = retry_seconds if retry_seconds is not None else float(2 ** attempt) + logger.warning( + "WB API rate limit on GET. sleep_seconds=%s x_ratelimit_retry=%s x_ratelimit_reset=%s", + max(1.0, delay), + response.headers.get("X-Ratelimit-Retry"), + response.headers.get("X-Ratelimit-Reset"), + ) + time.sleep(max(1.0, delay)) + continue + if response.status_code == 429: + retry_seconds = self._extract_retry_seconds(response) + if retry_seconds is not None and retry_seconds > 0: + _set_api_cooldown(retry_seconds + 30) raise FeedbackApiError(f"Ошибка запроса: {response.status_code} {response.text}") + if response is None: + raise FeedbackApiError("Не удалось получить ответ от API Wildberries.") payload = response.json() if payload.get("error"): raise FeedbackApiError(payload.get("errorText") or "Не удалось получить отзывы.") data = payload.get("data") or {} return data.get("feedbacks") or [] + def _paginate(self, *, is_answered: bool, max_items: int = 500) -> List[dict]: + result: List[dict] = [] + skip = 0 + take = self.page_size + while True: + try: + page = self._request(is_answered=is_answered, skip=skip, take=take) + except FeedbackApiError: + if not result: + raise + logger.info("_paginate stopping early due to API error, returning %s items collected", len(result)) + break + if not page: + break + result.extend(page) + if len(page) < take: + break + if len(result) >= max_items: + break + skip += len(page) + return result + 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] + raw = self._paginate(is_answered=False, max_items=max(limit * 3, 300)) + reviews = [Review.from_api(item) for item in raw] 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) + raw_unanswered = self._paginate(is_answered=False, max_items=limit) + raw_answered = self._paginate(is_answered=True, max_items=limit) + raw = raw_unanswered + raw_answered + raw.sort(key=lambda r: r.get("createdDate") or "", reverse=True) reviews = [Review.from_api(item) for item in raw] allowed = allowed_ratings or DEFAULT_STARS @@ -398,15 +595,74 @@ class FeedbackClient: self._request(is_answered=False, skip=0, take=1) def send_answer(self, review_id: str, text: str) -> None: + _check_api_cooldown_or_raise() 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}") + max_attempts = 4 + for attempt in range(1, max_attempts + 1): + self._throttle() + safe_headers = _sanitize_headers(self._get_headers()) + logger.info( + "WB API request method=POST url=%s payload=%s attempt=%s/%s headers=%s", + self.ANSWER_URL, + payload, + attempt, + max_attempts, + safe_headers, + ) + response = requests.post( + self.ANSWER_URL, + json=payload, + headers=self._get_headers(), + timeout=self.timeout, + ) + logger.info( + "WB API response method=POST url=%s status=%s headers=%s body=%s", + self.ANSWER_URL, + response.status_code, + dict(response.headers), + _short_text(response.text), + ) + if response.ok: + remaining = response.headers.get("X-Ratelimit-Remaining") + reset_seconds = self._extract_retry_seconds(response) + if remaining == "0": + if reset_seconds is not None and reset_seconds > 0: + _set_api_cooldown(reset_seconds + 30) + else: + _set_api_cooldown(EMPTY_REMAINING_FALLBACK_COOLDOWN_SECONDS) + logger.warning( + "WB API remaining=0 without reset headers. Fallback cooldown=%s sec", + EMPTY_REMAINING_FALLBACK_COOLDOWN_SECONDS, + ) + return + if response.status_code == 429 and attempt < max_attempts: + retry_seconds = self._extract_retry_seconds(response) + if retry_seconds is not None and retry_seconds > 0: + _set_api_cooldown(retry_seconds + 30) + if retry_seconds is not None and retry_seconds > 30: + logger.warning( + "WB API long rate limit on POST. retry_after_seconds=%s. Stop retries for this call.", + retry_seconds, + ) + raise FeedbackApiError( + f"Лимит WB API. Повторите позже (примерно через {int(retry_seconds)} сек)." + ) + delay = retry_seconds if retry_seconds is not None else float(2 ** attempt) + logger.warning( + "WB API rate limit on POST. sleep_seconds=%s x_ratelimit_retry=%s x_ratelimit_reset=%s", + max(1.0, delay), + response.headers.get("X-Ratelimit-Retry"), + response.headers.get("X-Ratelimit-Reset"), + ) + time.sleep(max(1.0, delay)) + continue + if response.status_code == 429: + retry_seconds = self._extract_retry_seconds(response) + if retry_seconds is not None and retry_seconds > 0: + _set_api_cooldown(retry_seconds + 30) + raise FeedbackApiError( + f"Не удалось отправить ответ: {response.status_code} {response.text}" + ) def answer_many(self, review_ids: List[str], text: str) -> int: sent = 0 @@ -505,6 +761,101 @@ def set_auto_reply_enabled(value: bool) -> None: db.set_setting(AUTO_REPLY_SETTING_KEY, "1" if value else "0") +def _get_api_cooldown_seconds_left() -> int: + raw = db.get_setting(API_GLOBAL_COOLDOWN_UNTIL_KEY) + if not raw: + return 0 + try: + until_ts = float(raw) + except ValueError: + return 0 + return max(0, int(until_ts - time.time())) + + +def _set_api_cooldown(seconds: float) -> None: + seconds_int = max(0, int(seconds)) + if seconds_int <= 0: + return + until_ts = time.time() + seconds_int + db.set_setting(API_GLOBAL_COOLDOWN_UNTIL_KEY, str(until_ts)) + logger.warning("Global API cooldown set for %s seconds", seconds_int) + + +def _check_api_cooldown_or_raise() -> None: + seconds_left = _get_api_cooldown_seconds_left() + if seconds_left > 0: + raise FeedbackApiError( + f"Лимит WB API активен. Повторите позже (примерно через {seconds_left} сек)." + ) + + +def _load_auto_reply_queue() -> List[dict]: + raw = db.get_setting(AUTO_REPLY_QUEUE_KEY) + if not raw: + return [] + try: + payload = json.loads(raw) + except (TypeError, ValueError): + return [] + if not isinstance(payload, list): + return [] + queue = [] + for item in payload: + if not isinstance(item, dict): + continue + if not item.get("id"): + continue + queue.append(item) + return queue + + +def _save_auto_reply_queue(queue: List[dict]) -> None: + db.set_setting(AUTO_REPLY_QUEUE_KEY, json.dumps(queue, ensure_ascii=False)) + + +def _build_auto_reply_queue(reviews: List[Review]) -> List[dict]: + queue: List[dict] = [] + for review in reviews: + if _has_review_content(review): + logger.info("Auto-reply skip review_id=%s reason=has_review_text", review.id) + db.add_auto_reply_log( + review_id=review.id, + rating=review.rating, + product_name=review.product_name, + nm_id=review.nm_id, + user_name=review.user_name, + review_text=review.text, + review_created_at=review.created_at.isoformat() if review.created_at else "", + reply_text="", + status="skipped", + error_text="Пропущен: у отзыва есть текст/достоинства/недостатки.", + ) + continue + queue.append( + { + "id": review.id, + "rating": review.rating, + "product_name": review.product_name, + "nm_id": review.nm_id, + "user_name": review.user_name, + "review_text": review.text, + "review_created_at": review.created_at.isoformat() if review.created_at else "", + } + ) + return queue + + +def _fetch_window_open() -> bool: + raw = db.get_setting(AUTO_REPLY_LAST_FETCH_KEY) + if not raw: + return True + try: + last_fetch = float(raw) + except ValueError: + return True + return (time.time() - last_fetch) >= AUTO_REPLY_FETCH_INTERVAL_SECONDS + + def _load_reply_pool(star: int) -> List[str]: if star == 5: db_value = db.get_setting(AUTO_REPLY_POOL_5_KEY) @@ -526,38 +877,116 @@ def _pick_auto_reply(star: int) -> Optional[str]: return None +def _has_review_content(review: Review) -> bool: + return bool((review.text or "").strip()) + + def process_auto_replies() -> int: + if _get_api_cooldown_seconds_left() > 0: + logger.info( + "Auto-reply skipped: global cooldown active for %s seconds", + _get_api_cooldown_seconds_left(), + ) + return 0 token_value = _get_background_token() if not token_value: + logger.info("Auto-reply skipped: background token is missing.") return 0 client = FeedbackClient(token_value) - reviews = client.fetch_reviews(limit=100, unanswered_only=True, allowed_ratings=AUTO_REPLY_STARS) + queue = _load_auto_reply_queue() + if not queue: + if not _fetch_window_open(): + logger.info( + "Auto-reply queue empty, but fetch window is closed. fetch_interval_seconds=%s", + AUTO_REPLY_FETCH_INTERVAL_SECONDS, + ) + return 0 + logger.info("Auto-reply queue is empty. Fetch new reviews once.") + try: + reviews = client.fetch_reviews( + limit=100, + unanswered_only=True, + allowed_ratings=AUTO_REPLY_STARS, + ) + except FeedbackApiError: + db.set_setting(AUTO_REPLY_LAST_FETCH_KEY, str(time.time())) + raise + db.set_setting(AUTO_REPLY_LAST_FETCH_KEY, str(time.time())) + queue = _build_auto_reply_queue(reviews) + _save_auto_reply_queue(queue) + logger.info("Auto-reply queue rebuilt size=%s", len(queue)) + cooldown_after_fetch = _get_api_cooldown_seconds_left() + if cooldown_after_fetch > 0: + logger.info("Auto-reply stop after fetch: cooldown active for %s seconds", cooldown_after_fetch) + return 0 + + if not queue: + logger.info("Auto-reply queue has no eligible reviews.") + return 0 + + logger.info( + "Auto-reply cycle start queue_size=%s batch_limit=%s", + len(queue), + AUTO_REPLY_BATCH_LIMIT, + ) sent = 0 - for review in reviews: - reply_text = _pick_auto_reply(review.rating) + processed = 0 + while queue and processed < AUTO_REPLY_BATCH_LIMIT: + item = queue[0] + review_id = item.get("id", "") + rating = int(item.get("rating") or 0) + reply_text = _pick_auto_reply(rating) if not reply_text: + logger.info("Auto-reply skip review_id=%s reason=no_pool_reply rating=%s", review_id, rating) + queue.pop(0) + processed += 1 continue try: - client.send_answer(review.id, reply_text) + client.send_answer(review_id, reply_text) + logger.info("Auto-reply sent review_id=%s rating=%s", review_id, rating) db.add_auto_reply_log( - review_id=review.id, - rating=review.rating, - product_name=review.product_name, - user_name=review.user_name, + review_id=review_id, + rating=rating, + product_name=item.get("product_name", ""), + nm_id=int(item.get("nm_id") or 0), + review_created_at=item.get("review_created_at", ""), + user_name=item.get("user_name", ""), + review_text=item.get("review_text", ""), reply_text=reply_text, status="sent", ) sent += 1 + queue.pop(0) + processed += 1 except FeedbackApiError as exc: + exc_str = str(exc) + is_rate_limit = ( + "Лимит WB API" in exc_str + or "429" in exc_str + or "cooldown" in exc_str.lower() + or "rate limit" in exc_str.lower() + or "too many" in exc_str.lower() + ) + if is_rate_limit: + logger.info("Auto-reply rate limit, keeping queue head review_id=%s", review_id) + break + logger.warning("Auto-reply failed review_id=%s rating=%s error=%s", review_id, rating, exc) db.add_auto_reply_log( - review_id=review.id, - rating=review.rating, - product_name=review.product_name, - user_name=review.user_name, + review_id=review_id, + rating=rating, + product_name=item.get("product_name", ""), + nm_id=int(item.get("nm_id") or 0), + review_created_at=item.get("review_created_at", ""), + user_name=item.get("user_name", ""), + review_text=item.get("review_text", ""), reply_text=reply_text, status="failed", error_text=str(exc), ) + queue.pop(0) + processed += 1 + _save_auto_reply_queue(queue) + logger.info("Auto-reply cycle finished sent_count=%s", sent) return sent @@ -568,7 +997,21 @@ def _scheduler_should_run(last_run_raw: Optional[str], now_ts: float) -> bool: last_run = float(last_run_raw) except ValueError: return True - return now_ts - last_run >= AUTO_REPLY_INTERVAL_MINUTES * 60 + return now_ts - last_run >= AUTO_REPLY_INTERVAL_SECONDS + + +def _next_auto_reply_meta() -> Tuple[Optional[datetime], Optional[int]]: + last_run_raw = db.get_setting(AUTO_REPLY_LAST_RUN_KEY) + if not last_run_raw: + return None, None + try: + last_run = float(last_run_raw) + except ValueError: + return None, None + next_ts = last_run + AUTO_REPLY_INTERVAL_SECONDS + seconds_left = max(0, int(next_ts - time.time())) + next_dt = datetime.fromtimestamp(next_ts, tz=timezone.utc).astimezone(APP_TIMEZONE) + return next_dt, seconds_left def auto_reply_loop() -> None: @@ -578,18 +1021,39 @@ def auto_reply_loop() -> None: 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() + logger.info("Auto-reply loop trigger start last_run=%s now_ts=%s", last_run, now_ts) db.set_setting(AUTO_REPLY_LAST_RUN_KEY, str(now_ts)) + process_auto_replies() + else: + logger.info("Auto-reply loop skip schedule last_run=%s now_ts=%s", last_run, now_ts) + else: + logger.info("Auto-reply loop skip: disabled") except Exception: - pass - time.sleep(60) + logger.exception("Auto-reply loop crashed") + time.sleep(AUTO_REPLY_LOOP_SLEEP_SECONDS) @app.template_filter("format_datetime") def format_datetime(value: Optional[datetime]) -> str: if not value: return "" - return value.strftime("%d.%m.%Y %H:%M") + dt = value + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(APP_TIMEZONE).strftime("%d.%m.%Y %H:%M") + + +@app.template_filter("format_log_datetime") +def format_log_datetime(value: Optional[str]) -> str: + if not value: + return "" + try: + dt = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return value + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(APP_TIMEZONE).strftime("%d.%m.%Y %H:%M") @app.before_request @@ -603,6 +1067,26 @@ def load_current_user() -> None: else: session.pop("user_id", None) session.pop("token_id", None) + logger.info( + "HTTP request method=%s path=%s query=%s remote=%s user_id=%s", + request.method, + request.path, + request.query_string.decode("utf-8", errors="ignore"), + request.remote_addr, + g.user["id"] if g.user else None, + ) + + +@app.after_request +def log_response(response): + logger.info( + "HTTP response method=%s path=%s status=%s length=%s", + request.method, + request.path, + response.status_code, + response.calculate_content_length(), + ) + return response def login_required(view): @@ -634,6 +1118,7 @@ def cabinet(): status = request.args.get("status") status_map = { "added": "Токен сохранён.", + "updated": "Токен обновлён.", "selected": "Активирован выбранный магазин.", "checked": "Токен успешно проверен.", } @@ -650,6 +1135,18 @@ def cabinet(): else: db.add_token(user_id=user["id"], name=name, token=token_value) return redirect(url_for("cabinet", status="added")) + elif action == "edit": + token_id = request.form.get("token_id") + token_row = db.get_token(int(token_id)) if token_id else None + name = (request.form.get("name") or "").strip() + token_value = (request.form.get("token") or "").strip() + if not token_row or not (user["is_admin"] or token_row["user_id"] == user["id"]): + error_message = "Недостаточно прав для изменения токена." + elif not name or not token_value: + error_message = "Введите название и токен." + else: + db.update_token(token_id=token_row["id"], name=name, token=token_value) + return redirect(url_for("cabinet", status="updated")) elif action == "select": token_id = request.form.get("token_id") if token_id: @@ -671,7 +1168,13 @@ def cabinet(): 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"]} + { + "id": row["id"], + "name": row["name"], + "token": row["token"], + "owner": row["owner"], + "user_id": row["user_id"], + } for row in raw_tokens ] active_token_id = session.get("token_id") @@ -693,6 +1196,8 @@ def index(): selected_stars_list = _parse_selected_stars(request.args.getlist("stars")) selected_stars = set(selected_stars_list) selected_stars_display = sorted(selected_stars, reverse=True) + next_auto_reply_at, next_auto_reply_in_seconds = _next_auto_reply_meta() + api_cooldown_seconds_left = _get_api_cooldown_seconds_left() active_token_value, active_token_name = _get_active_token() client: Optional[FeedbackClient] = None client_error: Optional[str] = None @@ -706,7 +1211,12 @@ def index(): success_message: Optional[str] = None if action in {"all", "unanswered"}: - if client: + if is_auto_reply_enabled() and not UI_FETCH_WHEN_AUTO_ENABLED: + error_message = ( + "Выгрузка отзывов временно отключена при активном автоответе, " + "чтобы не расходовать лимит API. Отключите автоответ или включите UI_FETCH_WHEN_AUTO_ENABLED=1." + ) + elif client: try: if action == "all": reviews = client.fetch_reviews( @@ -751,13 +1261,34 @@ def index(): current_action=action or "all", auto_reply_enabled=is_auto_reply_enabled(), auto_reply_interval_minutes=AUTO_REPLY_INTERVAL_MINUTES, + auto_reply_interval_seconds=AUTO_REPLY_INTERVAL_SECONDS, + next_auto_reply_at=next_auto_reply_at, + next_auto_reply_in_seconds=next_auto_reply_in_seconds, + api_cooldown_seconds_left=api_cooldown_seconds_left, reply_pool_5_text=_pool_to_multiline_text(_load_reply_pool(5)), reply_pool_4_text=_pool_to_multiline_text(_load_reply_pool(4)), + reply_pool_5_list=_load_reply_pool(5), + reply_pool_4_list=_load_reply_pool(4), + auto_reply_queue=_load_auto_reply_queue(), auto_reply_logs=db.list_auto_reply_logs(limit=100), current_user=g.user, ) +@app.route("/yandex_b847b9b35f967fcc.html") +def yandex_verify(): + return 'Verification: b847b9b35f967fcc', 200, {"Content-Type": "text/html; charset=UTF-8"} + + +@app.route("/api/status") +@login_required +def api_status(): + logs = db.list_auto_reply_logs(limit=1) + last_id = logs[0]["id"] if logs else None + cooldown = _get_api_cooldown_seconds_left() + return jsonify({"last_log_id": last_id, "cooldown": cooldown}) + + @app.route("/auto-reply-toggle", methods=["POST"]) @login_required def auto_reply_toggle(): @@ -769,11 +1300,12 @@ def auto_reply_toggle(): @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, []) + pool_5 = [item.strip() for item in request.form.getlist("pool_5_item") if item.strip()] + pool_4 = [item.strip() for item in request.form.getlist("pool_4_item") if item.strip()] + is_ajax = request.headers.get("X-Requested-With") == "XMLHttpRequest" if not pool_5 or not pool_4: + if is_ajax: + return jsonify({"ok": False, "error": "Для 5★ и 4★ укажите минимум по одному варианту ответа."}), 400 return redirect( url_for( "index", @@ -785,6 +1317,8 @@ def auto_reply_pools(): ) 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)) + if is_ajax: + return jsonify({"ok": True}) return redirect(url_for("index", status="pools_saved", action="unanswered", stars=[5, 4])) @@ -928,6 +1462,16 @@ def admin_panel(): if __name__ == "__main__": + logger.info( + "App start debug=%s port=%s req_per_sec=%s batch_limit=%s interval_min=%s timezone=%s log_file=%s", + True, + os.getenv("FLASK_PORT", "5000"), + WB_REQUESTS_PER_SECOND, + AUTO_REPLY_BATCH_LIMIT, + AUTO_REPLY_INTERVAL_MINUTES, + APP_TIMEZONE, + APP_LOG_FILE, + ) 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() diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0dd0422 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,16 @@ +version: "3.9" + +services: + app: + build: . + env_file: + - .env + environment: + - FLASK_PORT=5000 + ports: + - "54119:5000" + volumes: + - ./tokens.db:/app/tokens.db + - ./app.py:/app/app.py + - ./static:/app/static + - ./templates:/app/templates diff --git a/remote_copy/export_last_answers.py b/export_last_answers.py similarity index 100% rename from remote_copy/export_last_answers.py rename to export_last_answers.py diff --git a/remote_copy/export_last_answers_container.py b/export_last_answers_container.py similarity index 100% rename from remote_copy/export_last_answers_container.py rename to export_last_answers_container.py diff --git a/remote_copy/__pycache__/app.cpython-312.pyc b/remote_copy/__pycache__/app.cpython-312.pyc deleted file mode 100644 index c61d0a8fff5b84449ef7f7bc8cb385fc4e0de649..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 47110 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 - - - - - Панель администратора - - - -

-
-

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

-

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

- ← Вернуться в кабинет -
- {% 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 deleted file mode 100644 index b5fe7e2..0000000 --- a/remote_copy/templates/cabinet.html +++ /dev/null @@ -1,84 +0,0 @@ - - - - - - Личный кабинет - - - -
-
-

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

-

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

-
- {{ 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 deleted file mode 100644 index 156e78c..0000000 --- a/remote_copy/templates/index.html +++ /dev/null @@ -1,196 +0,0 @@ - - - - - - Отзывы 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 deleted file mode 100644 index b8e1c0b..0000000 --- a/remote_copy/templates/login.html +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - Вход - - - -
-

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

- {% if error_message %} -
{{ error_message }}
- {% endif %} -
- - - -
-

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

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

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

- {% if error_message %} -
{{ error_message }}
- {% endif %} - {% if success_message %} -
{{ success_message }}
- {% endif %} -
- - - - -
-

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

-
- - diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..16b646c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +Flask==3.0.3 +python-dotenv==1.0.1 +requests==2.32.3 diff --git a/static/styles.css b/static/styles.css new file mode 100644 index 0000000..2db0ae2 --- /dev/null +++ b/static/styles.css @@ -0,0 +1,1285 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap'); + +/* ─── Design tokens ──────────────────────────────────────────────── */ +:root { + --wb: #CB11AB; + --wb-dark: #A00D87; + --wb-glow: rgba(203,17,171,0.2); + --wb-light: rgba(203,17,171,0.08); + + --blue: #4F46E5; + --blue-dark: #3730A3; + --blue-light: rgba(79,70,229,0.08); + --blue-glow: rgba(79,70,229,0.22); + + --green: #059669; + --green-bg: #ECFDF5; + --green-line: #6EE7B7; + + --red: #DC2626; + --red-bg: #FEF2F2; + --red-line: #FECACA; + + --amber: #D97706; + --amber-bg: #FFFBEB; + --amber-line: #FDE68A; + + --star5: #F59E0B; + --star4: #84CC16; + --star3: #F97316; + --star2: #EF4444; + --star1: #DC2626; + + --bg: #F3F4F8; + --card: #FFFFFF; + --text: #111827; + --muted: #6B7280; + --subtle: #9CA3AF; + --line: #E5E7EB; + --line-strong: #D1D5DB; + + --shadow-xs: 0 1px 2px rgba(0,0,0,0.05); + --shadow-sm: 0 2px 8px rgba(0,0,0,0.07); + --shadow: 0 4px 20px rgba(0,0,0,0.09); + --shadow-md: 0 8px 32px rgba(0,0,0,0.10); + --shadow-lg: 0 24px 64px rgba(0,0,0,0.13); + + --r-xs: 6px; + --r-sm: 10px; + --r: 14px; + --r-lg: 20px; + --r-xl: 28px; + --r-full: 9999px; + + --t: 0.18s ease; + + font-family: 'Inter', system-ui, -apple-system, sans-serif; + color: var(--text); +} + +/* ─── Reset ──────────────────────────────────────────────────────── */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +/* ─── Body ───────────────────────────────────────────────────────── */ +body { + min-height: 100vh; + background: + radial-gradient(ellipse 1000px 600px at -5% -10%, rgba(203,17,171,0.07), transparent), + radial-gradient(ellipse 800px 500px at 105% 105%, rgba(79,70,229,0.08), transparent), + var(--bg); + background-attachment: fixed; + line-height: 1.6; +} + +/* ─── Typography ─────────────────────────────────────────────────── */ +h1 { font-size: 26px; font-weight: 800; letter-spacing: -0.5px; line-height: 1.2; } +h2 { font-size: 17px; font-weight: 700; letter-spacing: -0.2px; } +h3 { font-size: 15px; font-weight: 600; } + +p { color: var(--muted); } +a { color: var(--blue); text-decoration: none; transition: color var(--t); } +a:hover { color: var(--blue-dark); text-decoration: underline; } + +/* ─── Sticky topbar ──────────────────────────────────────────────── */ +.topbar { + position: sticky; + top: 0; + z-index: 200; + background: rgba(255,255,255,0.86); + backdrop-filter: blur(18px) saturate(180%); + -webkit-backdrop-filter: blur(18px) saturate(180%); + border-bottom: 1px solid rgba(229,231,235,0.7); + box-shadow: 0 1px 16px rgba(0,0,0,0.06); +} + +.topbar__inner { + max-width: 1100px; + margin: 0 auto; + padding: 0 24px; + height: 60px; + display: flex; + align-items: center; + gap: 8px; +} + +.topbar__brand { + display: flex; + align-items: center; + gap: 10px; + text-decoration: none; + flex-shrink: 0; + margin-right: 8px; +} + +.topbar__brand:hover { text-decoration: none; } + +.topbar__logo { + width: 34px; + height: 34px; + border-radius: var(--r-sm); + background: linear-gradient(135deg, #D914B5 0%, var(--wb-dark) 100%); + display: flex; + align-items: center; + justify-content: center; + color: white; + font-size: 12px; + font-weight: 800; + letter-spacing: 0.02em; + box-shadow: 0 4px 14px var(--wb-glow); + flex-shrink: 0; +} + +.topbar__name { + font-size: 15px; + font-weight: 700; + color: var(--text); + letter-spacing: -0.2px; +} + +.topbar__nav { + display: flex; + align-items: center; + gap: 2px; + flex: 1; +} + +.topbar__link { + padding: 6px 14px; + border-radius: var(--r-full); + font-size: 14px; + font-weight: 500; + color: var(--muted); + transition: all var(--t); + text-decoration: none !important; +} + +.topbar__link:hover { + color: var(--text); + background: var(--line); +} + +.topbar__link.active { + color: var(--blue); + background: var(--blue-light); + font-weight: 600; +} + +.topbar__user { + display: flex; + align-items: center; + gap: 10px; + flex-shrink: 0; + margin-left: auto; +} + +.topbar__username { + font-size: 14px; + font-weight: 500; + color: var(--muted); +} + +.btn-ghost { + display: inline-flex; + align-items: center; + padding: 6px 14px; + border-radius: var(--r-sm); + font-size: 14px; + font-weight: 500; + color: var(--muted); + background: transparent; + border: 1.5px solid var(--line); + cursor: pointer; + transition: all var(--t); + text-decoration: none !important; + font-family: inherit; +} + +.btn-ghost:hover { + background: var(--line); + color: var(--text); + border-color: var(--line-strong); +} + +/* ─── Page ───────────────────────────────────────────────────────── */ +.page { + max-width: 1100px; + margin: 0 auto; + padding: 28px 24px 56px; +} + +.page-header { + margin-bottom: 24px; +} + +.page-header p { + margin-top: 5px; + font-size: 14px; +} + +/* ─── Buttons ────────────────────────────────────────────────────── */ +button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + border: none; + border-radius: var(--r-sm); + padding: 9px 18px; + font-size: 14px; + font-weight: 600; + font-family: inherit; + cursor: pointer; + transition: all var(--t); + white-space: nowrap; + line-height: 1; + background: linear-gradient(180deg, #5D53F5 0%, var(--blue-dark) 100%); + color: #fff; + box-shadow: 0 4px 14px var(--blue-glow); +} + +button:hover { + transform: translateY(-1px); + box-shadow: 0 6px 22px var(--blue-glow); + filter: brightness(1.05); +} + +button:active { + transform: translateY(0); + filter: brightness(0.98); +} + +button.secondary { + background: var(--line); + color: var(--text); + box-shadow: none; +} + +button.secondary:hover { + background: var(--line-strong); + box-shadow: none; + filter: none; +} + +button.toggle-btn--on { + background: var(--green-bg); + color: var(--green); + border: 2px solid var(--green-line); + box-shadow: none; +} + +button.toggle-btn--on:hover { + background: var(--green); + color: white; + box-shadow: 0 4px 16px rgba(5,150,105,0.3); + filter: none; +} + +button.toggle-btn--off { + background: var(--line); + color: var(--muted); + border: 2px solid var(--line-strong); + box-shadow: none; +} + +button.toggle-btn--off:hover { + background: var(--blue); + color: white; + border-color: var(--blue); + box-shadow: 0 4px 16px var(--blue-glow); + filter: none; +} + +/* ─── Alerts ─────────────────────────────────────────────────────── */ +.alert { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 12px 16px; + border-radius: var(--r); + font-size: 14px; + margin-bottom: 16px; + border: 1px solid var(--amber-line); + background: var(--amber-bg); + color: var(--amber); +} + +.alert-error { + background: var(--red-bg); + border-color: var(--red-line); + color: var(--red); +} + +.alert-success { + background: var(--green-bg); + border-color: var(--green-line); + color: var(--green); +} + +/* ─── Badges ─────────────────────────────────────────────────────── */ +.badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 10px; + border-radius: var(--r-full); + font-size: 12px; + font-weight: 600; + background: var(--blue-light); + color: var(--blue); +} + +.badge-green { + background: var(--green-bg); + color: var(--green); +} + +.badge-inactive { + background: var(--red-bg); + color: var(--red); +} + +.badge-admin { + background: var(--wb-light); + color: var(--wb-dark); +} + +/* ─── Card ───────────────────────────────────────────────────────── */ +.card { + background: var(--card); + border: 1px solid var(--line); + border-radius: var(--r-lg); + padding: 20px 22px; + margin-bottom: 16px; + box-shadow: var(--shadow-sm); + transition: box-shadow var(--t); +} + +/* ─── Controls / star filter ─────────────────────────────────────── */ +.controls { + margin-bottom: 20px; +} + +.controls-card { + background: var(--card); + border: 1px solid var(--line); + border-radius: var(--r-lg); + padding: 18px 22px; + box-shadow: var(--shadow-sm); +} + +.star-filter { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + margin-bottom: 14px; +} + +.star-filter-label { + font-size: 12px; + font-weight: 700; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.07em; + margin-right: 4px; +} + +.star-pill input[type="checkbox"] { display: none; } + +.star-pill label { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 6px 14px; + border-radius: var(--r-full); + border: 1.5px solid var(--line); + background: #FAFBFC; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all var(--t); + user-select: none; + color: var(--muted); +} + +.star-pill label:hover { + border-color: var(--line-strong); + background: var(--card); + color: var(--text); +} + +.star-pill[data-star="5"] input:checked + label { border-color: var(--star5); background: rgba(245,158,11,0.09); color: #92400E; font-weight: 600; } +.star-pill[data-star="4"] input:checked + label { border-color: var(--star4); background: rgba(132,204,22,0.09); color: #3F6212; font-weight: 600; } +.star-pill[data-star="3"] input:checked + label { border-color: var(--star3); background: rgba(249,115,22,0.09); color: #9A3412; font-weight: 600; } +.star-pill[data-star="2"] input:checked + label { border-color: var(--star2); background: rgba(239,68,68,0.09); color: #991B1B; font-weight: 600; } +.star-pill[data-star="1"] input:checked + label { border-color: var(--star1); background: rgba(220,38,38,0.09); color: #7F1D1D; font-weight: 600; } + +.control-buttons { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +/* ─── Auto-reply section ─────────────────────────────────────────── */ +.auto-reply { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 20px; + flex-wrap: wrap; +} + +.auto-reply__info { flex: 1; } + +.auto-reply__title { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 6px; +} + +.auto-reply__info p { + font-size: 13px; + margin: 0; +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + display: inline-block; +} + +.status-dot--green { + background: var(--green); + box-shadow: 0 0 0 0 rgba(5,150,105,0.5); + animation: pulse-green 2s infinite; +} + +.status-dot--gray { background: var(--subtle); } + +@keyframes pulse-green { + 0% { box-shadow: 0 0 0 0 rgba(5,150,105,0.5); } + 70% { box-shadow: 0 0 0 6px rgba(5,150,105,0); } + 100% { box-shadow: 0 0 0 0 rgba(5,150,105,0); } +} + +.countdown-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 12px; + background: var(--blue-light); + border-radius: var(--r-full); + font-size: 12px; + font-weight: 600; + color: var(--blue); + margin-top: 8px; +} + +/* ─── Auto-reply pools ───────────────────────────────────────────── */ +.pool-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + margin-bottom: 14px; +} + +.pool-block { display: flex; flex-direction: column; gap: 7px; } + +.pool-items { + display: flex; + flex-direction: column; + gap: 6px; +} + +.pool-item { + display: grid; + grid-template-columns: 1fr auto; + gap: 6px; + align-items: start; +} + +.pool-item textarea { + padding: 7px 10px; + font-size: 13px; + resize: vertical; + min-height: 58px; + line-height: 1.4; +} + +.pool-item .pool-remove-btn { + min-width: 72px; + padding: 6px 10px; + font-size: 12px; + border-radius: 8px; +} + +.pool-add-btn { + align-self: flex-start; + padding: 6px 10px; + font-size: 12px; + border-radius: 8px; +} + +.pool-actions { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.pool-save-status { + font-size: 12px; + color: var(--muted); +} + +.pool-save-status.ok { color: var(--green); } +.pool-save-status.warn { color: var(--amber); } +.pool-save-status.err { color: var(--red); } + +.pool-label { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + font-weight: 600; + color: var(--text); +} + +.star-chip { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: var(--r-full); + font-size: 12px; + font-weight: 700; +} + +.star-chip--5 { background: rgba(245,158,11,0.12); color: #92400E; } +.star-chip--4 { background: rgba(132,204,22,0.12); color: #3F6212; } + +/* ─── Form inputs ────────────────────────────────────────────────── */ +label { + display: flex; + flex-direction: column; + gap: 5px; + font-size: 14px; + font-weight: 500; + color: var(--text); +} + +input[type="text"], +input[type="password"], +textarea { + width: 100%; + padding: 10px 14px; + border-radius: var(--r-sm); + border: 1.5px solid var(--line); + font-family: inherit; + font-size: 14px; + color: var(--text); + background: #FAFBFC; + transition: border-color var(--t), box-shadow var(--t), background var(--t); + outline: none; + resize: vertical; + line-height: 1.5; +} + +input[type="text"]:focus, +input[type="password"]:focus, +textarea:focus { + border-color: var(--blue); + background: white; + box-shadow: 0 0 0 3px rgba(79,70,229,0.12); +} + +/* ─── Logs table ─────────────────────────────────────────────────── */ +.logs-table-wrap { + overflow-x: auto; + border-radius: var(--r-sm); + border: 1px solid var(--line); +} + +.logs-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +.logs-table thead tr { + background: #F9FAFB; +} + +.logs-table th { + padding: 10px 14px; + text-align: left; + font-size: 11px; + font-weight: 700; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.07em; + border-bottom: 1px solid var(--line); + white-space: nowrap; +} + +.logs-table td { + padding: 10px 14px; + border-bottom: 1px solid #F3F4F6; + vertical-align: top; + color: var(--text); +} + +.logs-table tbody tr:hover { background: rgba(79,70,229,0.02); } +.logs-table tbody tr:last-child td { border-bottom: none; } + +.log-status--sent { color: var(--green); font-weight: 600; } +.log-status--skip { color: var(--amber); font-weight: 600; } +.log-status--error { color: var(--red); font-weight: 600; } + +/* ─── Review cards ───────────────────────────────────────────────── */ +.reviews { display: flex; flex-direction: column; gap: 12px; } + +.review { + background: var(--card); + border: 1px solid var(--line); + border-radius: var(--r-lg); + padding: 18px 20px; + box-shadow: var(--shadow-xs); + border-left: 4px solid var(--line); + transition: box-shadow var(--t), transform var(--t); +} + +.review:hover { + box-shadow: var(--shadow); + transform: translateY(-1px); +} + +.review[data-rating="5"] { border-left-color: var(--star5); } +.review[data-rating="4"] { border-left-color: var(--star4); } +.review[data-rating="3"] { border-left-color: var(--star3); } +.review[data-rating="2"] { border-left-color: var(--star2); } +.review[data-rating="1"] { border-left-color: var(--star1); } + +.review__header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 16px; + margin-bottom: 10px; +} + +.review__product { + font-size: 15px; + font-weight: 600; + color: var(--text); +} + +.rating { + display: inline-flex; + align-items: center; + gap: 3px; + padding: 2px 9px; + border-radius: var(--r-full); + font-size: 13px; + font-weight: 700; + margin-left: 8px; + flex-shrink: 0; +} + +.rating--5 { background: rgba(245,158,11,0.12); color: #92400E; } +.rating--4 { background: rgba(132,204,22,0.12); color: #3F6212; } +.rating--3 { background: rgba(249,115,22,0.1); color: #9A3412; } +.rating--2 { background: rgba(239,68,68,0.1); color: #991B1B; } +.rating--1 { background: rgba(220,38,38,0.1); color: #7F1D1D; } + +.review__meta { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 3px; + font-size: 13px; + color: var(--muted); + flex-shrink: 0; +} + +.review__text { + font-size: 14px; + color: var(--text); + line-height: 1.65; + margin-bottom: 10px; +} + +.details { margin: 0 0 12px; } +.details dt { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.07em; color: var(--subtle); margin-top: 8px; } +.details dd { font-size: 14px; color: var(--text); margin: 2px 0 0; } + +.answer { + padding: 10px 14px; + background: rgba(79,70,229,0.05); + border-left: 3px solid var(--blue); + border-radius: 0 var(--r-sm) var(--r-sm) 0; + font-size: 14px; + margin-bottom: 10px; + color: var(--text); +} + +.answer strong { color: var(--blue); } + +.no-answer { + font-size: 13px; + color: var(--subtle); + font-style: italic; + margin-bottom: 10px; +} + +.inline-reply { margin-top: 4px; } + +.inline-reply summary { + cursor: pointer; + color: var(--blue); + font-size: 14px; + font-weight: 600; + padding: 6px 0; + user-select: none; + list-style: none; + display: inline-flex; + align-items: center; + gap: 6px; +} + +.inline-reply summary::after { + content: '›'; + font-size: 16px; + transition: transform var(--t); +} + +.inline-reply[open] summary::after { transform: rotate(90deg); } + +.inline-reply summary:hover { color: var(--blue-dark); } + +.inline-reply form { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 10px; + padding-top: 12px; + border-top: 1px solid var(--line); +} + +/* ─── Reply form ─────────────────────────────────────────────────── */ +.reply-form { + background: var(--card); + border: 1px solid var(--line); + border-radius: var(--r-lg); + padding: 20px 22px; + margin-bottom: 16px; + box-shadow: var(--shadow-sm); + display: flex; + flex-direction: column; + gap: 12px; +} + +.reply-form > label { + font-weight: 700; + font-size: 15px; + color: var(--text); +} + +.reply-form textarea { min-height: 100px; } + +.reply-form__actions { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.reply-form__actions span { + font-size: 13px; + color: var(--muted); +} + +/* ─── Summary ────────────────────────────────────────────────────── */ +.summary { margin: 20px 0 12px; } +.summary h2 { margin-bottom: 4px; } +.summary p { font-size: 14px; } + +/* ─── Cabinet ────────────────────────────────────────────────────── */ +.cabinet-section { + background: var(--card); + border: 1px solid var(--line); + border-radius: var(--r-lg); + padding: 20px 22px; + margin-bottom: 16px; + box-shadow: var(--shadow-sm); +} + +.cabinet-section h2 { margin-bottom: 16px; } + +.cabinet-form { + display: flex; + flex-direction: column; + gap: 12px; +} + +.token-list { list-style: none; padding: 0; margin: 0; } + +.token-item { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 20px; + padding: 16px 0; + border-bottom: 1px solid var(--line); +} + +.token-item:last-child { border-bottom: none; } + +.token-main { flex: 1; min-width: 0; } + +.token-name { + font-size: 15px; + font-weight: 600; + color: var(--text); + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + margin-bottom: 2px; +} + +.token-owner { font-size: 13px; color: var(--muted); font-weight: 400; } + +.token-edit-form { + margin-top: 14px; + padding-top: 14px; + border-top: 1px dashed var(--line-strong); + display: flex; + flex-direction: column; + gap: 10px; +} + +.token-actions { + display: flex; + flex-direction: column; + gap: 6px; + flex-shrink: 0; +} + +.token-actions button { font-size: 13px; padding: 7px 14px; } + +/* ─── Auth ───────────────────────────────────────────────────────── */ +.auth-shell { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; +} + +.auth-card { + width: 100%; + max-width: 440px; + background: rgba(255,255,255,0.93); + border: 1px solid rgba(229,231,235,0.9); + border-radius: var(--r-xl); + padding: 36px; + box-shadow: var(--shadow-lg); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); +} + +.auth-kicker { + display: inline-flex; + align-items: center; + gap: 10px; + margin-bottom: 20px; +} + +.auth-kicker-logo { + width: 36px; + height: 36px; + border-radius: var(--r-sm); + background: linear-gradient(135deg, #D914B5, var(--wb-dark)); + display: flex; + align-items: center; + justify-content: center; + color: white; + font-size: 13px; + font-weight: 800; + box-shadow: 0 4px 14px var(--wb-glow); +} + +.auth-kicker-text { + font-size: 14px; + font-weight: 700; + color: var(--wb-dark); + letter-spacing: 0.02em; +} + +.auth-card h1 { margin-bottom: 6px; } + +.auth-subtitle { + font-size: 14px; + color: var(--muted); + margin-bottom: 26px; + line-height: 1.55; +} + +.auth-form { + display: flex; + flex-direction: column; + gap: 14px; +} + +.auth-form button { + width: 100%; + margin-top: 6px; + padding: 12px; + font-size: 15px; + border-radius: var(--r); +} + +.auth-footer { + margin-top: 20px; + font-size: 14px; + color: var(--muted); + text-align: center; +} + +/* ─── Utility ────────────────────────────────────────────────────── */ +.hint { font-size: 14px; color: var(--muted); } +.inline-form { margin: 0; } +.cabinet-link { display: none; } + +.section-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 16px; +} + +.section-header h2 { margin: 0; } + +.empty-state { + text-align: center; + padding: 32px 16px; + color: var(--muted); + font-size: 14px; +} + +/* ─── Mobile ─────────────────────────────────────────────────────── */ +@media (max-width: 768px) { + .page { padding: 16px 16px 48px; } + h1 { font-size: 22px; } + h2 { font-size: 16px; } + + .topbar__nav { display: none; } + .topbar__username { display: none; } + + .pool-grid { grid-template-columns: 1fr; } + .pool-item { grid-template-columns: 1fr; } + .pool-item .pool-remove-btn { width: 100%; } + .pool-add-btn { width: 100%; } + + .review__header { flex-direction: column; gap: 8px; } + .review__meta { align-items: flex-start; flex-direction: row; flex-wrap: wrap; gap: 8px; } + + .auto-reply { flex-direction: column; } + .token-item { flex-direction: column; } + .token-actions { flex-direction: row; width: 100%; } + + .control-buttons { flex-direction: column; } + .control-buttons button { width: 100%; } + + .reply-form__actions { flex-direction: column; align-items: flex-start; } + + .logs-table { font-size: 12px; min-width: 700px; } + .logs-table th, .logs-table td { padding: 8px 10px; } + + .auth-shell { align-items: flex-start; padding-top: 16px; } + .auth-card { padding: 24px 20px; border-radius: var(--r-lg); } +} + +/* ─── Stats bar ──────────────────────────────────────────────────── */ +.stats-bar { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 16px; +} + +.stat-chip { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 7px 16px; + border-radius: var(--r-full); + background: var(--card); + border: 1px solid var(--line); + box-shadow: var(--shadow-xs); + font-size: 13px; + font-weight: 500; + color: var(--muted); +} + +.stat-value { + font-weight: 700; + font-size: 15px; + color: var(--text); +} + +.stat-chip--warn { + border-color: var(--red-line); + background: var(--red-bg); +} +.stat-chip--warn .stat-value { color: var(--red); } + +.stat-chip--ok { + border-color: var(--green-line); + background: var(--green-bg); +} +.stat-chip--ok .stat-value { color: var(--green); } + +/* ─── Bottom nav (mobile only) ───────────────────────────────────── */ +.bottom-nav { + display: none; + position: fixed; + bottom: 0; + left: 0; + right: 0; + z-index: 300; + background: rgba(255,255,255,0.92); + backdrop-filter: blur(18px) saturate(180%); + -webkit-backdrop-filter: blur(18px) saturate(180%); + border-top: 1px solid rgba(229,231,235,0.8); + box-shadow: 0 -4px 24px rgba(0,0,0,0.08); + padding: 6px 8px env(safe-area-inset-bottom, 6px); + justify-content: space-around; + align-items: stretch; +} + +.bottom-nav__item { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + flex: 1; + padding: 6px 4px; + border-radius: var(--r); + color: var(--subtle); + text-decoration: none !important; + transition: all var(--t); + font-size: 11px; + font-weight: 500; + position: relative; +} + +.bottom-nav__item:hover { + color: var(--muted); + background: var(--line); + text-decoration: none; +} + +.bottom-nav__item.active { + color: var(--blue); + background: var(--blue-light); +} + +.bottom-nav__item svg { + width: 22px; + height: 22px; + flex-shrink: 0; +} + +.bottom-nav__badge { + position: absolute; + top: 4px; + right: calc(50% - 18px); + min-width: 16px; + height: 16px; + padding: 0 4px; + border-radius: var(--r-full); + background: var(--red); + color: white; + font-size: 10px; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + line-height: 1; +} + +@media (max-width: 768px) { + .bottom-nav { display: flex; } + .page { padding-bottom: 88px !important; } + .auth-shell { padding-bottom: 16px; } +} + +/* ─── Pool item editor ───────────────────────────────────────────── */ +.pool-items { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 8px; +} + +.pool-item { + display: flex; + gap: 8px; + align-items: center; +} + +.pool-item-input { + flex: 1; + padding: 9px 12px; + border: 1.5px solid var(--line); + border-radius: var(--r-sm); + font-size: 14px; + font-family: inherit; + background: #FAFBFC; + color: var(--text); + transition: border-color var(--t), box-shadow var(--t); + outline: none; + width: 100%; +} + +.pool-item-input:focus { + border-color: var(--blue); + background: white; + box-shadow: 0 0 0 3px rgba(79,70,229,0.12); +} + +.btn-delete-item { + width: 32px; + height: 32px; + padding: 0; + border-radius: var(--r-sm); + background: var(--red-bg); + color: var(--red); + border: 1px solid var(--red-line); + font-size: 14px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + box-shadow: none; + cursor: pointer; + line-height: 1; + font-family: inherit; +} + +.btn-delete-item:hover { + background: var(--red); + color: white; + border-color: var(--red); + transform: none; + box-shadow: none; + filter: none; +} + +.btn-add-item { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 14px; + border-radius: var(--r-sm); + background: transparent; + color: var(--muted); + border: 1.5px dashed var(--line-strong); + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all var(--t); + box-shadow: none; + font-family: inherit; + margin-top: 2px; + width: 100%; + justify-content: center; +} + +.btn-add-item:hover { + background: var(--blue-light); + color: var(--blue); + border-color: var(--blue); + border-style: solid; + transform: none; + box-shadow: none; + filter: none; +} + +/* ── Тумблер автоответа ─────────────────────────────────── */ +.tumbler { + display: inline-flex; + align-items: center; + cursor: pointer; + user-select: none; +} +.tumbler__input { + position: absolute; + width: 0; + height: 0; + opacity: 0; +} +.tumbler__track { + position: relative; + display: inline-block; + width: 52px; + height: 28px; + background: var(--c-border); + border-radius: 14px; + transition: background var(--t); + flex-shrink: 0; +} +.tumbler__thumb { + position: absolute; + top: 3px; + left: 3px; + width: 22px; + height: 22px; + background: #fff; + border-radius: 50%; + transition: transform var(--t); + box-shadow: 0 1px 4px rgba(0,0,0,.25); +} +.tumbler__input:checked ~ .tumbler__track { + background: #CB11AB; +} +.tumbler__input:checked ~ .tumbler__track .tumbler__thumb { + transform: translateX(24px); +} +.tumbler__track:hover { + filter: brightness(0.92); +} + +/* ── Тег артикула ───────────────────────────────────────── */ +.tag-article { + display: inline-block; + margin-left: 5px; + padding: 1px 6px; + background: #EEF2FF; + color: #4338CA; + border-radius: 4px; + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.01em; + white-space: nowrap; +} + +/* ── Статус пропущен ────────────────────────────────────── */ +.log-status--skip { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 99px; + font-size: 0.78rem; + font-weight: 600; + background: #F3F4F6; + color: #6B7280; + white-space: nowrap; +} diff --git a/static/wb_api.html b/static/wb_api.html new file mode 100644 index 0000000..217d25e --- /dev/null +++ b/static/wb_api.html @@ -0,0 +1,1985 @@ +Документация — WB API
Поиск
+ +

Общее (general)

Общее

Введение

Wildberries API предоставляет продавцам инструменты для управления магазином и получения оперативной и статистической информации по протоколу HTTP REST API.

+

Главное преимущество API — возможность автоматизировать процессы за счет интеграции с информационными системами продавца: например, ERP, WMS, OMS, CRM. С WB API продавец может не управлять магазином через интерфейс сайта вручную.

+

Использование API для работы с магазином на Wildberries — это отличный способ:

+
    +
  • автоматизировать рутинные процессы
  • +
  • получить доступ к актуальной информации
  • +
  • оптимизировать управление ассортиментом
  • +
+

Документация API предоставлена в формате Swagger OpenAPI и может быть использована для импорта в другие инструменты — например, Postman — или генерации клиентского кода на различных языках программирования с помощью Swagger CodeGen.

+

Для ручного тестирования API вы можете использовать:

+
    +
  • Под ОС Windows — PostMan
  • +
  • Под ОС Linux — curl
  • +
+

Как начать работу с API

    +
  1. Зарегистрируйтесь в личном кабинете Продавца.
  2. +
  3. Перейдите в настройки магазина и создайте API-токен. Токен позволит получить доступ к WB API. Система токенов позволит вам контролировать, кто и как взаимодействует с вашими данными через API.
  4. +
  5. Разработайте интеграцию с API с помощью собственных разработчиков или внешних специалистов. Вы также можете подключить партнёрские сервисы из нашего каталога решений для бизнеса.
  6. +
+
+ Используйте метод проверки подключения, чтобы узнать, успешно ли доходят запросы до API и правильно ли настроен API-токен. +
+ +

Практические советы:

+
    +
  • Используйте документацию.
    Официальная документация WB API поможет разобраться в функциональности и возможностях API. В ней приводятся примеры возможных запросов и ответов, список возможных ошибок, лимиты запросов, правила безопасности и так далее.
  • +
  • Регулярно проверяйте работу интеграции.
    Следите, корректно ли вы передаете данные и какие вы получаете ответы, чтобы вовремя дорабатывать интеграцию. Не забывайте про ограничения и учитывайте лимиты на количество запросов.
  • +
  • Храните API-токен в безопасности.
    Не передавайте его третьим лицам без необходимости. Используйте только доверенные сервисы. При обнаружении подозрительной активности немедленно удалите и замените токен.
  • +
  • При необходимости обращайтесь в техническую поддержку.
  • +
  • Следите за новостями и изменениями WB API в: +
  • +
+

Поддержка

Техническая поддержка происходит через диалоги в личном кабинете продавца. При создании нового обращения в техподдержку используйте категорию API.

+

Статус-коды HTTP

Основные статус-коды ответов на запросы в WB API:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
КодОписаниеКак решить
200Успешно
204Удалено/Обновлено/Добавлено
400Неправильный запросПроверьте синтаксис запроса
401Пользователь не авторизованПроверьте токен авторизации. Категория токена должна совпадать с категорией API. Также токен может:
• быть просрочен
• быть некорректным
• отсутствовать в запросе
403Доступ запрещёнТокен не должен быть сгенерирован удалённым пользователем. Доступ к методу не должен быть заблокирован. Если вы хотите использовать методы Джема, проверьте подписку в личном кабинете
404Не найденоПроверьте URL запроса
409Ошибка сохранения для части ссылок/обновления статуса/добавления сборочного задания/т.д.Проверьте данные запроса. Они должны отвечать требованиям и ограничениям сервиса
413Превышен лимит объёма данных в запросеУменьшите количество объектов в запросе
422Отсутствие в запросе параметра nmId/Размер ставки не изменен/т.д.Проверьте данные запроса. Данные запроса не должны противоречить друг другу
429Слишком много запросовПроверьте лимиты запросов и повторите запрос позже
5ХХВнутренние ошибки сервисаСервис недоступен. Повторите запрос позже или обратитесь в техническую поддержку
+
+ Обращайте внимание на поле details в ошибках 404 и 429 — туда мы добавляем полезную информацию по использованию методов +
+ +

Пример ошибки:

+
{
+  "title": "path not found",
+  "detail": "Please consult the https://dev.wildberries.ru/openapi/api-information",
+  ...
+  "status": 404,
+  "statusText": "Not Found",
+  "timestamp": "2025-04-24T07:25:28Z"
+}
+
+

Лимиты запросов

В WB API есть ограничения на скорость отправки запросов. Для равномерного распределения нагрузки используется алгоритм token bucket. Лимиты для конкретных методов API указаны в документации.

+

Например:

+
+Лимит запросов на один аккаунт продавца для всех методов категории Маркетплейс: + + + + + + + + + + + + + + + + +
ПериодЛимитИнтервалВсплеск
1 минута300 запросов200 миллисекунд20 запросов
+

Один запрос с кодом ответа 409 учитывается как 5 запросов

+
+ +
    +
  • Период — временной интервал, в течение которого можно отправить максимальное количество запросов по лимиту.
  • +
  • Лимит — максимальное количество запросов за период. В примере за одну минуту можно отправить до 300 запросов. Запросы должны быть равномерно распределены по времени.
  • +
  • Интервал — промежуток времени для пауз между запросами. В примере должен составлять 60 секунд/300 запросов200 миллисекунд или 0.2 секунды. Используйте интервал, чтобы равномерно распределить отправку запросов.
  • +
  • Всплескburst, максимальное количество запросов, которое можно отправить одновременно, без интервальных пауз. Допустимый всплеск также возвращается в ответе в заголовке X-Ratelimit-Remaining. Он встречается во всех статусах ответов, кроме ошибки 429.
  • +
+

X-Ratelimit-Remaining — это количество запросов, которое можно выполнить на данный момент без пауз. Значение X-Ratelimit-Remaining уменьшается на единицу после каждого запроса. Если X-Ratelimit-Remaining равен 0 и вы сделали следующий запрос без задержки, в ответ вы получите ошибку 429. Значение X-Ratelimit-Remaining восстанавливается со временем.

+
+ Есть случаи, когда один запрос может быть равен нескольким. Например, если вы отправляете запросы категории Маркетплейс, запрос с ошибкой 409 будет равен 10 запросам с другими статусами. Тогда значение X-Ratelimit-Remaining снизится сразу на 10 единиц. +
+ +

Если вы превысите скорость выполнения запросов, вы получите ошибку 429. В этом случае следующий запрос вы сможете сделать только после небольшого ожидания. Чтобы понять, сколько времени вам нужно ждать, используйте заголовки ответа 429:

+
    +
  • X-Ratelimit-Retry — через сколько секунд вы можете повторить запрос. Если вы сделаете попытку раньше, вы все равно будете получать ошибку 429.
  • +
  • X-Ratelimit-Limit — максимальное значение всплеска запросов — burst, которое будет восстановлено через X-Ratelimit-Reset секунд.
  • +
  • X-Ratelimit-Reset — через сколько секунд допустимый всплеск запросов восстановится до максимального значения, указанного в X-Ratelimit-Limit.
  • +
+

Пример ответа:

+
HTTP/1.1 429 Too Many Requests
+...
+X-Ratelimit-Reset: 29
+X-Ratelimit-Retry: 2
+...
+X-Ratelimit-Limit: 10
+
+

Авторизация

Чтобы авторизоваться в API, вам понадобится токен. Он действует 180 дней после создания. Добавляйте токен в заголовок запроса Authorization.

+
+ По пункту 9.9.6 оферты запрещена интеграция с порталом продавца без WB API +
+ +

Правила использования токенов доступа к API

+ Подробнее о токенах можно узнать в инструкции в справочном центре +
+ +

Для авторизации доступно четыре типа токенов:

+

Персональный токен

+
    +
  • Назначение: Эксклюзивный токен с расширенными возможностями. Предназначен для того, чтобы предоставлять доступ к данным продавца только вашим собственным программам — в том числе корпоративным системам (on-premise), развёрнутым на собственной или арендованной инфраструктуре.
    Расширенные возможности означают, что со временем по персональному токену появится доступ к дополнительным категориям данных продавца, которые недоступны в базовом токене. Мы заранее расскажем об этом в новостях.
  • +
  • Подходит для:
      +
    • собственных программных продуктов или систем компании, размещённых на своих или арендованных серверах
    • +
    • готовых ERP/CRM-систем в редакции on-premise, включая локальные (коробочные) версии 1С, развёрнутые на серверах компании или на компьютерах пользователей
    • +
    +
  • +
  • Ограничения: Персональный токен даёт доступ к чувствительной информации, поэтому его нельзя передавать третьим лицам или использовать в облачных сервисах. При создании токена система покажет предупреждение об ответственности — его нужно принять, чтобы продолжить.
    Настройки токена вы выбираете самостоятельно. Если не уверены, какие параметры указать, уточните у разработчика или IT-специалиста, который отвечает за подключаемую систему.
  • +
+

Сервисный токен

+
    +
  • Назначение: Специальный токен для подключения конкретного облачного сервиса из официального Каталога готовых решений для бизнеса на Wildberries.
  • +
  • Особенности: При создании токена вы выбираете сервис из Каталога, после чего все необходимые настройки, в том числе категории данных и уровни доступа, заполняются автоматически. Вам останется только подтвердить создание токена и передать его сервису.
  • +
  • Ограничения: Привязан к конкретному сервису и работает только с ним.
  • +
+

Базовый токен

+
    +
  • Назначение: Вспомогательный токен, который предоставляет доступ к ограниченному набору данных продавца и используется во всех случаях, когда не подходит сервисный или персональный токен.
  • +
  • Подходит для:
      +
    • тестирования интеграции на реальных данных перед запуском
    • +
    • остальных случаев, когда вы не можете использовать сервисный или персональный токен
    • +
    +
  • +
  • Ограничения: Можно работать с ограниченным набором данных.
  • +
+

Тестовый токен

+
    +
  • Назначение: Специальный токен для безопасного тестирования и отладки интеграций в изолированной среде — песочнице WB API.
  • +
  • Подходит для:
      +
    • разработки и отладки интеграций без риска для реальных данных
    • +
    • изучения возможностей API и экспериментов с методами
    • +
    • проверки новых функций перед запуском в рабочей среде
    • +
    +
  • +
  • Ограничения: Тестовый токен работает только с песочницей и даёт доступ к сгенерированным тестовым данным. Реальные данные магазина при этом недоступны. |
  • +
+

Как создать персональный, базовый или тестовый токен

+ Подробнее о создании Сервисного токена можно узнать в инструкции в справочном центре +
+ +
    +
  1. В личном кабинете перейдите в раздел Интеграции по API.
  2. +
  3. Нажмите + Создать токен. Откроется окно создания токена с двумя вкладками. Для всех типов токенов, кроме сервисного, выберите вкладку Для интеграции вручную.
  4. +
  5. Выберите тип токена.
  6. +
  7. Для базового и персонального токенов:
  8. +
+
    +
  • Заполните название токена
  • +
  • Выберите, с какими категориями API вы будете работать
  • +
  • Задайте уровень доступа к данным: Чтение и запись или Только чтение
  • +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
КатегорияМетоды
КонтентКатегории, предметы и характеристики
Создание карточек товаров
Карточки товаров
Медиафайлы
Ярлыки
АналитикаВоронка продаж
Поисковые запросы по вашим товарам
История остатков
Аналитика продавца CSV
Отчёт об остатках на складах
Отчёт о товарах с обязательной маркировкой
Отчёты об удержаниях
Платная приёмка
Платное хранение
Продажи по регионам
Доля бренда в продажах
Скрытые товары
Отчёт о возвратах и перемещении товаров
Цены и скидкиЦены и скидки
Календарь акций
МаркетплейсЗаказы FBS
Склады продавца
Остатки
Заказы DBS
Самовывоз
СтатистикаОсновные отчёты
Финансовые отчёты
ПродвижениеКампании
Создание кампаний
Управление кампаниями
Параметры кампаний
Финансы
Медиа
Статистика
Вопросы и отзывыВопросы
Отзывы
Закреплённые отзывы
Чат с покупателямиЧат с покупателями
ПоставкиПоставки FBW
ВозвратыВозвраты покупателями
ДокументыДокументы
ФинансыБаланс
ПользователиУправление пользователями продавца
+
+ Выбирайте только те категории, с которыми вы планируете работать. Например, если вы будете только загружать карточки товаров, выберите одну категорию — Контент. Если токен попадёт в чужие руки, по нему нельзя будет получить доступ к другим категориям API вашего магазина. +
+ +
    +
  1. Добавьте комментарий к токену по желанию. Для персонального токена отметьте чекбокс Я понимаю, что не следует передавать токен третьим лицам.
  2. +
  3. Нажмите Создать. Появится окно с вашим токеном.
  4. +
  5. Нажмите кнопку Скопировать и закрыть — окно закроется, токен будет скопирован в буфер обмена. После этого токен нельзя будет посмотреть в личном кабинете.
  6. +
  7. Сохраните токен в безопасном месте. Если вы потеряли токен, создайте новый.
  8. +
+
+ Если у вас несколько сервисов (интеграций) для работы с разными категориями, создайте для них отдельные токены. Это позволит предоставить доступ только к необходимым категориям, а также более гибко и безопасно управлять интеграциями. +

Как устроен токен

Токен представляет собой JWT согласно RFC 7519. Чтобы проверить, действителен ли ваш токен и какие категории методов API по нему доступны, вы можете декодировать его.

+
+ Рекомендуем не просматривать токен с помощью внешних онлайн-инструментов, чтобы он не попал в чужие руки +
+ +

Поля токенов

+

Тип токена можно определить по списку полей из payload токена после декодирования:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ТокенЗначение accЗначение forЗначение t
Базовый токен1Поле отсутствуетfalse
Тестовый токен2Поле отсутствуетtrue
Персональный токен3selffalse
Сервисный токен4asid:{ID сервиса}false
+

Другие поля:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ПолеТипОписание
idUUIDv4Уникальный ID токена
suintБитовая маска свойств токена
sidUUIDv4Уникальный ID продавца на Wildberries, которому принадлежит токен
expuintВремя жизни токена. Соответствует стандарту RFC 7519: JSON Web Token (JWT)
+

Остальные поля payload служебные и могут быть удалены.

+

Поле s

+

Поле s — это битовая маска, то есть целое число, каждый бит которого означает наличие или отсутствие какого-то свойства.

+

Подробнее про битовую маску +

+Значения бит

+

Позиция бита отсчитывается от 0, где 0 — это младший бит.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Позиция битаСвойство (если бит равен 1)
1Доступ к категории Контент
2Доступ к категории Аналитика
3Доступ к категории Цены и скидки
4Доступ к категории Маркетплейс
5Доступ к категории Статистика
6Доступ к категории Продвижение
7Доступ к категории Вопросы и отзывы
9Доступ к категории Чат с покупателями
10Доступ к категории Поставки
11Доступ к категории Возвраты покупателями
12Доступ к категории Документы
13Доступ к категории Финансы
16Доступ к категории Пользователи
30Токен только на чтение
+

Декодирование токена

Декодирование токена позволяет проверить, является ли токен действительным и к каким категориям методов API имеется доступ. Вы можете декодировать токен на отдельной странице портала разработчиков.

+

Проверка подключения к WB API

+ +

Проверить подключение можно с токеном любой категории

+
+

Проверка подключения{{ /ping }}

Описание метода
+ +

Метод проверяет:

+
    +
  1. Успешно ли запрос доходит до WB API
  2. +
  3. Валидность токена авторизации и URL запроса
  4. +
  5. Совпадают ли категория токена и сервис
  6. +
+
+ Метод не предназначен для проверки доступности сервисов WB +
+ +

У каждого сервиса есть свой вариант метода в зависимости от домена:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
КатегорияURL запроса
Контентhttps://content-api.wildberries.ru/ping
https://content-api-sandbox.wildberries.ru/ping
Аналитикаhttps://seller-analytics-api.wildberries.ru/ping
Цены и скидкиhttps://discounts-prices-api.wildberries.ru/ping
https://discounts-prices-api-sandbox.wildberries.ru/ping
Маркетплейсhttps://marketplace-api.wildberries.ru/ping
Статистикаhttps://statistics-api.wildberries.ru/ping
https://statistics-api-sandbox.wildberries.ru/ping
Продвижениеhttps://advert-api.wildberries.ru/ping
https://advert-api-sandbox.wildberries.ru/ping
Вопросы и отзывыhttps://feedbacks-api.wildberries.ru/ping
https://feedbacks-api-sandbox.wildberries.ru/ping
Чат с покупателямиhttps://buyer-chat-api.wildberries.ru/ping
Поставкиhttps://supplies-api.wildberries.ru/ping
Возвраты покупателямиhttps://returns-api.wildberries.ru/ping
Документыhttps://documents-api.wildberries.ru/ping
Финансыhttps://finance-api.wildberries.ru/ping
Тарифы, Новости, Информация о продавцеhttps://common-api.wildberries.ru/ping
Управление пользователями продавцаhttps://user-management-api.wildberries.ru/ping
+
+ Максимум 3 запроса за 30 секунд. Если попытаться автоматизировать использование метода, запросы будут временно заблокированы. Лимит действует отдельно для каждого варианта метода в зависимости от домена +
+
Authorizations:
HeaderApiKey

Responses

Response samples

Content type
application/json
{
  • "TS": "2024-08-16T11:19:05+03:00",
  • "Status": "OK"
}

API новостей

+ + + +

Новости портала продавцов можно получить с токеном любой категории

+
+

Получение новостей портала продавцов{{ /api/communications/v2/news }}

Описание метода
+ +

Метод позволяет получать новости портала продавцов.
Для получения успешного ответа необходимо указать +один из параметров from или fromID.
За один запрос можно получить не более 100 новостей.

+
+Лимит запросов на один аккаунт продавца: + + + + + + + + + + + + + + + + +
ПериодЛимитИнтервалВсплеск
1 минута1 запрос1 минута10 запросов
+
+
Authorizations:
HeaderApiKey
query Parameters
from
string <date>
Example: from=2025-02-06

Дата, от которой необходимо выдать новости

+
fromID
integer <uint64>
Example: fromID=7369

ID новости, начиная с которой — включая её — нужно получить список новостей

+

Responses

Response samples

Content type
application/json
{
  • "data": [
    ]
}

Информация о продавце

+ + +

Информацию о продавце можно получить с токеном любой категории

+
+

Получение информации о продавце{{ /api/v1/seller-info }}

Описание метода
+ +

Метод позволяет получать наименование продавца и ID его профиля. +
В запросе можно использовать любой токен, у которого не выбрана опция Тестовый контур.

+
+Лимит запросов на один аккаунт продавца: + + + + + + + + + + + + + + + + +
ПериодЛимитИнтервалВсплеск
1 минута1 запрос1 минута10 запросов
+
+
Authorizations:
HeaderApiKey

Responses

Response samples

Content type
application/json
{
  • "name": "ИП Кружинин В. Р.",
  • "sid": "e8923014-e233-47q8-898e-3cc86d67ea61",
  • "tradeMark": "Flax Store"
}

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

+ Для доступа к методам используйте Персональный токен для категории Пользователи +
+ +

С помощью этих методов вы можете:

+ +

Управлять доступом пользователей можно только с токеном активного владельца профиля продавца.

+
+ Данная категория методов доступна только продавцам из Российской Федерации +
+

Создать приглашение для нового пользователя{{ /api/v1/invite }}

Описание метода
+ +

Метод создаёт приглашение для нового пользователя с настройкой доступов к разделам профиля продавца.
+Как выдаются права доступа:

+
    +
  • Если access пустой ([]) или не указан — по умолчанию выдаются все доступы, кроме доступов к витрине (showcase) и Джем (changeJam)
  • +
  • Если в access указана часть разделов профиля, то кроме тех доступов, что указаны в запросе, также выдаются все доступы по умолчанию
  • +
  • Если в access перечислены все возможные разделы, доступы будут выданы согласно запросу, без доступов по умолчанию
  • +
  • Если в access дважды указан один и тот же раздел (code):
      +
    • при разных значениях disabled (true и false) доступ не будет выдан
    • +
    • при одинаковых значениях "disabled": true доступ не будет выдан
    • +
    • при одинаковых значениях "disabled": false доступ будет выдан
    • +
    +
  • +
+
+Лимит запросов на один аккаунт продавца: + + + + + + + + + + + + + + + + +
ПериодЛимитИнтервалВсплеск
1 секунда1 запрос1 секунда5 запросов
+
+
Authorizations:
HeaderApiKey
Request Body schema: application/json
required
Array of objects (Access)

Настройки доступа к разделам профиля продавца

+
required
object

Responses

Request samples

Content type
application/json
{
  • "access": [
    ],
  • "invite": {
    }
}

Response samples

Content type
application/json
{}

Получить список активных или приглашённых пользователей продавца{{ /api/v1/users }}

Описание метода
+ +

Метод возвращает список активных или приглашённых пользователей профиля продавца.

+Чтобы выбрать список, укажите значение параметра isInviteOnly:

+
    +
  • isInviteOnly=true — список приглашённых пользователей, которые ещё не активировали доступ
  • +
  • isInviteOnly=false или не указан — список активных пользователей
  • +
+

По каждому пользователю можно получить:

+
    +
  • роль пользователя
  • +
  • разделы, к которым есть доступы
  • +
  • статус приглашения
  • +
+

Список приглашённых пользователей в ответе всегда отсортирован по дате создания: от новых до старых.

+
+Лимит запросов на один аккаунт продавца: + + + + + + + + + + + + + + + + +
ПериодЛимитИнтервалВсплеск
1 секунда1 запрос1 секунда5 запросов
+
+
Authorizations:
HeaderApiKey
query Parameters
limit
integer <int64> <= 100
Default: 100

Количество активных или приглашённых пользователей в ответе

+
offset
integer <int64>
Default: 0

Сколько элементов пропустить. Например, для значения 10 ответ начнется с 11 элемента

+
isInviteOnly
boolean
Default: false
    +
  • true — список приглашённых пользователей, которые ещё не активировали доступ
  • +
  • false или не указан — список активных пользователей профиля продавца
  • +
+

Responses

Response samples

Content type
application/json
Example

Список приглашённых пользователей

+
{
  • "total": 2,
  • "countInResponse": 2,
  • "users": [
    ]
}

Изменить права доступа пользователей{{ /api/v1/users/access }}

Описание метода
+ +

Метод меняет права доступа одному или нескольким пользователям.
+
+Обновляются только права доступа, переданные в параметрах запроса. Остальные поля остаются без изменений.

+
+Лимит запросов на один аккаунт продавца: + + + + + + + + + + + + + + + + +
ПериодЛимитИнтервалВсплеск
1 секунда1 запрос1 секунда5 запросов
+
+
Authorizations:
HeaderApiKey
Request Body schema: application/json
required
required
Array of objects (UserAccess)

Настройки доступа для пользователя

+

Responses

Request samples

Content type
application/json
{
  • "usersAccesses": [
    ]
}

Response samples

Content type
application/json
{
  • "title": "Bad Request",
  • "status": 400,
  • "detail": "bad request cause: user is not in current supplier",
  • "requestId": "c479c04d0b576a9ba0b20fdf235004c2",
  • "origin": "public-acl"
}

Удалить пользователя{{ /api/v1/user }}

Описание метода
+ +

Метод удаляет пользователя из списка сотрудников продавца. Этому пользователю будет закрыт доступ в профиль продавца.

+
+Лимит запросов на один аккаунт продавца: + + + + + + + + + + + + + + + + +
ПериодЛимитИнтервалВсплеск
1 секунда1 запрос1 секунда10 запросов
+
+
Authorizations:
HeaderApiKey
query Parameters
deletedUserID
required
integer <int64>

ID пользователя, которому будет закрыт доступ

+

Responses

Response samples

Content type
application/json
{
  • "title": "Bad Request",
  • "status": 400,
  • "detail": "bad request cause: user is not in current supplier",
  • "requestId": "c479c04d0b576a9ba0b20fdf235004c2",
  • "origin": "public-acl"
}
+ + + + +
\ No newline at end of file diff --git a/static/wb_user_comm.html b/static/wb_user_comm.html new file mode 100644 index 0000000..33f2eaa --- /dev/null +++ b/static/wb_user_comm.html @@ -0,0 +1,2346 @@ +Документация — WB API
Поиск
+ +

Общение с покупателями (communication)

+ Узнать больше об общении с покупателями можно в справочном центре +
+ +

С помощью методов общения с покупателями вы можете работать с:

+
    +
  1. Вопросами и отзывами покупателей
  2. +
  3. Закреплёнными отзывами
  4. +
  5. Чатами с покупателями
  6. +
  7. Заявками покупателей на возврат
  8. +
+

Общение с покупателями

+ +
+ Узнать больше об общении с покупателями можно в справочном центре +
+ +

С помощью методов общения с покупателями вы можете работать с:

+
    +
  1. Вопросами и отзывами покупателей
  2. +
  3. Закреплёнными отзывами
  4. +
  5. Чатами с покупателями
  6. +
  7. Заявками покупателей на возврат
  8. +
+

Вопросы

+ Для доступа к методам используйте токен для категории Вопросы и отзывы +
+ +
+ Узнать больше о вопросах можно в справочном центре +
+ +

Методы для получения вопросов:

+
    +
  1. Непросмотренные отзывы и вопросы
  2. +
  3. Неотвеченные вопросы
  4. +
  5. Количество вопросов
  6. +
  7. Список вопросов
  8. +
+

Вы можете получить один вопрос по его ID и работать с полученными вопросами.

+

Непросмотренные отзывы и вопросы{{ /api/v1/new-feedbacks-questions }}

Описание метода
+ +

Метод проверяет наличие непросмотренных вопросов и отзывов от покупателей. Если у продавца есть непросмотренные вопросы или отзывы, возвращает true.

+
+Лимит запросов на один аккаунт продавца для всех методов категории Вопросы и отзывы: + + + + + + + + + + + + + + + + +
ПериодЛимитИнтервалВсплеск
1 секунда3 запроса333 миллисекунды6 запросов
+
+
Authorizations:
HeaderApiKey

Responses

Response samples

Content type
application/json
{
  • "data": {
    },
  • "error": false,
  • "errorText": "",
  • "additionalErrors": null
}

Неотвеченные вопросы{{ /api/v1/questions/count-unanswered }}

Описание метода
+ +

Метод возвращает общее количество неотвеченных вопросов и количество неотвеченных вопросов за сегодня.

+
+Лимит запросов на один аккаунт продавца для всех методов категории Вопросы и отзывы: + + + + + + + + + + + + + + + + +
ПериодЛимитИнтервалВсплеск
1 секунда3 запроса333 миллисекунды6 запросов
+
+
Authorizations:
HeaderApiKey

Responses

Response samples

Content type
application/json
{
  • "data": {
    },
  • "error": false,
  • "errorText": "",
  • "additionalErrors": null
}

Количество вопросов{{ /api/v1/questions/count }}

Описание метода
+ +

Метод возвращает количество отвеченных или неотвеченных вопросов за заданный период.

+
+Лимит запросов на один аккаунт продавца для всех методов категории Вопросы и отзывы: + + + + + + + + + + + + + + + + +
ПериодЛимитИнтервалВсплеск
1 секунда3 запроса333 миллисекунды6 запросов
+
+
Authorizations:
HeaderApiKey
query Parameters
dateFrom
integer
Example: dateFrom=1688465092

Дата начала периода в формате Unix timestamp

+
dateTo
integer
Example: dateTo=1688465092

Дата конца периода в формате Unix timestamp

+
isAnswered
boolean
Example: isAnswered=false

Есть ли ответ на вопрос:

+
    +
  • true — да, по умолчанию
  • +
  • false — нет
  • +
+

Responses

Response samples

Content type
application/json
{
  • "data": 77,
  • "error": false,
  • "errorText": "",
  • "additionalErrors": null
}

Список вопросов{{ /api/v1/questions }}

Описание метода
+ +

Метод возвращает список вопросов по заданным фильтрам. Вы можете:

+
    +
  • получить данные отвеченных и неотвеченных вопросов
  • +
  • сортировать вопросы по дате
  • +
  • настроить пагинацию и количество вопросов в ответе
  • +
+
+ Можно получить максимум 10 000 вопросов в одном ответе +
+ +
+Лимит запросов на один аккаунт продавца для всех методов категории Вопросы и отзывы: + + + + + + + + + + + + + + + + +
ПериодЛимитИнтервалВсплеск
1 секунда3 запроса333 миллисекунды6 запросов
+
+
Authorizations:
HeaderApiKey
query Parameters
isAnswered
required
boolean

Есть ли ответ на вопрос:

+
    +
  • true — да, по умолчанию
  • +
  • false — нет
  • +
+
nmId
integer

Артикул WB

+
take
required
integer

Количество запрашиваемых вопросов (максимально допустимое значение для параметра - 10 000, +при этом сумма значений параметров take и skip не должна превышать 10 000)

+
skip
required
integer

Количество вопросов для пропуска (максимально допустимое значение для параметра - 10 000, +при этом сумма значений параметров take и skip не должна превышать 10 000)

+
order
string

Сортировка вопросов по дате (dateAsc/dateDesc)

+
dateFrom
integer
Example: dateFrom=1688465092

Дата начала периода в формате Unix timestamp

+
dateTo
integer
Example: dateTo=1688465092

Дата конца периода в формате Unix timestamp

+

Responses

Response samples

Content type
application/json
{
  • "data": {
    },
  • "error": false,
  • "errorText": "",
  • "additionalErrors": null
}

Работа с вопросами{{ /api/v1/questions }}

Описание метода
+ +

В зависимости от тела запроса, метод позволяет:

+
    +
  • отметить вопрос как просмотренный
  • +
  • отклонить вопрос
  • +
  • ответить на вопрос или отредактировать ответ
  • +
+
+ Отредактировать ответ на вопрос можно 1 раз в течение 60 дней после отправки ответа +
+ +
+Лимит запросов на один аккаунт продавца для всех методов категории Вопросы и отзывы: + + + + + + + + + + + + + + + + +
ПериодЛимитИнтервалВсплеск
1 секунда3 запроса333 миллисекунды6 запросов
+
+
Authorizations:
HeaderApiKey
Request Body schema: application/json
One of
id
required
string

Id вопроса

+
wasViewed
required
boolean

Просмотрен ли вопрос

+

Responses

Request samples

Content type
application/json
Example

Просмотреть вопрос

+
{
  • "id": "n5um6IUBQOOSTxXoo0gV",
  • "wasViewed": true
}

Response samples

Content type
application/json
{
  • "data": null,
  • "error": false,
  • "errorText": "",
  • "additionalErrors": null
}

Получить вопрос по ID{{ /api/v1/question }}

Описание метода
+ +

Метод возвращает данные вопроса по его ID. Далее вы можете работать с этим вопросом.

+
+Лимит запросов на один аккаунт продавца для всех методов категории Вопросы и отзывы: + + + + + + + + + + + + + + + + +
ПериодЛимитИнтервалВсплеск
1 секунда3 запроса333 миллисекунды6 запросов
+
+
Authorizations:
HeaderApiKey
query Parameters
id
required
string
Example: id=ljAVapEBL38RyMdRln61

ID вопроса

+

Responses

Response samples

Content type
application/json
{
  • "data": {
    },
  • "error": false,
  • "errorText": "",
  • "additionalErrors": null
}

Отзывы

+ Для доступа к методам используйте токен для категории Вопросы и отзывы +
+ +
+ Узнать больше об отзывах можно в справочном центре +
+ +

Методы для получения отзывов:

+
    +
  1. Непросмотренные отзывы и вопросы
  2. +
  3. Необработанные отзывы
  4. +
  5. Количество отзывов
  6. +
  7. Список отзывов
  8. +
  9. Список архивных отзывов
  10. +
+

Вы можете получить товар один отзыв по его ID и работать с полученными вопросами через методы:

+
    +
  1. Ответить на отзыв
  2. +
  3. Отредактировать ответ на отзыв
  4. +
  5. Возврат товара по ID отзыва
  6. +
+

Необработанные отзывы{{ /api/v1/feedbacks/count-unanswered }}

Описание метода
+ +

Метод возвращает:

+
    +
  • количество необработанных отзывов за сегодня и за всё время
  • +
  • среднюю оценку всех отзывов
  • +
+
+Лимит запросов на один аккаунт продавца для всех методов категории Вопросы и отзывы: + + + + + + + + + + + + + + + + +
ПериодЛимитИнтервалВсплеск
1 секунда3 запроса333 миллисекунды6 запросов
+
+
Authorizations:
HeaderApiKey

Responses

Response samples

Content type
application/json
{
  • "data": {
    },
  • "error": false,
  • "errorText": "",
  • "additionalErrors": null
}

Количество отзывов{{ /api/v1/feedbacks/count }}

Описание метода
+ +

Метод возвращает количество обработанных или необработанных отзывов за заданный период.

+
+Лимит запросов на один аккаунт продавца для всех методов категории Вопросы и отзывы: + + + + + + + + + + + + + + + + +
ПериодЛимитИнтервалВсплеск
1 секунда3 запроса333 миллисекунды6 запросов
+
+
Authorizations:
HeaderApiKey
query Parameters
dateFrom
integer
Example: dateFrom=1688465092

Дата начала периода в формате Unix timestamp

+
dateTo
integer
Example: dateTo=1688465092

Дата конца периода в формате Unix timestamp

+
isAnswered
boolean
Example: isAnswered=false

Обработан ли отзыв:

+
    +
  • true — да, по умолчанию
  • +
  • false — нет
  • +
+

Responses

Response samples

Content type
application/json
{
  • "data": 724583,
  • "error": false,
  • "errorText": "",
  • "additionalErrors": null
}

Список отзывов{{ /api/v1/feedbacks }}

Описание метода
+ +

Метод возвращает список отзывов по заданным фильтрам. Вы можете:

+
    +
  • получить данные обработанных и необработанных отзывов
  • +
  • сортировать отзывы по дате
  • +
  • настроить пагинацию и количество отзывов в ответе
  • +
+
+Лимит запросов на один аккаунт продавца для всех методов категории Вопросы и отзывы: + + + + + + + + + + + + + + + + +
ПериодЛимитИнтервалВсплеск
1 секунда3 запроса333 миллисекунды6 запросов
+
+
Authorizations:
HeaderApiKey
query Parameters
isAnswered
required
boolean
Example: isAnswered=false

Обработан ли отзыв:

+
    +
  • true — да, по умолчанию
  • +
  • false — нет
  • +
+
nmId
integer
Example: nmId=5870243

Артикул WB

+
take
required
integer
Example: take=1

Количество отзывов (max. 5 000)

+
skip
required
integer
Example: skip=0

Количество отзывов для пропуска (max. 199990)

+
order
string
Enum: "dateAsc" "dateDesc"

Сортировка отзывов по дате (dateAsc/dateDesc)

+
dateFrom
integer
Example: dateFrom=1688465092

Дата начала периода в формате Unix timestamp

+
dateTo
integer
Example: dateTo=1688465092

Дата конца периода в формате Unix timestamp

+

Responses

Response samples

Content type
application/json
{}

Ответить на отзыв{{ /api/v1/feedbacks/answer }}

Описание метода
+ +

Метод позволяет ответить на отзыв покупателя.

+
+ ID отзыва не валидируется. Если в запросе вы передали некорректный ID, вы не получите ошибку. +
+ +
+Лимит запросов на один аккаунт продавца для всех методов категории Вопросы и отзывы: + + + + + + + + + + + + + + + + +
ПериодЛимитИнтервалВсплеск
1 секунда3 запроса333 миллисекунды6 запросов
+
+
Authorizations:
HeaderApiKey
Request Body schema: application/json
id
required
string

ID отзыва

+
text
required
string [ 2 .. 5000 ]

Текст ответа

+

Responses

Request samples

Content type
application/json
{
  • "id": "J2FMRjUj6hwvwCElqssz",
  • "text": "Спасибо за Ваш отзыв!"
}

Response samples

Content type
application/json
Example

Не указан заголовок Content-Type

+
{
  • "title": "bad request",
  • "requestId": "e6c4100223db8bf5818b2e5f12705891",
  • "origin": "fbapi",
  • "detail": "content-type header not specified"
}

Отредактировать ответ на отзыв{{ /api/v1/feedbacks/answer }}

Описание метода
+ +

Метод позволяет отредактировать уже отправленный ответ на отзыв покупателя. +

+Отредактировать ответ можно только один раз в течение 60 дней c момента отправки.

+
+ ID отзыва не валидируется. Если в запросе вы передали некорректный ID, вы не получите ошибку. +
+ +
+Лимит запросов на один аккаунт продавца для всех методов категории Вопросы и отзывы: + + + + + + + + + + + + + + + + +
ПериодЛимитИнтервалВсплеск
1 секунда3 запроса333 миллисекунды6 запросов
+
+
Authorizations:
HeaderApiKey
Request Body schema: application/json
id
required
string

ID отзыва

+
text
required
string [ 2 .. 5000 ]

Текст ответа

+

Responses

Request samples

Content type
application/json
{
  • "id": "J2FMRjUj6hwvwCElqssz",
  • "text": "Спасибо за Ваш отзыв, он очень важен для нас!"
}

Response samples

Content type
application/json
{
  • "title": "unauthorized",
  • "detail": "token problem; token is malformed: could not base64 decode signature: illegal base64 data at input byte 84",
  • "code": "07e4668e--a53a3d31f8b0-[UK-oWaVDUqNrKG]; 03bce=277; 84bd353bf-75",
  • "requestId": "7b80742415072fe8b6b7f7761f1d1211",
  • "origin": "s2s-api-auth-catalog",
  • "status": 401,
  • "statusText": "Unauthorized",
  • "timestamp": "2024-09-30T06:52:38Z"
}

Возврат товара по ID отзыва{{ /api/v1/feedbacks/order/return }}

Описание метода
+ +

Метод запрашивает возврат товара, по которому оставлен отзыв. +

+Возврат доступен для отзывов с полем "isAbleReturnProductOrders": true.

+
+Лимит запросов на один аккаунт продавца для всех методов категории Вопросы и отзывы: + + + + + + + + + + + + + + + + +
ПериодЛимитИнтервалВсплеск
1 секунда3 запроса333 миллисекунды6 запросов
+
+
Authorizations:
HeaderApiKey
Request Body schema: application/json
required
feedbackId
string

ID отзыва

+

Responses

Request samples

Content type
application/json
{
  • "feedbackId": "absdfgerrrfff1234"
}

Response samples

Content type
application/json
{
  • "data": { },
  • "error": true,
  • "errorText": "string",
  • "additionalErrors": [
    ]
}

Получить отзыв по ID{{ /api/v1/feedback }}

Описание метода
+ +

Метод возвращает данные отзыва по его ID.

+
+Лимит запросов на один аккаунт продавца для всех методов категории Вопросы и отзывы: + + + + + + + + + + + + + + + + +
ПериодЛимитИнтервалВсплеск
1 секунда3 запроса333 миллисекунды6 запросов
+
+
Authorizations:
HeaderApiKey
query Parameters
id
required
string
Example: id=G7Y9Y1kBAtKOitoBT_lV

ID отзыва

+

Responses

Response samples

Content type
application/json
{}

Список архивных отзывов{{ /api/v1/feedbacks/archive }}

Описание метода
+ +

Метод возвращает список архивных отзывов. +

+Отзыв становится архивным, если:

+
    +
  • на отзыв получен ответ
  • +
  • на отзыв не получен ответ в течение 30 дней
  • +
  • в отзыве нет текста и фото
  • +
+
+Лимит запросов на один аккаунт продавца для всех методов категории Вопросы и отзывы: + + + + + + + + + + + + + + + + +
ПериодЛимитИнтервалВсплеск
1 секунда3 запроса333 миллисекунды6 запросов
+
+
Authorizations:
HeaderApiKey
query Parameters
nmId
integer
Example: nmId=14917842

Артикул WB

+
take
required
integer
Example: take=1

Количество отзывов (max. 5 000)

+
skip
required
integer
Example: skip=0

Количество отзывов для пропуска

+
order
string
Enum: "dateAsc" "dateDesc"

Сортировка отзывов по дате (dateAsc/dateDesc)

+

Responses

Response samples

Content type
application/json
{}

Закреплённые отзывы

+ Для доступа к методам используйте токен для категории Вопросы и отзывы +
+ +
+ Узнать больше о закреплённых отзывах можно в справочном центре +
+ +

С помощью этих методов вы можете:

+
    +
  1. Получить список закреплённых и откреплённых отзывов
  2. +
  3. Закрепить отзывы. Метод доступен по подписке Джем или c тарифной опцией Закрепление отзыва
  4. +
  5. Открепить отзывы
  6. +
  7. Получить количество закреплённых и откреплённых отзывов
  8. +
  9. Получить лимиты закреплённых отзывов по подписке и тарифной опции
  10. +
+

Список закреплённых и откреплённых отзывов{{ /api/feedbacks/v1/pins }}

Описание метода
+ +

Метод предоставляет список закреплённых и откреплённых отзывов. +
+Откреплёнными считаются только отзывы, которые были откреплены автоматически по причинам, указанным в ответе в поле unpinnedCause.

+
+Лимит запросов на один аккаунт продавца для всех методов категории Вопросы и отзывы: + + + + + + + + + + + + + + + + +
ПериодЛимитИнтервалВсплеск
1 секунда3 запроса333 миллисекунды6 запросов
+
+
Authorizations:
HeaderApiKey
query Parameters
state
string
Enum: "pinned" "unpinned"
Example: state=pinned

Закреплён ли отзыв:

+
    +
  • pinned — да
  • +
  • unpinned — нет
  • +
+
pinOn
string
Enum: "nm" "imt"
Example: pinOn=nm

Место закрепления отзыва:

+
    +
  • nm — карточка товара
  • +
  • imt — объединённая карточка
  • +
+
imtId
integer
Example: imtId=256972151

ID объединённой карточки товара.
+Все артикулы WB объединённой карточки товара имеют один и тот же imtId.
+У каждой карточки товара есть imtId, даже если она не объединена с другими карточками

+
nmId
integer
Example: nmId=177974151

Артикул WB

+
feedbackId
integer
Example: feedbackId=789

ID отзыва

+
dateFrom
string <date-time>
Example: dateFrom=2020-01-01T15:04:05Z

Дата закрепления первого отзыва в списке

+
dateTo
string <date-time>
Example: dateTo=2020-02-01T15:04:05Z

Дата закрепления последнего отзыва в списке

+
next
integer
Example: next=741

ID последней операции закрепления (пагинатор)

+
limit
integer <= 500
Default: 500
Example: limit=100

Количество отзывов на одной странице (пагинация)

+

Responses

Response samples

Content type
application/json
{
  • "data": [
    ],
  • "next": 200
}

Закрепить отзывы{{ /api/feedbacks/v1/pins }}

Описание метода
+ +

Метод позволяет закрепить отзывы в карточке товара или в объединённой карточке.
+Чтобы получить ID отзывов, используйте метод Список закреплённых и откреплённых отзывов.
+
+Метод доступен по подписке Джем или c тарифной опцией Закрепление отзыва.

+
+Лимит запросов на один аккаунт продавца для всех методов категории Вопросы и отзывы: + + + + + + + + + + + + + + + + +
ПериодЛимитИнтервалВсплеск
1 секунда3 запроса333 миллисекунды6 запросов
+
+
Authorizations:
HeaderApiKey
Request Body schema: application/json
required
Array (<= 500 items)
pinMethod
required
string
Enum: "tariff" "subscription"

Метод закрепления:

+
    +
  • subscription — подписка Джем
  • +
  • tariff — тарифная опция
  • +
+
pinOn
required
string
Enum: "nm" "imt"

Место закрепления отзыва:

+
    +
  • nm — карточка товара
  • +
  • imt — объединённая карточка
  • +
+
feedbackId
required
string

ID отзыва

+

Responses

Request samples

Content type
application/json
[
  • {
    },
  • {
    }
]

Response samples

Content type
application/json
{
  • "data": [
    ]
}

Открепить отзывы{{ /api/feedbacks/v1/pins }}

Описание метода
+ +

Метод позволяет открепить отзывы в карточке товара или в объединённой карточке.
+Чтобы получить pinId — ID операций закрепления, используйте метод Список закреплённых и откреплённых отзывов.

+
+Лимит запросов на один аккаунт продавца для всех методов категории Вопросы и отзывы: + + + + + + + + + + + + + + + + +
ПериодЛимитИнтервалВсплеск
1 секунда3 запроса333 миллисекунды6 запросов
+
+
Authorizations:
HeaderApiKey
Request Body schema: application/json
required

Список pinId — ID операций закрепления отзывов

+
Array (<= 500 items)
integer

Responses

Request samples

Content type
application/json
[
  • 123456,
  • 234567,
  • 345678
]

Response samples

Content type
application/json
{
  • "data": [
    ]
}

Количество закреплённых и откреплённых отзывов{{ /api/feedbacks/v1/pins/count }}

Описание метода
+ +

Метод возвращает количество закреплённых и откреплённых отзывов за заданный период.

+
+Лимит запросов на один аккаунт продавца для всех методов категории Вопросы и отзывы: + + + + + + + + + + + + + + + + +
ПериодЛимитИнтервалВсплеск
1 секунда3 запроса333 миллисекунды6 запросов
+
+
Authorizations:
HeaderApiKey
query Parameters
state
string
Enum: "pinned" "unpinned"
Example: state=pinned

Закреплён ли отзыв:

+
    +
  • pinned — да
  • +
  • unpinned — нет
  • +
+
pinOn
string
Enum: "nm" "imt"
Example: pinOn=nm

Место закрепления отзыва:

+
    +
  • nm — карточка товара
  • +
  • imt — объединённая карточка
  • +
+
imtId
integer
Example: imtId=256971531

ID объединённой карточки товара.
+Все артикулы WB объединённой карточки товара имеют один и тот же imtId.
+У каждой карточки товара есть imtId, даже если она не объединена с другими карточками

+
nmId
integer
Example: nmId=177974151

Артикул WB

+
feedbackId
integer
Example: feedbackId=789

ID отзыва

+
dateFrom
string <date-time>
Example: dateFrom=2020-01-01T15:04:05Z

Дата закрепления первого отзыва в списке

+
dateTo
string <date-time>
Example: dateTo=2020-02-01T15:04:05Z

Дата закрепления последнего отзыва в списке

+

Responses

Response samples

Content type
application/json
{
  • "data": 0
}

Лимиты закреплённых отзывов{{ /api/feedbacks/v1/pins/limits }}

Описание метода
+ +

Метод возвращает лимиты закреплённых отзывов по тарифу и подписке.

+
+Лимит запросов на один аккаунт продавца для всех методов категории Вопросы и отзывы: + + + + + + + + + + + + + + + + +
ПериодЛимитИнтервалВсплеск
1 секунда3 запроса333 миллисекунды6 запросов
+
+
Authorizations:
HeaderApiKey

Responses

Response samples

Content type
application/json
{
  • "data": {
    }
}

Чат с покупателями

+ Для доступа к методам используйте токен для категории Чат с покупателями +
+ +
+ Узнать больше о чате с покупателями можно в справочном центре +
+ +

Чат позволяет продавцам и покупателям общаться напрямую. +
Покупатели могут обращаться с вопросами по товарам или претензиями. Рекомендуем отвечать на сообщения в чате в течение 10 дней.

+



Чат всегда начинает покупатель. В одном чате можно общаться только с одним покупателем.

+
+ Обработка заявок на возврат товара доступна только в веб-версии чатов с покупателями. +
+ +

Работа с чатами:

+
    +
  1. Получите список чатов. Сохраните ID чатов в своей базе данных — это позволит обновлять информацию о чатах при получении событий.
  2. +
  3. Получите события чатов: сообщения. У новых чатов значение поля isNewChat будет true.
  4. +
  5. Отправляйте сообщения в чат
  6. +
+

Список чатов{{ /api/v1/seller/chats }}

Описание метода
+ +

Метод возвращает список всех чатов продавца. По этим данным можно получить события чатов или отправить сообщение покупателю.

+
+Лимит запросов на один аккаунт продавца: + + + + + + + + + + + + + + + + +
ПериодЛимитИнтервалВсплеск
10 секунд10 запросов1 секунда10 запросов
+
+
Authorizations:
HeaderApiKey

Responses

Response samples

Content type
application/json
{
  • "result": [
    ],
  • "errors": null
}

События чатов{{ /api/v1/seller/events }}

Описание метода
+ +

Метод возвращает список событий всех чатов с покупателями.

+

Чтобы получить все события:

+
    +
  1. Сделайте первый запрос без параметра next.
  2. +
  3. Повторяйте запрос со значением параметра next из ответа на предыдущий запрос, пока totalEvents не станет равным 0. Это будет означать, что вы получили все события.
  4. +
+
+Лимит запросов на один аккаунт продавца: + + + + + + + + + + + + + + + + +
ПериодЛимитИнтервалВсплеск
10 секунд10 запросов1 секунда10 запросов
+
+
Authorizations:
HeaderApiKey
query Parameters
next
integer

Пагинатор. С какого момента получить следующий пакет данных.
Формат Unix timestamp с миллисекундами

+

Responses

Response samples

Content type
application/json
{
  • "result": {
    },
  • "errors": null
}

Отправить сообщение{{ /api/v1/seller/message }}

Описание метода
+ +

Метод отправляет сообщения в чат с покупателем.

+
+Лимит запросов на один аккаунт продавца: + + + + + + + + + + + + + + + + +
ПериодЛимитИнтервалВсплеск
10 секунд10 запросов1 секунда10 запросов
+
+
Authorizations:
HeaderApiKey
Request Body schema: multipart/form-data
required
replySign
required
string <= 255 characters

Подпись чата. Можно получить из информации по чату или данных события, если в событии есть поле "isNewChat": true.

+
message
string <= 1000 characters

Текст сообщения. Максимум 1000 символов.

+
file
Array of strings <binary> [ items <binary > ]

Файлы, формат JPEG, PDF или PNG, максимальный размер — 5 Мб каждый. Максимальный суммарный размер файлов — 30 Мб.

+

Responses

Response samples

Content type
application/json
{
  • "result": {
    },
  • "errors": [ ]
}

Получить файл из сообщения{{ /api/v1/seller/download/{id} }}

Описание метода
+ +

Метод возвращает файл или изображение из сообщения по его ID.

+
+Лимит запросов на один аккаунт продавца: + + + + + + + + + + + + + + + + +
ПериодЛимитИнтервалВсплеск
10 секунд10 запросов1 секунда10 запросов
+
+
Authorizations:
HeaderApiKey
path Parameters
id
required
string

ID файла, см. значение поля downloadID в методе События чатов

+

Responses

Response samples

Content type
application/json

Недействительный ID файла

+
{
  • "status": 400,
  • "title": "invalid fileID",
  • "origin": "proxy-chats",
  • "detail": "invalid fileID",
  • "requestId": "62f59a4ce21064f20b1bbc28c85f38d8",
  • "error": "invalid fileID"
}

Возвраты покупателями

+ Для доступа к методам используйте токен для категории Возвраты покупателями +
+ +
+ Узнать больше о возвратах покупателями можно в справочном центре +
+ +

С помощью этих методов вы можете:

+
    +
  1. Отслеживать заявки покупателей на возврат
  2. +
  3. Отвечать на заявки
  4. +
+

Заявки покупателей на возврат{{ /api/v1/claims }}

Описание метода
+ +

Метод возвращает заявки покупателей на возврат товаров за последние 14 дней. Вы можете отвечать на эти заявки.

+
+Лимит запросов на один аккаунт продавца: + + + + + + + + + + + + + + + + +
ПериодЛимитИнтервалВсплеск
1 минута20 запросов3 секунды10 запросов
+
+
Authorizations:
HeaderApiKey
query Parameters
is_archive
required
boolean
Example: is_archive=true

Состояние заявки:

+
    +
  • false — на рассмотрении
  • +
  • true — в архиве
  • +
+
id
string <UUID>
Example: id=fe3e9337-e9f9-423c-8930-946a8ebef80

ID заявки

+
limit
integer <uint> [ 1 .. 200 ]
Example: limit=50

Количество заявок в ответе. По умолчанию 50

+
offset
integer <uint> >= 0
Example: offset=0

После какого элемента выдавать данные. По умолчанию 0

+
nm_id
integer
Example: nm_id=196320101

Артикул WB

+

Responses

Response samples

Content type
application/json
{
  • "claims": [
    ],
  • "total": 31
}

Ответ на заявку покупателя{{ /api/v1/claim }}

Описание метода
+ +

Метод отправляет ответ на заявку покупателя на возврат товаров.

+
+Лимит запросов на один аккаунт продавца: + + + + + + + + + + + + + + + + +
ПериодЛимитИнтервалВсплеск
1 минута20 запросов3 секунды10 запросов
+
+
Authorizations:
HeaderApiKey
Request Body schema: application/json
required

Ответ на заявку

+
id
required
string <UUID>

ID заявки

+
action
required
string

Действие с заявкой.
Используйте одно из значений массива actions — ответа метода получения заявок

+
comment
string [ 10 .. 1000 ] characters

Комментарий.
Применимо только при "action":"rejectcustom" или "action":"approvecc1". При "action":"rejectcustom" параметр обязателен

+

Responses

Request samples

Content type
application/json
{
  • "id": "fe3e9337-e9f9-423c-8930-946a8ebef80",
  • "action": "rejectcustom",
  • "comment": "Фото не имеет отношения к товару в заявке"
}

Response samples

Content type
application/json; charset=utf-8
{
  • "title": "Validation error",
  • "detail": "Input model is not valid; Details: The Action field is required.",
  • "requestId": "0HN3PI6JUGFSL:00000004"
}
+ + + + +
\ No newline at end of file diff --git a/templates/admin.html b/templates/admin.html new file mode 100644 index 0000000..ada757b --- /dev/null +++ b/templates/admin.html @@ -0,0 +1,111 @@ + + + + + + Администратор — WB Feedback + + + + + + +
+ + + + {% if info_message %} +
{{ info_message }}
+ {% endif %} + +
+
+

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

+ {{ users|length }} +
+ +
    + {% 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/templates/cabinet.html b/templates/cabinet.html new file mode 100644 index 0000000..314f1c7 --- /dev/null +++ b/templates/cabinet.html @@ -0,0 +1,158 @@ + + + + + + Кабинет — WB Feedback + + + + + + +
+ + + + {% if error_message %} +
{{ error_message }}
+ {% endif %} + {% if success_message %} +
{{ success_message }}
+ {% endif %} + +
+
+

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

+ {% if tokens %} + {{ tokens|length }} + {% 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/templates/index.html b/templates/index.html new file mode 100644 index 0000000..7392d56 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,444 @@ + + + + + + Отзывы — WB Feedback + + + + + + +
+ + {% 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 %} +
+

По требованию WB — 1 ответ каждые 10 минут. Очередь обрабатывается автоматически.

+ {% if api_cooldown_seconds_left and api_cooldown_seconds_left > 0 %} +

⚠ API на паузе: ещё {{ api_cooldown_seconds_left }} сек.

+ {% endif %} +
+
+ + +
+
+ + +
+
+

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

+
+

Для каждого нового отзыва текст выбирается случайно из пула.

+
+ + +
+
+
+ Ответы для 5★ +
+
+ +
+
+
+ Ответы для 4★ +
+
+ +
+
+
+ +
+
+
+ + +
+
+

Очередь автоответов

+ {% if auto_reply_queue %} + {{ auto_reply_queue|length }} + {% endif %} +
+ {% if auto_reply_queue %} +
+ + + + + + + + + + + + {% for item in auto_reply_queue %} + + + + + + + {% endfor %} + +
#ID отзываОценкаТоварПокупатель
{{ loop.index }}{{ item.rating }}★ + {{ item.get("product_name") or "—" }} + {% if item.get("nm_id") %} + #{{ item.nm_id }} + {% endif %} + {{ item.get("user_name") or "—" }}
+
+ {% else %} +
Очередь пуста — новые отзывы будут загружены автоматически.
+ {% endif %} +
+ + +
+
+

Журнал автоответов

+ последние 100 +
+ {% if auto_reply_logs %} +
+ + + + + + + + + + + + + + + + {% for log in auto_reply_logs %} + + + + + + + + + + + + {% endfor %} + +
Дата логаДата оценкиОценкаТоварПокупательТекст отзываСтатусID отзываОтвет
{{ log["created_at"]|format_log_datetime }}{{ log["review_created_at"]|format_log_datetime if log["review_created_at"] else "—" }}{{ log["rating"] }}★ + {{ log["product_name"] or "—" }} + {% if log["nm_id"] %} + #{{ log["nm_id"] }} + {% endif %} + {{ log["user_name"] or "—" }}{{ log["review_text"] or "—" }} + {% if log["status"] == "sent" %} + ✓ Отправлен + {% elif log["status"] == "skipped" %} + — Пропущен + {% else %} + ✗ Ошибка + {% endif %} + {{ log["review_id"] or "—" }}{{ log["reply_text"] or "—" }}
+
+ {% else %} +
Пока нет записей автоответа.
+ {% endif %} +
+ + + {% if reviews %} + {% set unanswered_count = reviews | rejectattr('answer') | list | length %} + {% set answered_count = reviews | selectattr('answer') | list | length %} + +
+

{{ "Неотвеченные отзывы" if current_filter == 'unanswered' else "Все отзывы" }}

+

Оценки: {{ selected_stars_display|join(', ') }}★.

+
+ + +
+
+ {{ reviews|length }} + отзывов загружено +
+ {% if unanswered_count > 0 %} +
+ {{ unanswered_count }} + без ответа +
+ {% endif %} + {% if answered_count > 0 %} +
+ {{ answered_count }} + отвечено +
+ {% endif %} + {% for star in [5,4,3,2,1] %} + {% set cnt = reviews | selectattr('rating', 'equalto', star) | list | length %} + {% if cnt > 0 %} +
+ {{ cnt }} + {{ star }}★ +
+ {% endif %} + {% endfor %} +
+ + {% 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/templates/login.html b/templates/login.html new file mode 100644 index 0000000..1f6108b --- /dev/null +++ b/templates/login.html @@ -0,0 +1,39 @@ + + + + + + Вход — WB Feedback + + + +
+
+
+ + Wildberries Feedback +
+

Добро пожаловать

+

Управляйте отзывами, ответами и автоответом в одном кабинете.

+ + {% if error_message %} +
{{ error_message }}
+ {% endif %} + +
+ + + +
+ + +
+
+ + diff --git a/templates/register.html b/templates/register.html new file mode 100644 index 0000000..de00c51 --- /dev/null +++ b/templates/register.html @@ -0,0 +1,46 @@ + + + + + + Регистрация — WB Feedback + + + +
+
+
+ + Wildberries Feedback +
+

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

+

После регистрации администратор подтвердит доступ к кабинету.

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