diff --git a/app.py b/app.py index a12cc7f..e1b91ae 100644 --- a/app.py +++ b/app.py @@ -1481,7 +1481,9 @@ def request_access(): f"{geo_line}\n" f"🖥 IP: {ip}" ) - _send_telegram(text) + ok = _send_telegram(text) + if not ok: + return jsonify({"ok": False, "error": "Не удалось отправить запрос. Попробуйте позже."}), 500 return jsonify({"ok": True}) diff --git a/context.md b/context.md index 32bcb5c..8135692 100644 --- a/context.md +++ b/context.md @@ -1,32 +1,66 @@ -# Контекст проекта Wildberries -сервер ssh 192.168.33.19 -путь /home/sites/wild +# Контекст проекта WBfeed + ## Что это за проект -- Flask-приложение для работы с отзывами Wildberries. -- Поддерживает вход/регистрацию, личный кабинет с токенами, админку, ответы на отзывы. -- Есть автоответ для новых неотвеченных отзывов 5★ и 4★ с рандомным выбором ответа из пулов. +- Flask-приложение для автоответов на отзывы Wildberries. +- Поддерживает вход, личный кабинет с токенами магазинов, админку, журнал автоответов. +- Автоответ для 5★, 4★, 3★, 2★, 1★ с настраиваемыми пулами шаблонов. +- Уведомления в Telegram через прокси tel.4mont.ru при запросе доступа. -## Текущая структура (локальная копия) -- `remote_copy/app.py` — основной backend (Flask + SQLite). -- `remote_copy/templates/*.html` — шаблоны интерфейса. -- `remote_copy/static/styles.css` — стили. -- `remote_copy/export_last_answers*.py` — утилиты выгрузки ответов. -- `promt.md` — заметка с сервером и путём. +## Серверы -## Сервер и запуск -- Сервер: `192.168.33.19` -- Путь проекта на сервере: `/home/sites/wild` -- Запуск: Docker Compose, проброс `54119 -> 5000` +### Основной (локальная сеть) +- SSH: `root@192.168.33.19`, пароль: `utOgbZ09` +- Путь проекта: `/home/sites/wild` +- Docker Compose, порт `54119 → 5000` +- В `/etc/hosts` прописано: `192.168.33.19 tel.4mont.ru` (Telegram прокси) -## Что уже реализовано -- Пользователи и роли (`is_admin`, `is_active`). -- Управление токенами в кабинете. -- Получение всех/неотвеченных отзывов + фильтры по звёздам. -- Ручной ответ одному/всем отзывам. -- Переключатель автоответа. -- Настраиваемые пулы автоответов для 5★ и 4★ через UI. -- Журнал автоответов (последние 100 записей). -- Базовая мобильная адаптация. +### Продакшн (публичный) +- SSH: `root@45.129.3.83`, пароль: `utOgbZ09ruslan` +- Путь проекта: `/home/docker/wbfeed` +- Docker Compose, порт `2323 → 5000`, контейнер `wbfeed-app-1` +- NPM (Nginx Proxy Manager) на том же сервере +- Для NPM → wbfeed: Forward `172.19.0.1:2323` (шлюз Docker-сети npm) +- Для любого нового Docker-проекта через NPM: тот же IP `172.19.0.1`, другой порт -## Известное ограничение -- API Wildberries периодически отдаёт `429 Too Many Requests` при массовой выгрузке исторических ответов. +## Локальная структура (только контекст, код деплоится на сервер) +- `app.py` — основной backend (Flask + SQLite) +- `templates/*.html` — шаблоны интерфейса +- `static/styles.css` — стили +- `context.md` — этот файл, хранится локально в git + +## Деплой + +### На основной сервер (192.168.33.19) +```bash +sshpass -p 'utOgbZ09' scp <файл> root@192.168.33.19:/home/sites/wild/<путь> +sshpass -p 'utOgbZ09' ssh root@192.168.33.19 "docker restart wild-app-1" +# templates/ и static/ — перезапуск не нужен, Flask видит сразу +``` + +### На продакшн (45.129.3.83) +```bash +sshpass -p 'utOgbZ09ruslan' scp -o StrictHostKeyChecking=no <файл> root@45.129.3.83:/home/docker/wbfeed/<путь> +sshpass -p 'utOgbZ09ruslan' ssh root@45.129.3.83 "docker restart wbfeed-app-1" +``` + +## Учётные данные +- Админ: логин `ruslan`, пароль `utOgbZ09ruslan+` +- Git: https://git.ruslan.xyz/ruslan/wildberries.git + +## Telegram уведомления +- Бот токен: `8181219074:AAGvqWqb6t10YP4xpMOQnBq_6LrUqAFm5hM` +- Chat ID: `54986411` +- Прокси URL: `https://tel.4mont.ru/bot{TOKEN}/sendMessage` +- На 192.168.33.19 работает через `/etc/hosts` → локальный прокси +- На 45.129.3.83 прокси недоступен (разные сети) — уведомления не доходят + +## Технический стек +- Python 3.11, Flask 3.0.3, SQLite (tokens.db) +- Docker + Docker Compose, Nginx Proxy Manager +- WB API rate limit: 1 запрос / 120 сек +- Автоответ: цикл каждые 120 сек, 1 ответ за цикл, очередь в SQLite + +## Известные особенности +- `tokens.db` монтируется как bind-mount: нужно создавать как файл (`touch`), не как папку +- `app.py` монтируется как файл — после `scp` нужен `docker restart` (inode binding) +- `templates/` и `static/` монтируются как папки — изменения видны без рестарта diff --git a/static/styles.css b/static/styles.css index 7e0f0e7..073b2e9 100644 --- a/static/styles.css +++ b/static/styles.css @@ -1113,6 +1113,11 @@ textarea:focus { text-align: center; } +.input-invalid { + border-color: #dc2626 !important; + background: #fff5f5 !important; +} + @media (max-width: 768px) { .login-split { flex-direction: column; } .login-promo { padding: 40px 24px; flex: none; } diff --git a/templates/index.html b/templates/index.html index 28ba905..0200464 100644 --- a/templates/index.html +++ b/templates/index.html @@ -79,9 +79,9 @@
Автоответ активен - {% if api_cooldown_seconds_left and api_cooldown_seconds_left > 0 %} -  ·  Следующий ответ через {{ api_cooldown_seconds_left }} сек. - {% endif %} + +  ·  Следующий ответ через {{ api_cooldown_seconds_left or 0 }} сек. +
{% endif %} @@ -410,7 +410,15 @@ initPools(); } else if (data.last_log_id !== lastLogId) { window.location.reload(); } - if (cooldownCtrl && data.cooldown > 0) cooldownCtrl.update(data.cooldown); + const wrap = document.getElementById('cooldown-wrap'); + if (wrap) { + if (data.cooldown > 0) { + wrap.style.display = ''; + if (cooldownCtrl) cooldownCtrl.update(data.cooldown); + } else { + wrap.style.display = 'none'; + } + } if (fetchCtrl && data.next_fetch_seconds > 0) fetchCtrl.update(data.next_fetch_seconds); if (data.next_fetch_seconds === 0 && data.queue_len === 0 && data.auto_reply_enabled) { window.location.reload(); diff --git a/templates/login.html b/templates/login.html index b2dd79c..c11c24e 100644 --- a/templates/login.html +++ b/templates/login.html @@ -189,25 +189,43 @@ closeBtn.addEventListener('click', () => modal.classList.remove('active')); modal.addEventListener('click', e => { if (e.target === modal) modal.classList.remove('active'); }); document.addEventListener('keydown', e => { if (e.key === 'Escape') modal.classList.remove('active'); }); -document.getElementById('request-form').addEventListener('submit', async e => { +const form = document.getElementById('request-form'); + +function validateField(name) { + const input = form[name]; + const errEl = document.getElementById('err-' + name); + const val = input.value.trim(); + let msg = ''; + + if (name === 'name' && !val) + msg = 'Введите имя'; + if (name === 'email' && (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val))) + msg = 'Введите корректный email'; + if (name === 'phone' && val.replace(/\D/g, '').length < 10) + msg = 'Введите корректный телефон'; + + errEl.textContent = msg; + input.classList.toggle('input-invalid', !!msg); + return !msg; +} + +['name', 'email', 'phone'].forEach(name => { + const input = form[name]; + input.addEventListener('blur', () => validateField(name)); + input.addEventListener('input', () => { + if (input.classList.contains('input-invalid')) validateField(name); + }); +}); + +form.addEventListener('submit', async e => { e.preventDefault(); - const form = e.target; - const name = form.name.value.trim(); - const email = form.email.value.trim(); - const phone = form.phone.value.trim(); - let valid = true; + const validName = validateField('name'); + const validEmail = validateField('email'); + const validPhone = validateField('phone'); + if (!validName || !validEmail || !validPhone) return; - ['name','email','phone'].forEach(f => document.getElementById('err-' + f).textContent = ''); document.getElementById('modal-error').style.display = 'none'; - if (!name) - { document.getElementById('err-name').textContent = 'Введите имя'; valid = false; } - if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) - { document.getElementById('err-email').textContent = 'Введите корректный email'; valid = false; } - if (!phone || phone.replace(/\D/g, '').length < 10) - { document.getElementById('err-phone').textContent = 'Введите корректный телефон'; valid = false; } - if (!valid) return; - const btn = document.getElementById('request-submit'); btn.disabled = true; btn.textContent = 'Отправка…';