Compare commits

..
11 changed files with 98 additions and 663 deletions

No files matched your search

+1 -7
View File
@@ -14,10 +14,4 @@ SESSION_SECRET=
HH_HEADLESS=0 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
+5 -20
View File
@@ -9,7 +9,7 @@
│ nginx │ Let's Encrypt │ nginx │ Let's Encrypt
│ reverse proxy│ │ reverse proxy│
└───────┬───────┘ └───────┬───────┘
│ :18091 │ :5000
▼ ▼
gunicorn (Flask: авторизация, изоляция данных, лимиты генераций) gunicorn (Flask: авторизация, изоляция данных, лимиты генераций)
│ │
@@ -69,9 +69,7 @@
- ОС: **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`
--- ---
@@ -156,19 +154,6 @@ 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
@@ -201,7 +186,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:18091 webui:app -w 1 --threads 8 --timeout 300 -b 127.0.0.1:5000 webui:app
Restart=always Restart=always
RestartSec=5 RestartSec=5
LimitNOFILE=65536 LimitNOFILE=65536
@@ -245,7 +230,7 @@ server {
client_max_body_size 20M; client_max_body_size 20M;
location / { location / {
proxy_pass http://127.0.0.1:18091; proxy_pass http://127.0.0.1:5000;
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;
@@ -296,7 +281,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:18091/api/accounts > /dev/null || systemctl restart hh-web curl -sf http://127.0.0.1:5000/api/accounts > /dev/null || systemctl restart hh-web
``` ```
--- ---
+8 -38
View File
@@ -1,9 +1,9 @@
# Автоотклик на hh.ru (полуавтоматический) # Автоотклик на hh.ru (полуавтоматический, безопасный)
Скрипт ищет вакансии на hh.ru по ключевым словам, генерирует персонализированный Скрипт ищет вакансии на hh.ru по ключевым словам, генерирует персонализированный
текст отклика через ИИ (на основе вашего резюме и описания вакансии), открывает текст отклика через ИИ (на основе вашего резюме и описания вакансии), открывает
страницу в браузере, подставляет текст и резюме. Отправка — в один клик страницу в браузере и подставляет текст. **Кнопку «Отправить» всегда нажимаете вы** —
(«Отправить отклик»), либо с проверкой текста перед отправкой. это не нарушает правила hh.ru и не рискует аккаунтом.
## Установка ## Установка
@@ -25,30 +25,17 @@ PLAYWRIGHT_DOWNLOAD_HOST=https://cdn.npmmirror.com/binaries/playwright .venv/bin
.venv/bin/python webui.py .venv/bin/python webui.py
``` ```
Откройте **http://localhost:18091** (порт меняется через `PORT`) — там можно: Откройте **http://localhost:5000** — там можно:
- регистрироваться и входить (открытая регистрация, отключается `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 из браузера:
@@ -102,30 +89,13 @@ 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`)
+60 -234
View File
@@ -15,10 +15,6 @@ 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 (с запасными вариантами)
@@ -27,37 +23,21 @@ RESPONSE_BUTTONS = [
'[data-qa="vacancy-response-link"]', '[data-qa="vacancy-response-link"]',
'button:has-text("Откликнуться")', 'button:has-text("Откликнуться")',
] ]
# hh.ru прячет поле письма за переключателем «Сопроводительное письмо / Добавить»:
# до клика по нему на форме есть только textarea вопроса скрининга (task_*).
# ВАЖНО: запасной селектор 'textarea' НЕ используется, если есть вопросы скрининга —
# иначе текст письма попадёт в первый вопрос вместо поля письма.
LETTER_TOGGLE = '[data-qa="vacancy-response-letter-toggle"]'
MESSAGE_FIELDS = [ MESSAGE_FIELDS = [
'textarea[data-qa="vacancy-response-popup-form-letter-input"]',
'textarea[data-qa="vacancy-response-letter"]', 'textarea[data-qa="vacancy-response-letter"]',
'textarea[data-qa="vacancy-response-popup-form"] textarea', 'textarea[data-qa="vacancy-response-popup-form"] textarea',
'textarea',
] ]
SUBMIT_BUTTONS = [ SUBMIT_BUTTONS = [
'[data-qa="vacancy-response-submit-popup"]',
'[data-qa="vacancy-response-submit"]', '[data-qa="vacancy-response-submit"]',
'[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 = {}
# ---------- Браузерный поток ---------- # ---------- Браузерный поток ----------
@@ -127,8 +107,9 @@ def get_proxy_config():
# ---------- Внутренние функции (только из браузерного потока) ---------- # ---------- Внутренние функции (только из браузерного потока) ----------
def _launch_context_with(p, account_dir): def _launch_context(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(),
@@ -142,15 +123,6 @@ def _launch_context_with(p, 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")
@@ -173,33 +145,10 @@ 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)
ctx = _browsers.get(key) if key not in _browsers:
if ctx is None or not _context_alive(ctx): _browsers[key] = _launch_context(account_dir)
if ctx is not None: return _browsers[key]
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):
@@ -214,33 +163,10 @@ 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, submit=False): resume_id=None, screenshot_dir=None):
"""Открывает вакансию, жмёт «Откликнуться», выбирает резюме, вставляет текст. """Открывает вакансию, жмёт «Откликнуться», выбирает резюме, вставляет текст,
При submit=True дополнительно жмёт «Отправить» и снимает скриншот результата. делает скриншот. Возвращает (ok, note, screenshot_path)."""
Возвращает (ok, note, screenshot_path).
Браузер закрывается ВСЕГДА, кроме ручного режима (submit=False, успех) —
там он нужен для confirm_send."""
key = (user, account) key = (user, account)
# Закрываем предыдущую страницу этого аккаунта # Закрываем предыдущую страницу этого аккаунта
prev = _pages.pop(key, None) prev = _pages.pop(key, None)
@@ -250,134 +176,56 @@ def open_response_page(account_dir, user, account, vacancy, message,
except Exception: except Exception:
pass pass
page = _new_page(account_dir, user, account) context = _get_context(account_dir, user, account)
page = context.new_page()
_pages[key] = page _pages[key] = page
keep_open = False page.goto(vacancy["url"], timeout=45000)
try: page.wait_for_timeout(2500)
page.goto(vacancy["url"], timeout=45000)
page.wait_for_timeout(2500)
# Кнопка «Откликнуться» # Кнопка «Откликнуться»
clicked = False clicked = False
for sel in RESPONSE_BUTTONS: for sel in RESPONSE_BUTTONS:
try:
btn = page.locator(sel).first
if btn.is_visible(timeout=3000):
btn.click()
clicked = True
break
except Exception:
continue
if not clicked:
shot = _save_screenshot(page, screenshot_dir, vacancy["id"])
return False, "Не нашёл кнопку «Откликнуться» — проверьте скриншот", shot
# Ждём модалку
page.wait_for_timeout(2500)
# hh.ru прячет поле письма за переключателем «Сопроводительное письмо» —
# раскрываем его, иначе заполнится textarea вопроса скрининга (task_*)
try: try:
toggle = page.locator(LETTER_TOGGLE).first btn = page.locator(sel).first
toggle.wait_for(state="visible", timeout=5000) if btn.is_visible(timeout=3000):
toggle.click() btn.click()
page.wait_for_timeout(800) clicked = True
print(" ✓ Переключатель «Сопроводительное письмо» раскрыт") break
except Exception: except Exception:
print(" ⚠ Переключатель письма не найден — поле может отсутствовать") continue
if not clicked:
# Выбор резюме (радио-кнопка name="resumeId" value="{hash}")
if resume_id:
try:
radio = page.locator(f'input[name="resumeId"][value="{resume_id}"]').first
if radio.is_visible(timeout=3000):
radio.check(force=True)
print(f" ✓ Резюме {resume_id} выбрано")
except Exception:
print(" ⚠ Не нашёл радио-кнопку резюме — выберите вручную")
# Вставляем текст
filled = False
for sel in MESSAGE_FIELDS:
try:
field = page.locator(sel).first
if field.is_visible(timeout=3000):
field.fill(message)
filled = True
break
except Exception:
continue
if not filled:
# Запасной вариант: если вопросов скрининга НЕТ, единственная
# textarea на форме — это поле письма (старая версия формы).
# При наличии task_* НЕ заполняем — иначе письмо попадёт в вопрос.
try:
if page.locator('textarea[name^="task_"]').count() == 0:
field = page.locator('textarea').first
if field.is_visible(timeout=2000):
field.fill(message)
filled = True
except Exception:
pass
if not filled:
shot = _save_screenshot(page, screenshot_dir, vacancy["id"])
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 может закрыть модалку/перезагрузить страницу —
# ловим любые ошибки, но отклик уже отправлен.
try:
page.wait_for_timeout(2500)
# Проверяем, не осталась ли форма открытой с ошибкой валидации
# (например, не заполнены обязательные вопросы скрининга)
form_open = page.locator('[data-qa="vacancy-response-popup-form"]').count()
err_visible = False
if form_open:
for sel in ['[data-qa="vacancy-response-error"]',
'[data-qa="vacancy-response-popup-form"] [class*="error"]']:
try:
if page.locator(sel).first.is_visible(timeout=1000):
err_visible = True
break
except Exception:
continue
shot = _save_screenshot(page, screenshot_dir, vacancy["id"])
if form_open and err_visible:
return False, "hh.ru не принял отклик — проверьте скриншот (возможно, не заполнены обязательные вопросы)", shot
except Exception:
shot = None
return True, "Отклик отправлен", shot
# Ручной режим: браузер оставляем открытым для confirm_send
keep_open = True
shot = _save_screenshot(page, screenshot_dir, vacancy["id"]) shot = _save_screenshot(page, screenshot_dir, vacancy["id"])
return False, "Не нашёл кнопку «Откликнуться» — проверьте скриншот", shot
# Ждём модалку
page.wait_for_timeout(2000)
# Выбор резюме (радио-кнопка name="resumeId" value="{hash}")
if resume_id:
try:
radio = page.locator(f'input[name="resumeId"][value="{resume_id}"]').first
if radio.is_visible(timeout=3000):
radio.check(force=True)
print(f" ✓ Резюме {resume_id} выбрано")
except Exception:
print(" ⚠ Не нашёл радио-кнопку резюме — выберите вручную")
# Вставляем текст
filled = False
for sel in MESSAGE_FIELDS:
try:
field = page.locator(sel).first
if field.is_visible(timeout=3000):
field.fill(message)
filled = True
break
except Exception:
continue
shot = _save_screenshot(page, screenshot_dir, vacancy["id"])
if filled:
return True, "Текст вставлен. Проверьте скриншот и нажмите «Отправить»", shot return True, "Текст вставлен. Проверьте скриншот и нажмите «Отправить»", shot
finally: return False, "Не нашёл поле сообщения — проверьте скриншот", shot
# Браузер закрываем ВСЕГДА, кроме ручного режима (нужен для confirm_send)
if not keep_open:
_close_context(key)
def confirm_send(user, account, vid): def confirm_send(user, account, vid):
@@ -391,37 +239,25 @@ 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: page.wait_for_timeout(1500)
# Вторая модалка подтверждения (если появилась)
_click_second_confirm(page)
except Exception:
pass
try:
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
# Кнопка не найдена — браузер больше не нужен, закрываем
_close_context(key)
return False, "Не нашёл кнопку «Отправить» — возможно, форма уже закрыта" return False, "Не нашёл кнопку «Отправить» — возможно, форма уже закрыта"
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)
page = _new_page(account_dir, user, account) context = _get_context(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(key): def close_context(user, account):
"""Закрывает контекст и останавливает playwright-инстанс (из браузерного потока).""" key = (user, account)
if key in _browsers: if key in _browsers:
try: try:
_browsers[key].close() _browsers[key].close()
@@ -429,25 +265,15 @@ def _close_context(key):
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, submit=False): resume_id=None, screenshot_dir=None):
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, submit=submit)) resume_id=resume_id, screenshot_dir=screenshot_dir))
def browser_send(user, account, vid): def browser_send(user, account, vid):
+1 -4
View File
@@ -90,10 +90,7 @@ def generate_response(llm_config, vacancy, resume_text, style_hint=""):
raise RuntimeError(f"LLM ошибка {resp.status_code}: {resp.text[:300]}") raise RuntimeError(f"LLM ошибка {resp.status_code}: {resp.text[:300]}")
data = resp.json() data = resp.json()
content = (data["choices"][0]["message"].get("content") or "").strip() return data["choices"][0]["message"]["content"].strip()
if not content:
raise RuntimeError("LLM вернул пустой текст — попробуйте ещё раз")
return content
def generate_response_with_retry(llm_config, vacancy, resume_text, style_hint="", attempts=2): def generate_response_with_retry(llm_config, vacancy, resume_text, style_hint="", attempts=2):
-4
View File
@@ -8,10 +8,6 @@ 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 -2
View File
@@ -2,5 +2,4 @@ requests>=2.31.0
playwright>=1.40.0 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
+10 -179
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 !important; } .hidden { display: none; }
.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,12 +86,6 @@
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; }
@@ -246,28 +240,6 @@
</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">
@@ -280,7 +252,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 id="btn-done" class="secondary" onclick="markDone(currentShotId)">Отправил (отметить)</button> <button class="secondary" onclick="markDone(currentShotId)">Отправил (отметить)</button>
<span id="send-note" class="muted"></span> <span id="send-note" class="muted"></span>
</div> </div>
</div> </div>
@@ -291,8 +263,6 @@ 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) {
@@ -411,9 +381,6 @@ 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 и нажмите «Сохранить ключ»';
@@ -540,7 +507,6 @@ 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>
@@ -591,15 +557,12 @@ 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">
${sent <button class="secondary" onclick="openVacancy('${v.id}')">Открыть в браузере</button>
? '<button class="secondary" disabled>Отправлено ✓</button>' <button class="secondary" onclick="markDone('${v.id}')">Отправил</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);
@@ -619,104 +582,6 @@ 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];
} else {
document.getElementById('send-modal-note').textContent = 'Не удалось сгенерировать текст — попробуйте ещё раз';
}
} 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.ok) {
// Отклик отправлен — статус меняем всегда, даже если скриншота нет
sentTexts.add(sendModalId);
const v = vacancies.find(x => x.id === sendModalId);
if (v) v.responded = true;
renderVacancies();
renderTexts();
closeSendModal();
if (data.screenshot) {
showShotModal(sendModalId, data.screenshot, data.note || 'Отклик отправлен', true);
} else {
alert(data.note || 'Отклик отправлен');
}
} else if (data.screenshot) {
// Бэкенд вернул скриншот, но отклик НЕ отправлен (например, не нашёл поле)
note.textContent = data.note || 'Не удалось отправить отклик';
btn.disabled = false;
showShotModal(sendModalId, data.screenshot, data.note || 'Не удалось отправить отклик', false);
} 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] || '');
@@ -728,7 +593,12 @@ async function openVacancy(id) {
}); });
if (note) note.textContent = data.note || ''; if (note) note.textContent = data.note || '';
if (data.screenshot) { if (data.screenshot) {
showShotModal(id, data.screenshot, data.note || '', false); currentShotId = id;
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 || 'Не удалось открыть вакансию');
} }
@@ -738,45 +608,11 @@ 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.ok) {
sentTexts.add(id);
const v = vacancies.find(x => x.id === id);
if (v) v.responded = true;
renderVacancies();
renderTexts();
if (data.screenshot) {
showShotModal(id, data.screenshot, data.note || 'Отклик отправлен', true);
} else {
alert(data.note || 'Отклик отправлен');
}
} else if (data.screenshot) {
showShotModal(id, data.screenshot, data.note || 'Не удалось отправить отклик', false);
} 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 = 'Отправляю...';
@@ -788,9 +624,7 @@ 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;
@@ -799,15 +633,12 @@ 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,9 +29,6 @@
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>
@@ -42,7 +39,6 @@
<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
@@ -1,72 +0,0 @@
<!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>
+12 -99
View File
@@ -9,7 +9,7 @@
показывается скриншотом, «Отправить» нажимается по подтверждению человека. показывается скриншотом, «Отправить» нажимается по подтверждению человека.
Запуск: .venv/bin/python webui.py Запуск: .venv/bin/python webui.py
Открыть: http://localhost:18091 Открыть: http://localhost:5000
""" """
import io import io
import json import json
@@ -20,18 +20,13 @@ 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, generate_password_hash from werkzeug.security import check_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)
from hh_llm import generate_response_with_retry from hh_llm import generate_response
from hh_browser import browser_open, browser_send, browser_close from hh_browser import browser_open, browser_send, browser_close
BASE_DIR = os.path.dirname(os.path.abspath(__file__)) BASE_DIR = os.path.dirname(os.path.abspath(__file__))
@@ -173,15 +168,6 @@ 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), {})
@@ -222,47 +208,6 @@ 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()
@@ -508,8 +453,8 @@ def api_generate(name):
details = fetch_vacancy_details(vid, proxies=get_proxies()) or {} details = fetch_vacancy_details(vid, proxies=get_proxies()) or {}
vacancy_full = {**vacancy, **details} vacancy_full = {**vacancy, **details}
try: try:
texts[vid] = generate_response_with_retry(cfg.get("llm", {}), vacancy_full, texts[vid] = generate_response(cfg.get("llm", {}), vacancy_full,
resume, cfg.get("style", "")) resume, cfg.get("style", ""))
if limit is not None: if limit is not None:
used += 1 used += 1
bump_llm_usage(current_user()) bump_llm_usage(current_user())
@@ -548,40 +493,6 @@ 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):
@@ -619,7 +530,11 @@ 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")
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)
return jsonify({"ok": True}) return jsonify({"ok": True})
@@ -634,7 +549,5 @@ def api_reset(name):
if __name__ == "__main__": if __name__ == "__main__":
port = int(os.environ.get("PORT", "18091")) print("Откройте http://localhost:5000")
host = os.environ.get("HOST", "127.0.0.1") app.run(host="127.0.0.1", port=5000, debug=False)
print(f"Откройте http://localhost:{port}")
app.run(host=host, port=port, debug=False)