Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7619f28fe | ||
|
|
31fd316d51 | ||
|
|
3d5610ca79 | ||
|
|
ec9f342cf6 | ||
|
|
2d781737f2 | ||
|
|
e4cd06a23d | ||
|
|
4dcaffd4c4 | ||
|
|
7738d35f3d | ||
|
|
40c2c86452 | ||
|
|
a79be642f0 | ||
|
|
963c29084c | ||
|
|
adf6fd24f7 | ||
|
|
25b46a0b2a |
No files matched your search
+7
-1
@@ -14,4 +14,10 @@ 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
|
||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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`)
|
||||||
+139
-20
@@ -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,10 +287,44 @@ 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"])
|
||||||
|
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"])
|
shot = _save_screenshot(page, screenshot_dir, vacancy["id"])
|
||||||
if filled:
|
return True, "Текст вставлен. Проверьте скриншот и нажмите «Отправить»", shot
|
||||||
return True, "Текст вставлен. Проверьте скриншот и нажмите «Отправить»", shot
|
|
||||||
return False, "Не нашёл поле сообщения — проверьте скриншот", shot
|
|
||||||
|
|
||||||
|
|
||||||
def confirm_send(user, account, vid):
|
def confirm_send(user, account, vid):
|
||||||
@@ -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()
|
||||||
page.wait_for_timeout(1500)
|
try:
|
||||||
|
# Вторая модалка подтверждения (если появилась)
|
||||||
|
_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
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
+2
-1
@@ -2,4 +2,5 @@ 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
|
||||||
+161
-10
@@ -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 = 'Отмечено как отправленное';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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)
|
||||||
Reference in new issue
Block a user