Compare commits

..
Author SHA1 Message Date
apuc b7619f28fe fix: вторая модалка подтверждения hh.ru — повторный клик «Отправить отклик» после «Отправить» 2026-09-17 10:11:49 +03:00
apuc 31fd316d51 README: порт 18091, регистрация, одно-кликовая отправка, переменные окружения 2026-09-17 10:11:48 +03:00
apuc 3d5610ca79 fix: закрывать браузер в finally — после отправки hh.ru закрывает модалку, wait_for_timeout падал с TargetClosedError и _close_context пропускался 2026-09-17 10:11:48 +03:00
apuc ec9f342cf6 Автозакрытие браузера после успешной отправки отклика (send-now и confirm_send) 2026-09-16 13:31:00 +03:00
apuc 2d781737f2 Модалка отправки отклика: кнопка в колонке действий, генерация с прелоадером, отправка по готовности текста 2026-09-16 13:26:46 +03:00
apuc e4cd06a23d fix: авто-перезапуск браузера при закрытом контексте (BrowserContext.new_page: has been closed) 2026-09-16 13:20:19 +03:00
apuc 4dcaffd4c4 Деплой: python-dotenv для загрузки .env, порт 18091 в DEPLOY.md 2026-09-16 13:14:18 +03:00
apuc 7738d35f3d fix: авто-загрузка резюме при открытии аккаунта + восстановление выбора resume_id 2026-09-16 13:09:10 +03:00
apuc 40c2c86452 Одно-кликовая отправка: кнопка «Отправить отклик» после генерации, /send-now, скриншот результата 2026-09-16 13:02:20 +03:00
apuc a79be642f0 fix: .hidden !important — модалка не была скрыта; защита от null-аккаунта в confirmSend/markDone 2026-09-16 12:44:13 +03:00
apuc 963c29084c Простая регистрация: /register, автовход, ALLOW_REGISTRATION, лимит для новых 2026-09-16 12:30:30 +03:00
apuc adf6fd24f7 Порт по умолчанию 18091, привязка через HOST 2026-09-16 12:17:18 +03:00
apuc 25b46a0b2a Многопользовательский режим: авторизация, изоляция данных, лимиты генераций, деплой без VNC 2026-09-12 16:42:35 +03:00
10 changed files with 539 additions and 50 deletions

No files matched your search

+6
View File
@@ -15,3 +15,9 @@ HH_HEADLESS=0
# На сервере без sandbox (root/контейнер): 1 # На сервере без sandbox (root/контейнер): 1
HH_NO_SANDBOX=0 HH_NO_SANDBOX=0
# Регистрация: 1 = открытая (любой может создать аккаунт), 0 = только через manage_users.py
ALLOW_REGISTRATION=1
# Дневной лимит генераций для самостоятельно зарегистрированных пользователей
REGISTER_LLM_LIMIT=50
+20 -5
View File
@@ -9,7 +9,7 @@
│ nginx │ Let's Encrypt │ nginx │ Let's Encrypt
│ reverse proxy│ │ reverse proxy│
└───────┬───────┘ └───────┬───────┘
│ :5000 │ :18091
▼ ▼
gunicorn (Flask: авторизация, изоляция данных, лимиты генераций) gunicorn (Flask: авторизация, изоляция данных, лимиты генераций)
│ │
@@ -69,7 +69,9 @@
- ОС: **Debian 12 / Ubuntu 22.04+**, есть **root** - ОС: **Debian 12 / Ubuntu 22.04+**, есть **root**
- Домен или статический IP; порты **22, 80, 443** открыты - Домен или статический IP; порты **22, 80, 443** открыты
- Круг пользователей небольшой (2–10 человек), пользователей создаёт владелец (без открытой регистрации) - Круг пользователей небольшой (2–10 человек). Два варианта:
- **Открытая регистрация** (по умолчанию): любой может создать аккаунт на странице `/register`
- **Закрытая** (`ALLOW_REGISTRATION=0` в `.env`): пользователей создаёт владелец через `manage_users.py`
--- ---
@@ -154,6 +156,19 @@ systemctl enable --now xvfb
## Этап 4 — пользователи ## Этап 4 — пользователи
**Вариант А — открытая регистрация** (по умолчанию, `ALLOW_REGISTRATION=1`):
любой посетитель создаёт аккаунт сам на странице `/register` (логин + пароль,
сразу входит). Лимит генераций для таких пользователей — `REGISTER_LLM_LIMIT`
(по умолчанию 50 в день). Владельца всё равно стоит создать через CLI:
```bash
# Владелец (лимит 50 генераций в день)
/opt/hh_auto/.venv/bin/python /opt/hh_auto/manage_users.py add admin --password СЛОЖНЫЙ_ПАРОЛЬ --limit 50
```
**Вариант Б — закрытая регистрация** (`ALLOW_REGISTRATION=0` в `.env`):
пользователей создаёт только владелец:
```bash ```bash
# Владелец (лимит 50 генераций в день) # Владелец (лимит 50 генераций в день)
/opt/hh_auto/.venv/bin/python /opt/hh_auto/manage_users.py add admin --password СЛОЖНЫЙ_ПАРОЛЬ --limit 50 /opt/hh_auto/.venv/bin/python /opt/hh_auto/manage_users.py add admin --password СЛОЖНЫЙ_ПАРОЛЬ --limit 50
@@ -186,7 +201,7 @@ WorkingDirectory=/opt/hh_auto
Environment=DISPLAY=:99 Environment=DISPLAY=:99
EnvironmentFile=/opt/hh_auto/.env EnvironmentFile=/opt/hh_auto/.env
ExecStart=/opt/hh_auto/.venv/bin/gunicorn \ ExecStart=/opt/hh_auto/.venv/bin/gunicorn \
-w 1 --threads 8 --timeout 300 -b 127.0.0.1:5000 webui:app -w 1 --threads 8 --timeout 300 -b 127.0.0.1:18091 webui:app
Restart=always Restart=always
RestartSec=5 RestartSec=5
LimitNOFILE=65536 LimitNOFILE=65536
@@ -230,7 +245,7 @@ server {
client_max_body_size 20M; client_max_body_size 20M;
location / { location / {
proxy_pass http://127.0.0.1:5000; proxy_pass http://127.0.0.1:18091;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -281,7 +296,7 @@ chmod 644 /etc/cron.d/hh-backup
journalctl -u hh-web -f journalctl -u hh-web -f
# Здоровье # Здоровье
curl -sf http://127.0.0.1:5000/api/accounts > /dev/null || systemctl restart hh-web curl -sf http://127.0.0.1:18091/api/accounts > /dev/null || systemctl restart hh-web
``` ```
--- ---
+38 -8
View File
@@ -1,9 +1,9 @@
# Автоотклик на hh.ru (полуавтоматический, безопасный) # Автоотклик на hh.ru (полуавтоматический)
Скрипт ищет вакансии на hh.ru по ключевым словам, генерирует персонализированный Скрипт ищет вакансии на hh.ru по ключевым словам, генерирует персонализированный
текст отклика через ИИ (на основе вашего резюме и описания вакансии), открывает текст отклика через ИИ (на основе вашего резюме и описания вакансии), открывает
страницу в браузере и подставляет текст. **Кнопку «Отправить» всегда нажимаете вы** — страницу в браузере, подставляет текст и резюме. Отправка — в один клик
это не нарушает правила hh.ru и не рискует аккаунтом. («Отправить отклик»), либо с проверкой текста перед отправкой.
## Установка ## Установка
@@ -25,17 +25,30 @@ PLAYWRIGHT_DOWNLOAD_HOST=https://cdn.npmmirror.com/binaries/playwright .venv/bin
.venv/bin/python webui.py .venv/bin/python webui.py
``` ```
Откройте **http://localhost:5000** — там можно: Откройте **http://localhost:18091** (порт меняется через `PORT`) — там можно:
- регистрироваться и входить (открытая регистрация, отключается `ALLOW_REGISTRATION=0`)
- создавать аккаунты (имя, регион, поисковые запросы, стиль) - создавать аккаунты (имя, регион, поисковые запросы, стиль)
- загружать резюме файлом (**txt, md, docx, pdf**) - загружать резюме файлом (**txt, md, docx, pdf**)
- сохранять **ключ сессии hh.ru** (cookies из браузера) — нужен для поиска и списка резюме - сохранять **ключ сессии hh.ru** (cookies из браузера) — нужен для поиска и списка резюме
- выбирать **резюме на hh.ru** — оно автоматически подставляется в модалке отклика - выбирать **резюме на hh.ru** — оно автоматически подставляется в модалке отклика
- искать вакансии и видеть их списком (название, компания, зарплата, опыт) - искать вакансии и видеть их списком (название, компания, зарплата, опыт)
- генерировать тексты откликов через ИИ (редактируются перед отправкой) - генерировать тексты откликов через ИИ (редактируются перед отправкой)
- отправлять отклик в один клик: кнопка «Отправить отклик» в колонке действий —
браузер открывает вакансию, вставляет текст, жмёт «Отправить» и закрывается
(скриншот результата показывается в модалке)
- открывать вакансию в браузере с уже вставленным текстом и выбранным резюме - открывать вакансию в браузере с уже вставленным текстом и выбранным резюме
для ручной проверки перед отправкой
- отмечать отправленные — они больше не предлагаются - отмечать отправленные — они больше не предлагаются
### Регистрация
При первом запуске откройте `/register` и создайте логин с паролем (минимум
6 символов) — вы сразу войдёте. Каждый пользователь работает со своими
аккаунтами и лимитом генераций. Открытая регистрация отключается
`ALLOW_REGISTRATION=0` (пользователей тогда создаёт администратор через
`manage_users.py`).
### Ключ сессии hh.ru ### Ключ сессии hh.ru
Чтобы скрипт мог искать вакансии и видеть ваши резюме, нужны cookies из браузера: Чтобы скрипт мог искать вакансии и видеть ваши резюме, нужны cookies из браузера:
@@ -89,13 +102,30 @@ accounts/
1. Скрипт парсит страницу поиска hh.ru по вашим запросам 1. Скрипт парсит страницу поиска hh.ru по вашим запросам
2. Для каждой новой вакансии получает описание 2. Для каждой новой вакансии получает описание
3. ИИ генерирует текст отклика (вакансия + ваше резюме + стиль) 3. ИИ генерирует текст отклика (вакансия + ваше резюме + стиль)
4. Браузер открывает вакансию, жмёт «Откликнуться», вставляет текст 4. **Один клик**: кнопка «Отправить отклик» — браузер открывает вакансию,
5. **Вы проверяете текст и нажимаете «Отправить»** жмёт «Откликнуться», вставляет текст, выбирает резюме, жмёт «Отправить»
6. Отмечаете отправленное — скрипт переходит к следующей и автоматически закрывается (скриншот результата — в модалке)
5. **С проверкой**: «Открыть в браузере» — вы проверяете текст и отправляете
сами, либо жмёте «Отправить отклик» в модалке скриншота
6. Отправленные вакансии отмечаются и больше не предлагаются
## Переменные окружения (.env)
| Переменная | По умолчанию | Описание |
|---|---|---|
| `LLM_API_KEY` | — | Ключ API для генерации текстов откликов |
| `SESSION_SECRET` | `dev-secret-change-me` | Секрет сессий Flask (обязательно сменить) |
| `PORT` | `18091` | Порт веб-интерфейса |
| `HOST` | `127.0.0.1` | Адрес прослушивания |
| `ALLOW_REGISTRATION` | `1` | Открытая регистрация (`0` — выключить) |
| `REGISTER_LLM_LIMIT` | `50` | Лимит генераций для новых пользователей |
| `HH_HEADLESS` | `0` | `1` — браузер без окна (для сервера без дисплея) |
| `PROXY_URL` | — | Прокси для браузера: `http://LOGIN:PASS@HOST:PORT` |
## Важно ## Важно
- Скрипт не нарушает правила hh.ru: финальное действие всегда за человеком - Отправка в один клик автоматически жмёт «Отправить» — используйте с осторожностью
и не чаще, чем позволяет hh.ru (паузы между страницами уже стоят)
- Не запускайте поиск слишком часто — между страницами есть паузы - Не запускайте поиск слишком часто — между страницами есть паузы
- Если hh.ru покажет капчу — скрипт остановится, решите капчу вручную и запустите снова - Если hh.ru покажет капчу — скрипт остановится, решите капчу вручную и запустите снова
- `.env` содержит API-ключ — не коммитьте его в git (уже добавлен в `.gitignore`) - `.env` содержит API-ключ — не коммитьте его в git (уже добавлен в `.gitignore`)
+137 -18
View File
@@ -15,6 +15,10 @@ import time
from playwright.sync_api import sync_playwright from playwright.sync_api import sync_playwright
from dotenv import load_dotenv
load_dotenv()
from hh_search import parse_cookies from hh_search import parse_cookies
# Селекторы hh.ru (с запасными вариантами) # Селекторы hh.ru (с запасными вариантами)
@@ -33,11 +37,21 @@ SUBMIT_BUTTONS = [
'[data-qa="vacancy-response-popup-form"] button[type="submit"]', '[data-qa="vacancy-response-popup-form"] button[type="submit"]',
'button:has-text("Отправить")', 'button:has-text("Отправить")',
] ]
# Кнопки подтверждения во ВТОРОЙ модалке hh.ru: после клика «Отправить»
# иногда появляется модалка «Отклик будет отправлен» с кнопкой
# «Отправить отклик» — её нужно нажать ещё раз.
CONFIRM_BUTTONS = [
'button:has-text("Отправить отклик")',
'button:has-text("Подтвердить")',
'button:has-text("Да, отправить")',
'[data-qa="vacancy-response-confirm"]',
]
# Живые браузерные контексты и страницы: {(user, account): ...} # Живые браузерные контексты и страницы: {(user, account): ...}
# Доступны ТОЛЬКО из браузерного потока. # Доступны ТОЛЬКО из браузерного потока.
_browsers = {} _browsers = {}
_pages = {} _pages = {}
_playwrights = {}
# ---------- Браузерный поток ---------- # ---------- Браузерный поток ----------
@@ -107,9 +121,8 @@ def get_proxy_config():
# ---------- Внутренние функции (только из браузерного потока) ---------- # ---------- Внутренние функции (только из браузерного потока) ----------
def _launch_context(account_dir): def _launch_context_with(p, account_dir):
"""Запускает браузер с профилем аккаунта и подставляет cookies сессии.""" """Запускает браузер с профилем аккаунта и подставляет cookies сессии."""
p = sync_playwright().start()
kwargs = dict( kwargs = dict(
user_data_dir=os.path.join(account_dir, "profile"), user_data_dir=os.path.join(account_dir, "profile"),
headless=is_headless(), headless=is_headless(),
@@ -123,6 +136,15 @@ def _launch_context(account_dir):
return context return context
def _context_alive(ctx):
"""Проверяет, жив ли контекст (лёгкая операция, не создаёт страниц)."""
try:
ctx.pages
return True
except Exception:
return False
def _inject_session_cookies(context, account_dir): def _inject_session_cookies(context, account_dir):
"""Подставляет cookies из session.txt — вход на hh.ru без ручного логина.""" """Подставляет cookies из session.txt — вход на hh.ru без ручного логина."""
path = os.path.join(account_dir, "session.txt") path = os.path.join(account_dir, "session.txt")
@@ -145,10 +167,33 @@ def _inject_session_cookies(context, account_dir):
def _get_context(account_dir, user, account): def _get_context(account_dir, user, account):
"""Возвращает живой контекст; если браузер умер — перезапускает."""
key = (user, account) key = (user, account)
if key not in _browsers: ctx = _browsers.get(key)
_browsers[key] = _launch_context(account_dir) if ctx is None or not _context_alive(ctx):
return _browsers[key] if ctx is not None:
print(f" ⚠ Браузер {key} закрыт — перезапускаю")
_close_context(key)
p = sync_playwright().start()
try:
ctx = _launch_context_with(p, account_dir)
except Exception:
p.stop()
raise
_browsers[key] = ctx
_playwrights[key] = p
return ctx
def _new_page(account_dir, user, account):
"""Создаёт страницу; если контекст умер между проверкой и созданием —
перезапускает браузер и пробует ещё раз."""
try:
return _get_context(account_dir, user, account).new_page()
except Exception:
print(f" ⚠ Не удалось создать страницу для {(user, account)} — перезапускаю браузер")
_close_context((user, account))
return _get_context(account_dir, user, account).new_page()
def _save_screenshot(page, screenshot_dir, vid): def _save_screenshot(page, screenshot_dir, vid):
@@ -163,10 +208,31 @@ def _save_screenshot(page, screenshot_dir, vid):
return None return None
def _click_second_confirm(page):
"""После клика «Отправить» hh.ru иногда показывает вторую модалку
подтверждения («Отклик будет отправлен») — жмём и её.
Возвращает True, если вторая кнопка найдена и нажата."""
try:
page.wait_for_timeout(1500)
except Exception:
return False
for sel in CONFIRM_BUTTONS:
try:
btn = page.locator(sel).first
if btn.is_visible(timeout=1500):
btn.click()
print(" ✓ Вторая модалка подтверждения — кнопка нажата")
return True
except Exception:
continue
return False
def open_response_page(account_dir, user, account, vacancy, message, def open_response_page(account_dir, user, account, vacancy, message,
resume_id=None, screenshot_dir=None): resume_id=None, screenshot_dir=None, submit=False):
"""Открывает вакансию, жмёт «Откликнуться», выбирает резюме, вставляет текст, """Открывает вакансию, жмёт «Откликнуться», выбирает резюме, вставляет текст.
делает скриншот. Возвращает (ok, note, screenshot_path).""" При submit=True дополнительно жмёт «Отправить» и снимает скриншот результата.
Возвращает (ok, note, screenshot_path)."""
key = (user, account) key = (user, account)
# Закрываем предыдущую страницу этого аккаунта # Закрываем предыдущую страницу этого аккаунта
prev = _pages.pop(key, None) prev = _pages.pop(key, None)
@@ -176,8 +242,7 @@ def open_response_page(account_dir, user, account, vacancy, message,
except Exception: except Exception:
pass pass
context = _get_context(account_dir, user, account) page = _new_page(account_dir, user, account)
page = context.new_page()
_pages[key] = page _pages[key] = page
page.goto(vacancy["url"], timeout=45000) page.goto(vacancy["url"], timeout=45000)
page.wait_for_timeout(2500) page.wait_for_timeout(2500)
@@ -222,11 +287,45 @@ def open_response_page(account_dir, user, account, vacancy, message,
except Exception: except Exception:
continue continue
if not filled:
shot = _save_screenshot(page, screenshot_dir, vacancy["id"]) shot = _save_screenshot(page, screenshot_dir, vacancy["id"])
if filled:
return True, "Текст вставлен. Проверьте скриншот и нажмите «Отправить»", shot
return False, "Не нашёл поле сообщения — проверьте скриншот", shot return False, "Не нашёл поле сообщения — проверьте скриншот", shot
# Одно-кликовый режим: сразу жмём «Отправить»
if submit:
submitted = False
for sel in SUBMIT_BUTTONS:
try:
btn = page.locator(sel).first
if btn.is_visible(timeout=3000):
btn.click()
submitted = True
break
except Exception:
continue
if not submitted:
shot = _save_screenshot(page, screenshot_dir, vacancy["id"])
return False, "Не нашёл кнопку «Отправить» — проверьте скриншот", shot
# hh.ru иногда показывает вторую модалку подтверждения — жмём и её
try:
_click_second_confirm(page)
except Exception:
pass
# Ждём подтверждение и снимаем скриншот результата.
# После отправки hh.ru может закрыть модалку/перезагрузить страницу —
# ловим любые ошибки, но браузер закрываем ВСЕГДА (finally).
try:
page.wait_for_timeout(2500)
shot = _save_screenshot(page, screenshot_dir, vacancy["id"])
except Exception:
shot = None
finally:
_close_context(key)
return True, "Отклик отправлен", shot
shot = _save_screenshot(page, screenshot_dir, vacancy["id"])
return True, "Текст вставлен. Проверьте скриншот и нажмите «Отправить»", shot
def confirm_send(user, account, vid): def confirm_send(user, account, vid):
"""Кликает «Отправить» на открытой странице. Возвращает (ok, note).""" """Кликает «Отправить» на открытой странице. Возвращает (ok, note)."""
@@ -239,7 +338,18 @@ def confirm_send(user, account, vid):
btn = page.locator(sel).first btn = page.locator(sel).first
if btn.is_visible(timeout=3000): if btn.is_visible(timeout=3000):
btn.click() btn.click()
try:
# Вторая модалка подтверждения (если появилась)
_click_second_confirm(page)
except Exception:
pass
try:
page.wait_for_timeout(1500) page.wait_for_timeout(1500)
except Exception:
pass
finally:
# Отклик отправлен — закрываем браузер автоматически
_close_context(key)
return True, "Кнопка «Отправить» нажата. Проверьте статус отклика на hh.ru." return True, "Кнопка «Отправить» нажата. Проверьте статус отклика на hh.ru."
except Exception: except Exception:
continue continue
@@ -249,15 +359,14 @@ def confirm_send(user, account, vid):
def open_login_page(account_dir, user, account): def open_login_page(account_dir, user, account):
"""Открывает hh.ru в браузере (для ручного входа, локальный режим с экраном).""" """Открывает hh.ru в браузере (для ручного входа, локальный режим с экраном)."""
key = (user, account) key = (user, account)
context = _get_context(account_dir, user, account) page = _new_page(account_dir, user, account)
page = context.new_page()
_pages[key] = page _pages[key] = page
page.goto("https://hh.ru", timeout=45000) page.goto("https://hh.ru", timeout=45000)
return True, "Страница hh.ru открыта" return True, "Страница hh.ru открыта"
def close_context(user, account): def _close_context(key):
key = (user, account) """Закрывает контекст и останавливает playwright-инстанс (из браузерного потока)."""
if key in _browsers: if key in _browsers:
try: try:
_browsers[key].close() _browsers[key].close()
@@ -265,15 +374,25 @@ def close_context(user, account):
pass pass
_browsers.pop(key, None) _browsers.pop(key, None)
_pages.pop(key, None) _pages.pop(key, None)
p = _playwrights.pop(key, None)
if p:
try:
p.stop()
except Exception:
pass
def close_context(user, account):
_close_context((user, account))
# ---------- Публичные обёртки (можно вызывать из любого потока) ---------- # ---------- Публичные обёртки (можно вызывать из любого потока) ----------
def browser_open(account_dir, user, account, vacancy, message, def browser_open(account_dir, user, account, vacancy, message,
resume_id=None, screenshot_dir=None): resume_id=None, screenshot_dir=None, submit=False):
return run_in_browser(lambda: open_response_page( return run_in_browser(lambda: open_response_page(
account_dir, user, account, vacancy, message, account_dir, user, account, vacancy, message,
resume_id=resume_id, screenshot_dir=screenshot_dir)) resume_id=resume_id, screenshot_dir=screenshot_dir, submit=submit))
def browser_send(user, account, vid): def browser_send(user, account, vid):
+4
View File
@@ -8,6 +8,10 @@ import urllib.parse
import requests import requests
from dotenv import load_dotenv
load_dotenv()
HEADERS = { HEADERS = {
"User-Agent": ("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " "User-Agent": ("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/120.0 Safari/537.36"), "(KHTML, like Gecko) Chrome/120.0 Safari/537.36"),
+1
View File
@@ -3,3 +3,4 @@ playwright>=1.40.0
flask>=3.0.0 flask>=3.0.0
pypdf>=4.0.0 pypdf>=4.0.0
python-docx>=1.1.0 python-docx>=1.1.0
python-dotenv>=1.0.0
+161 -10
View File
@@ -67,7 +67,7 @@
border-radius: 8px; margin-bottom: 8px; background: var(--card); } border-radius: 8px; margin-bottom: 8px; background: var(--card); }
.account-item .name { font-weight: 600; } .account-item .name { font-weight: 600; }
.account-item .actions { display: flex; gap: 8px; } .account-item .actions { display: flex; gap: 8px; }
.hidden { display: none; } .hidden { display: none !important; }
.texts-panel textarea { min-height: 90px; margin-bottom: 8px; } .texts-panel textarea { min-height: 90px; margin-bottom: 8px; }
.texts-panel .item { margin-bottom: 14px; } .texts-panel .item { margin-bottom: 14px; }
.texts-panel .item .title { font-weight: 600; margin-bottom: 4px; } .texts-panel .item .title { font-weight: 600; margin-bottom: 4px; }
@@ -86,6 +86,12 @@
max-height: 92vh; overflow: auto; } max-height: 92vh; overflow: auto; }
.modal-box img { max-width: 100%; border: 1px solid var(--border); .modal-box img { max-width: 100%; border: 1px solid var(--border);
border-radius: 8px; display: block; } border-radius: 8px; display: block; }
.spinner { width: 18px; height: 18px; border: 2px solid var(--border);
border-top-color: var(--accent); border-radius: 50%;
animation: spin .8s linear infinite; flex-shrink: 0; }
@keyframes spin { to { transform: rotate(360deg); } }
.loading { display: flex; align-items: center; gap: 10px;
color: var(--muted); margin-bottom: 8px; }
::-webkit-scrollbar { width: 10px; height: 10px; } ::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: var(--bg); } ::-webkit-scrollbar-track { background: var(--bg); }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 5px; } ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 5px; }
@@ -240,6 +246,28 @@
</section> </section>
</main> </main>
<!-- ============ МОДАЛКА: ОТПРАВКА ОТКЛИКА ============ -->
<div id="send-modal" class="modal hidden" onclick="if(event.target===this) closeSendModal()">
<div class="modal-box">
<div class="toolbar">
<h2 style="margin:0">Отправить отклик</h2>
<div class="spacer"></div>
<button class="secondary" onclick="closeSendModal()">Закрыть</button>
</div>
<div id="send-vacancy-title" class="muted" style="margin-bottom:8px"></div>
<div id="send-loading" class="loading">
<div class="spinner"></div>
<span>Текст генерируется...</span>
</div>
<textarea id="send-letter" style="min-height:140px" disabled
placeholder="Сопроводительное письмо появится здесь..."></textarea>
<div class="toolbar" style="margin-top:12px">
<button id="btn-send-letter" onclick="sendFromModal()" disabled>Отправить отклик</button>
<span id="send-modal-note" class="muted"></span>
</div>
</div>
</div>
<!-- ============ МОДАЛКА: ПРОВЕРКА ОТКЛИКА ============ --> <!-- ============ МОДАЛКА: ПРОВЕРКА ОТКЛИКА ============ -->
<div id="shot-modal" class="modal hidden" onclick="if(event.target===this) closeShot()"> <div id="shot-modal" class="modal hidden" onclick="if(event.target===this) closeShot()">
<div class="modal-box"> <div class="modal-box">
@@ -252,7 +280,7 @@
<img id="shot-img" alt="Скриншот вакансии"> <img id="shot-img" alt="Скриншот вакансии">
<div class="toolbar" style="margin-top:12px"> <div class="toolbar" style="margin-top:12px">
<button id="btn-send" onclick="confirmSend()">Отправить отклик</button> <button id="btn-send" onclick="confirmSend()">Отправить отклик</button>
<button class="secondary" onclick="markDone(currentShotId)">Отправил (отметить)</button> <button id="btn-done" class="secondary" onclick="markDone(currentShotId)">Отправил (отметить)</button>
<span id="send-note" class="muted"></span> <span id="send-note" class="muted"></span>
</div> </div>
</div> </div>
@@ -263,6 +291,8 @@ let currentAccount = null;
let vacancies = []; let vacancies = [];
let generatedTexts = {}; let generatedTexts = {};
let currentShotId = null; let currentShotId = null;
let sentTexts = new Set();
let sendModalId = null;
// ---------- Вкладки ---------- // ---------- Вкладки ----------
function showTab(name) { function showTab(name) {
@@ -381,6 +411,9 @@ async function loadAccountSettings() {
if (data.has_session) { if (data.has_session) {
sm.className = 'ok'; sm.className = 'ok';
sm.textContent = 'Ключ сохранён на сервере. Меняйте только если истёк.'; sm.textContent = 'Ключ сохранён на сервере. Меняйте только если истёк.';
await loadResumes(); // авто-загрузка списка резюме
// Применяем сохранённый resume_id после того, как опции появились
document.getElementById('cfg-resume-id').value = cfg.resume_id || '';
} else { } else {
sm.className = 'error'; sm.className = 'error';
sm.textContent = 'Ключ не задан — вставьте cookies и нажмите «Сохранить ключ»'; sm.textContent = 'Ключ не задан — вставьте cookies и нажмите «Сохранить ключ»';
@@ -507,6 +540,7 @@ function renderVacancies() {
<td>${exp}</td> <td>${exp}</td>
<td>${status}</td> <td>${status}</td>
<td class="actions-cell"> <td class="actions-cell">
<button class="secondary" onclick="openSendModal('${v.id}')">Отправить отклик</button>
<button class="secondary" onclick="openVacancy('${v.id}')">Открыть</button> <button class="secondary" onclick="openVacancy('${v.id}')">Открыть</button>
<button class="secondary" onclick="markDone('${v.id}')">Отправил</button> <button class="secondary" onclick="markDone('${v.id}')">Отправил</button>
</td> </td>
@@ -557,12 +591,15 @@ function renderTexts(errors = {}) {
vacancies.filter(v => generatedTexts[v.id]).forEach(v => { vacancies.filter(v => generatedTexts[v.id]).forEach(v => {
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'item'; div.className = 'item';
const sent = sentTexts.has(v.id);
div.innerHTML = ` div.innerHTML = `
<div class="title">${v.title} — ${v.company || ''}</div> <div class="title">${v.title} — ${v.company || ''}</div>
<textarea data-id="${v.id}">${escapeHtml(generatedTexts[v.id])}</textarea> <textarea data-id="${v.id}">${escapeHtml(generatedTexts[v.id])}</textarea>
<div class="toolbar"> <div class="toolbar">
<button class="secondary" onclick="openVacancy('${v.id}')">Открыть в браузере</button> ${sent
<button class="secondary" onclick="markDone('${v.id}')">Отправил</button> ? '<button class="secondary" disabled>Отправлено ✓</button>'
: `<button class="secondary" onclick="sendVacancy('${v.id}')">Отправить отклик</button>
<button class="secondary" onclick="openVacancy('${v.id}')">Открыть в браузере</button>`}
<span class="muted" id="note-${v.id}"></span> <span class="muted" id="note-${v.id}"></span>
</div>`; </div>`;
list.appendChild(div); list.appendChild(div);
@@ -582,6 +619,92 @@ function escapeHtml(s) {
} }
// ---------- Открытие, проверка и отправка ---------- // ---------- Открытие, проверка и отправка ----------
async function openSendModal(id) {
if (!currentAccount) { alert('Сначала выберите аккаунт'); return; }
const v = vacancies.find(x => x.id === id);
if (!v) return;
sendModalId = id;
document.getElementById('send-vacancy-title').textContent = `${v.title} — ${v.company || ''}`;
document.getElementById('send-letter').value = '';
document.getElementById('send-letter').disabled = true;
document.getElementById('btn-send-letter').disabled = true;
document.getElementById('send-modal-note').textContent = '';
document.getElementById('send-modal').classList.remove('hidden');
const cached = generatedTexts[id];
if (cached) {
// Текст уже сгенерирован ранее — показываем сразу
document.getElementById('send-letter').value = cached;
document.getElementById('send-letter').disabled = false;
document.getElementById('btn-send-letter').disabled = false;
document.getElementById('send-loading').classList.add('hidden');
return;
}
// Генерируем текст (прелоадер виден)
document.getElementById('send-loading').classList.remove('hidden');
try {
const data = await api(`/api/accounts/${currentAccount}/generate`, {
method: 'POST', body: JSON.stringify({ ids: [id] }),
});
document.getElementById('send-loading').classList.add('hidden');
if (data.texts && data.texts[id]) {
generatedTexts[id] = data.texts[id];
document.getElementById('send-letter').value = data.texts[id];
document.getElementById('send-letter').disabled = false;
document.getElementById('btn-send-letter').disabled = false;
renderTexts();
} else if (data.errors && data.errors[id]) {
document.getElementById('send-modal-note').textContent = 'Ошибка: ' + data.errors[id];
}
} catch (e) {
document.getElementById('send-loading').classList.add('hidden');
document.getElementById('send-modal-note').textContent = 'Ошибка: ' + e.message;
}
}
function closeSendModal() {
document.getElementById('send-modal').classList.add('hidden');
}
async function sendFromModal() {
if (!currentAccount || !sendModalId) return;
const message = document.getElementById('send-letter').value;
const btn = document.getElementById('btn-send-letter');
const note = document.getElementById('send-modal-note');
btn.disabled = true; note.textContent = 'Отправляю...';
try {
const data = await api(`/api/accounts/${currentAccount}/send-now`, {
method: 'POST', body: JSON.stringify({ id: sendModalId, message }),
});
if (data.screenshot) {
showShotModal(sendModalId, data.screenshot, data.note || 'Отклик отправлен', true);
sentTexts.add(sendModalId);
const v = vacancies.find(x => x.id === sendModalId);
if (v) v.responded = true;
renderVacancies();
renderTexts();
closeSendModal();
} else {
note.textContent = data.note || 'Не удалось отправить отклик';
btn.disabled = false;
}
} catch (e) {
note.textContent = 'Ошибка: ' + e.message;
btn.disabled = false;
}
}
function showShotModal(id, screenshot, note, sent) {
currentShotId = id;
document.getElementById('shot-img').src = screenshot + '?t=' + Date.now();
document.getElementById('shot-note').textContent = note;
document.getElementById('send-note').textContent = '';
document.getElementById('btn-send').style.display = sent ? 'none' : '';
document.getElementById('btn-done').style.display = sent ? 'none' : '';
document.getElementById('shot-modal').classList.remove('hidden');
}
async function openVacancy(id) { async function openVacancy(id) {
const ta = document.querySelector(`textarea[data-id="${id}"]`); const ta = document.querySelector(`textarea[data-id="${id}"]`);
const message = ta ? ta.value : (generatedTexts[id] || ''); const message = ta ? ta.value : (generatedTexts[id] || '');
@@ -593,12 +716,7 @@ async function openVacancy(id) {
}); });
if (note) note.textContent = data.note || ''; if (note) note.textContent = data.note || '';
if (data.screenshot) { if (data.screenshot) {
currentShotId = id; showShotModal(id, data.screenshot, data.note || '', false);
document.getElementById('shot-img').src = data.screenshot + '?t=' + Date.now();
document.getElementById('shot-note').textContent = data.note || '';
document.getElementById('send-note').textContent = '';
document.getElementById('btn-send').disabled = false;
document.getElementById('shot-modal').classList.remove('hidden');
} else { } else {
alert(data.note || 'Не удалось открыть вакансию'); alert(data.note || 'Не удалось открыть вакансию');
} }
@@ -608,11 +726,39 @@ async function openVacancy(id) {
} }
} }
async function sendVacancy(id) {
if (!currentAccount) { alert('Сначала выберите аккаунт'); return; }
const ta = document.querySelector(`textarea[data-id="${id}"]`);
const message = ta ? ta.value : (generatedTexts[id] || '');
const note = document.getElementById(`note-${id}`);
if (note) { note.textContent = 'Отправляю отклик...'; }
try {
const data = await api(`/api/accounts/${currentAccount}/send-now`, {
method: 'POST', body: JSON.stringify({ id, message }),
});
if (note) note.textContent = data.note || '';
if (data.screenshot) {
showShotModal(id, data.screenshot, data.note || 'Отклик отправлен', true);
sentTexts.add(id);
const v = vacancies.find(x => x.id === id);
if (v) v.responded = true;
renderVacancies();
renderTexts();
} else {
alert(data.note || 'Не удалось отправить отклик');
}
} catch (e) {
if (note) note.textContent = 'Ошибка: ' + e.message;
alert('Ошибка: ' + e.message);
}
}
function closeShot() { function closeShot() {
document.getElementById('shot-modal').classList.add('hidden'); document.getElementById('shot-modal').classList.add('hidden');
} }
async function confirmSend() { async function confirmSend() {
if (!currentAccount) { alert('Сначала выберите аккаунт'); return; }
const btn = document.getElementById('btn-send'); const btn = document.getElementById('btn-send');
const note = document.getElementById('send-note'); const note = document.getElementById('send-note');
btn.disabled = true; note.textContent = 'Отправляю...'; btn.disabled = true; note.textContent = 'Отправляю...';
@@ -624,7 +770,9 @@ async function confirmSend() {
if (data.ok) { if (data.ok) {
const v = vacancies.find(x => x.id === currentShotId); const v = vacancies.find(x => x.id === currentShotId);
if (v) v.responded = true; if (v) v.responded = true;
sentTexts.add(currentShotId);
renderVacancies(); renderVacancies();
renderTexts();
} }
} catch (e) { } catch (e) {
note.textContent = 'Ошибка: ' + e.message; note.textContent = 'Ошибка: ' + e.message;
@@ -633,12 +781,15 @@ async function confirmSend() {
} }
async function markDone(id) { async function markDone(id) {
if (!currentAccount) { alert('Сначала выберите аккаунт'); return; }
await api(`/api/accounts/${currentAccount}/done`, { await api(`/api/accounts/${currentAccount}/done`, {
method: 'POST', body: JSON.stringify({ id }), method: 'POST', body: JSON.stringify({ id }),
}); });
const v = vacancies.find(x => x.id === id); const v = vacancies.find(x => x.id === id);
if (v) v.responded = true; if (v) v.responded = true;
sentTexts.add(id);
renderVacancies(); renderVacancies();
renderTexts();
const note = document.getElementById(`note-${id}`); const note = document.getElementById(`note-${id}`);
if (note) note.textContent = 'Отмечено как отправленное'; if (note) note.textContent = 'Отмечено как отправленное';
} }
+4
View File
@@ -29,6 +29,9 @@
button:hover { background: var(--accent-hover); } button:hover { background: var(--accent-hover); }
.error { color: var(--red); font-size: 13px; text-align: center; margin-top: 10px; .error { color: var(--red); font-size: 13px; text-align: center; margin-top: 10px;
min-height: 20px; } min-height: 20px; }
.link { text-align: center; margin-top: 14px; font-size: 13px; }
.link a { color: var(--accent); text-decoration: none; }
.link a:hover { color: var(--accent-hover); }
</style> </style>
</head> </head>
<body> <body>
@@ -39,6 +42,7 @@
<input type="password" id="password" placeholder="Пароль" autocomplete="current-password"> <input type="password" id="password" placeholder="Пароль" autocomplete="current-password">
<button onclick="doLogin()">Войти</button> <button onclick="doLogin()">Войти</button>
<div id="msg" class="error"></div> <div id="msg" class="error"></div>
<div class="link">Нет аккаунта? <a href="/register">Зарегистрироваться</a></div>
</div> </div>
<script> <script>
async function doLogin() { async function doLogin() {
+72
View File
@@ -0,0 +1,72 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Регистрация — hh.ru автоотклик</title>
<style>
:root {
--bg: #0f1117; --card: #1a1d27; --border: #2a2f3a; --text: #e5e7eb;
--muted: #9ca3af; --accent: #3b82f6; --accent-hover: #60a5fa;
--input-bg: #12141c; --red: #ef4444;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
background: var(--bg); color: var(--text); font-size: 14px;
display: flex; align-items: center; justify-content: center;
min-height: 100vh; }
.login-box { background: var(--card); border: 1px solid var(--border);
border-radius: 12px; padding: 32px; width: 100%; max-width: 360px; }
.login-box h1 { font-size: 18px; text-align: center; margin-bottom: 4px; }
.login-box .muted { text-align: center; font-size: 13px; margin-bottom: 20px; }
input { width: 100%; padding: 10px 12px; border: 1px solid var(--border);
border-radius: 8px; font-size: 14px; font-family: inherit;
background: var(--input-bg); color: var(--text); margin-bottom: 12px; }
input:focus { outline: none; border-color: var(--accent); }
button { width: 100%; padding: 10px; border: none; border-radius: 8px;
cursor: pointer; font-size: 14px; background: var(--accent); color: #fff;
font-weight: 500; }
button:hover { background: var(--accent-hover); }
.error { color: var(--red); font-size: 13px; text-align: center; margin-top: 10px;
min-height: 20px; }
.link { text-align: center; margin-top: 14px; font-size: 13px; }
.link a { color: var(--accent); text-decoration: none; }
.link a:hover { color: var(--accent-hover); }
</style>
</head>
<body>
<div class="login-box">
<h1>hh.ru автоотклик</h1>
<p class="muted">Регистрация</p>
<input type="text" id="username" placeholder="Логин (3-30 символов)" autocomplete="username" autofocus>
<input type="password" id="password" placeholder="Пароль (мин. 6 символов)" autocomplete="new-password">
<input type="password" id="confirm" placeholder="Повторите пароль" autocomplete="new-password">
<button onclick="doRegister()">Зарегистрироваться</button>
<div id="msg" class="error"></div>
<div class="link">Уже есть аккаунт? <a href="/login">Войти</a></div>
</div>
<script>
async function doRegister() {
const msg = document.getElementById('msg');
msg.textContent = '';
try {
const resp = await fetch('/register', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
username: document.getElementById('username').value,
password: document.getElementById('password').value,
confirm: document.getElementById('confirm').value,
}),
});
const data = await resp.json();
if (!resp.ok) { msg.textContent = data.error || 'Ошибка регистрации'; return; }
window.location = '/';
} catch (e) { msg.textContent = 'Ошибка сети'; }
}
document.getElementById('confirm').addEventListener('keydown', e => {
if (e.key === 'Enter') doRegister();
});
</script>
</body>
</html>
+96 -9
View File
@@ -9,7 +9,7 @@
показывается скриншотом, «Отправить» нажимается по подтверждению человека. показывается скриншотом, «Отправить» нажимается по подтверждению человека.
Запуск: .venv/bin/python webui.py Запуск: .venv/bin/python webui.py
Открыть: http://localhost:5000 Открыть: http://localhost:18091
""" """
import io import io
import json import json
@@ -20,9 +20,14 @@ import time
from datetime import date from datetime import date
from functools import wraps from functools import wraps
from dotenv import load_dotenv
# Загружаем .env (LLM_API_KEY, SESSION_SECRET, PORT, HH_HEADLESS и т.д.)
load_dotenv()
from flask import (Flask, jsonify, render_template, request, session, from flask import (Flask, jsonify, render_template, request, session,
redirect, url_for, send_file) redirect, url_for, send_file)
from werkzeug.security import check_password_hash from werkzeug.security import check_password_hash, generate_password_hash
from hh_search import (search_vacancies, fetch_vacancy_details, fetch_my_resumes, from hh_search import (search_vacancies, fetch_vacancy_details, fetch_my_resumes,
parse_cookies, get_proxies) parse_cookies, get_proxies)
@@ -168,6 +173,15 @@ def save_progress(acc_dir, progress):
write_json(os.path.join(acc_dir, "progress.json"), progress) write_json(os.path.join(acc_dir, "progress.json"), progress)
def mark_responded(acc_dir, vid):
"""Добавляет вакансию в список откликнутых."""
progress = get_progress(acc_dir)
responded = set(progress.get("responded", []))
responded.add(vid)
progress["responded"] = sorted(responded)
save_progress(acc_dir, progress)
def get_cache(user, account): def get_cache(user, account):
with _cache_lock: with _cache_lock:
return _cache.setdefault((user, account), {}) return _cache.setdefault((user, account), {})
@@ -208,6 +222,47 @@ def login_page():
return render_template("login.html") return render_template("login.html")
@app.route("/register", methods=["GET", "POST"])
def register_page():
"""Простая регистрация: логин + пароль. Отключается через ALLOW_REGISTRATION=0."""
if os.environ.get("ALLOW_REGISTRATION", "1") != "1":
if request.method == "POST":
return jsonify({"error": "Регистрация отключена"}), 403
return redirect(url_for("login_page"))
if request.method == "POST":
data = request.get_json(force=True)
username = (data.get("username") or "").strip()
password = data.get("password") or ""
confirm = data.get("confirm") or ""
# Проверки
if not re.match(r"^[a-zA-Z0-9_.-]{3,30}$", username):
return jsonify({"error": "Логин: 3-30 символов, буквы/цифры/точка/дефис/подчёркивание"}), 400
if len(password) < 6:
return jsonify({"error": "Пароль: минимум 6 символов"}), 400
if password != confirm:
return jsonify({"error": "Пароли не совпадают"}), 400
users = load_users()
if username in users:
return jsonify({"error": "Логин уже занят"}), 409
limit = int(os.environ.get("REGISTER_LLM_LIMIT", "50") or 0)
users[username] = {
"password_hash": generate_password_hash(password),
"llm_limit": limit,
"llm_usage": {},
"created": date.today().isoformat(),
"registered": True,
}
save_users(users)
os.makedirs(os.path.join(USERS_DIR, username, "accounts"), exist_ok=True)
session["user"] = username # сразу входим
return jsonify({"ok": True, "limit": limit})
return render_template("register.html")
@app.route("/logout") @app.route("/logout")
def logout(): def logout():
session.clear() session.clear()
@@ -493,6 +548,40 @@ def api_open(name):
return jsonify({"error": f"Ошибка браузера: {e}"}), 500 return jsonify({"error": f"Ошибка браузера: {e}"}), 500
@app.route("/api/accounts/<name>/send-now", methods=["POST"])
@login_required
def api_send_now(name):
"""Одно-кликовый режим: открывает вакансию, вставляет текст, жмёт «Отправить»,
делает скриншот результата и помечает вакансию как откликнутую."""
acc_dir = account_path(name)
if not os.path.isdir(acc_dir):
return jsonify({"error": "Аккаунт не найден"}), 404
data = request.get_json(force=True)
vid = data.get("id")
message = data.get("message") or ""
cfg = read_json(os.path.join(acc_dir, "config.json"), {})
resume_id = cfg.get("resume_id")
cache = get_cache(current_user(), name)
vacancy = cache.get(vid)
if not vacancy:
return jsonify({"error": "Вакансия не найдена в кэше — обновите поиск"}), 404
try:
shot_dir = os.path.join(acc_dir, "screenshots")
ok, note, shot = browser_open(acc_dir, current_user(), name, vacancy, message,
resume_id=resume_id, screenshot_dir=shot_dir,
submit=True)
if ok:
mark_responded(acc_dir, vid)
return jsonify({
"ok": ok,
"note": note,
"screenshot": f"/api/accounts/{name}/screenshot/{vid}" if shot else None,
})
except Exception as e:
return jsonify({"error": f"Ошибка браузера: {e}"}), 500
@app.route("/api/accounts/<name>/send", methods=["POST"]) @app.route("/api/accounts/<name>/send", methods=["POST"])
@login_required @login_required
def api_send(name): def api_send(name):
@@ -530,11 +619,7 @@ def api_done(name):
return jsonify({"error": "Аккаунт не найден"}), 404 return jsonify({"error": "Аккаунт не найден"}), 404
data = request.get_json(force=True) data = request.get_json(force=True)
vid = data.get("id") vid = data.get("id")
progress = get_progress(acc_dir) mark_responded(acc_dir, vid)
responded = set(progress.get("responded", []))
responded.add(vid)
progress["responded"] = sorted(responded)
save_progress(acc_dir, progress)
return jsonify({"ok": True}) return jsonify({"ok": True})
@@ -549,5 +634,7 @@ def api_reset(name):
if __name__ == "__main__": if __name__ == "__main__":
print("Откройте http://localhost:5000") port = int(os.environ.get("PORT", "18091"))
app.run(host="127.0.0.1", port=5000, debug=False) host = os.environ.get("HOST", "127.0.0.1")
print(f"Откройте http://localhost:{port}")
app.run(host=host, port=port, debug=False)