Compare commits

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

No files matched your search

+23
View File
@@ -0,0 +1,23 @@
# Ключ LLM (обязательно) — используется для генерации текстов откликов
LLM_API_KEY=
LLM_BASE_URL=https://opencode.ai/zen/v1
LLM_MODEL=deepseek-v4-flash
# Прокси для hh.ru (необязательно): http://LOGIN:PASS@HOST:PORT
PROXY_URL=
# Секрет сессии Flask (обязательно на сервере):
# python3 -c "import secrets; print(secrets.token_hex(32))"
SESSION_SECRET=
# Браузер: 1 = headless (без дисплея), 0/пусто = видимый (на сервере нужен Xvfb)
HH_HEADLESS=0
# На сервере без sandbox (root/контейнер): 1
HH_NO_SANDBOX=0
# Регистрация: 1 = открытая (любой может создать аккаунт), 0 = только через manage_users.py
ALLOW_REGISTRATION=1
# Дневной лимит генераций для самостоятельно зарегистрированных пользователей
REGISTER_LLM_LIMIT=50
+4
View File
@@ -1,6 +1,10 @@
# Секреты — не коммитить # Секреты — не коммитить
.env .env
# Пользователи сервиса — личные данные (пароли, аккаунты, резюме, сессии, прогресс)
users/
users.json
# Аккаунты пользователя — личные данные (config, резюме, сессии, прогресс) # Аккаунты пользователя — личные данные (config, резюме, сессии, прогресс)
accounts/* accounts/*
!accounts/example/ !accounts/example/
+365
View File
@@ -0,0 +1,365 @@
# Деплой hh_auto на физический Linux-сервер (многопользовательский, без VNC)
## Архитектура
```
Браузер пользователя
│ HTTPS (443)
┌───────┴───────┐
│ nginx │ Let's Encrypt
│ reverse proxy│
└───────┬───────┘
│ :18091
▼
gunicorn (Flask: авторизация, изоляция данных, лимиты генераций)
│
▼
Playwright (Chromium: headless или на Xvfb :99)
│
▼
скриншот заполненной формы → проверка в веб-интерфейсе → кнопка «Отправить»
```
**Ключевые принципы:**
1. **Авторизация** — логин/пароль на входе в веб-интерфейс (Flask-сессия)
2. **Изоляция данных** — у каждого пользователя своя папка `users/<логин>/` с его аккаунтами hh.ru, резюме, cookies, прогрессом
3. **Без VNC** — браузер работает на сервере (headless или на виртуальном экране Xvfb), заполненная форма показывается **скриншотом** в веб-интерфейсе, человек подтверждает отправку кнопкой
4. **Лимиты** — дневной лимит генераций на пользователя (вы платите за LLM)
5. **LLM-ключ общий** (в `.env` на сервере) — пользователи его не видят
**Как работает отклик без VNC:**
1. Пользователь вставляет cookies hh.ru (из своего браузера) — они подставляются в браузер на сервере автоматически
2. Playwright открывает вакансию, жмёт «Откликнуться», выбирает резюме, вставляет текст
3. Делает скриншот заполненной формы → показывает его в веб-интерфейсе
4. Человек смотрит скриншот и жмёт «Отправить отклик» → скрипт кликает кнопку в браузере
5. Вакансия помечается отправленной
---
## Модель пользователей и данных
```
/opt/hh_auto/
├── .env # LLM_API_KEY, PROXY_URL, SESSION_SECRET, HH_HEADLESS (chmod 600)
├── users.json # пользователи: логин, hash пароля, лимит, счётчик генераций
├── manage_users.py # CLI: add / list / passwd / limit / remove
└── users/
├── admin/ # вы (владелец)
│ └── accounts/
│ └── my_hh/ # config.json, resume.txt, session.txt,
│ # profile/, progress.json, screenshots/
└── friend1/ # друг
└── accounts/
└── friend1_hh/
```
`users.json`:
```json
{
"admin": {"password_hash": "...", "llm_limit": 50, "llm_usage": {"2026-09-12": 3}, "created": "2026-09-12"},
"friend1": {"password_hash": "...", "llm_limit": 20, "llm_usage": {}, "created": "2026-09-12"}
}
```
Пароли — только хэши (werkzeug), в открытом виде нигде не хранятся.
---
## Предположения
- ОС: **Debian 12 / Ubuntu 22.04+**, есть **root**
- Домен или статический IP; порты **22, 80, 443** открыты
- Круг пользователей небольшой (2–10 человек). Два варианта:
- **Открытая регистрация** (по умолчанию): любой может создать аккаунт на странице `/register`
- **Закрытая** (`ALLOW_REGISTRATION=0` в `.env`): пользователей создаёт владелец через `manage_users.py`
---
## Этап 0 — подготовка сервера
```bash
apt update && apt upgrade -y
useradd -m -s /bin/bash hh
apt install -y ufw
ufw allow 22/tcp && ufw allow 80/tcp && ufw allow 443/tcp && ufw --force enable
```
---
## Этап 1 — системные пакеты
```bash
apt install -y python3 python3-venv python3-pip \
nginx certbot python3-certbot-nginx \
xvfb fonts-liberation
# Зависимости Chromium (если playwright install-deps недоступен):
apt install -y libnss3 libxcomposite1 libxdamage1 libxrandr2 libgbm1 \
libasound2 libatk-bridge2.0-0 libcups2 libpango-1.0-0 libcairo2 \
libxkbcommon0 libatspi2.0-0
```
> **Xvfb** нужен только если браузер работает «видимым» (рекомендуется — меньше шансов, что hh.ru распознает автоматизацию). Если хотите совсем без дисплея — поставьте `HH_HEADLESS=1` в `.env`, тогда Xvfb не нужен.
---
## Этап 2 — деплой кода
```bash
cd /opt && git clone https://git.itguild.info/apuc/hh_auto
chown -R hh:hh /opt/hh_auto
su - hh
cd /opt/hh_auto
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
.venv/bin/playwright install chromium
cat > /opt/hh_auto/.env << 'EOF'
LLM_API_KEY=ваш_ключ_здесь
LLM_BASE_URL=https://opencode.ai/zen/v1
LLM_MODEL=deepseek-v4-flash
PROXY_URL=http://LOGIN:PASSWORD@185.168.251.163:8000
SESSION_SECRET=$(python3 -c "import secrets; print(secrets.token_hex(32))")
HH_HEADLESS=0
HH_NO_SANDBOX=1
EOF
chmod 600 /opt/hh_auto/.env
mkdir -p /opt/hh_auto/users && chmod 700 /opt/hh_auto/users
```
> `HH_NO_SANDBOX=1` — если Chromium не стартует из-за sandbox (root/контейнер). `PROXY_URL` — если hh.ru блокирует IP сервера.
---
## Этап 3 — виртуальный дисплей (если HH_HEADLESS=0)
```bash
cat > /etc/systemd/system/xvfb.service << 'EOF'
[Unit]
Description=Xvfb virtual display :99
After=network.target
[Service]
ExecStart=/usr/bin/Xvfb :99 -screen 0 1400x900x24 -nolisten tcp +extension GLX
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now xvfb
```
---
## Этап 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
# Владелец (лимит 50 генераций в день)
/opt/hh_auto/.venv/bin/python /opt/hh_auto/manage_users.py add admin --password СЛОЖНЫЙ_ПАРОЛЬ --limit 50
# Друг (лимит 20)
/opt/hh_auto/.venv/bin/python /opt/hh_auto/manage_users.py add friend1 --password ПАРОЛЬ_ДРУГА --limit 20
# Просмотр
/opt/hh_auto/.venv/bin/python /opt/hh_auto/manage_users.py list
```
Другие команды: `passwd <логин>` (смена пароля), `limit <логин> <N>` (лимит, 0 = без лимита), `remove <логин>` (удаление с данными).
---
## Этап 5 — веб-приложение
### 5.1 Gunicorn
```bash
cat > /etc/systemd/system/hh-web.service << 'EOF'
[Unit]
Description=hh_auto Flask app (gunicorn)
After=network.target xvfb.service
[Service]
User=hh
Group=hh
WorkingDirectory=/opt/hh_auto
Environment=DISPLAY=:99
EnvironmentFile=/opt/hh_auto/.env
ExecStart=/opt/hh_auto/.venv/bin/gunicorn \
-w 1 --threads 8 --timeout 300 -b 127.0.0.1:18091 webui:app
Restart=always
RestartSec=5
LimitNOFILE=65536
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now hh-web
```
> `-w 1`: один воркер — in-memory кэш и браузеры живут в процессе. `--timeout 300`: открытие вакансии в браузере занимает до минуты. `DISPLAY=:99`: браузер открывается на Xvfb (если HH_HEADLESS=0).
### 5.2 Nginx + HTTPS
```bash
# Сертификат (домен):
certbot certonly --webroot -w /var/www/html -d hh.example.com
# Или самоподписанный (IP):
openssl req -x509 -newkey rsa:4096 -keyout /etc/ssl/private/hh.key \
-out /etc/ssl/certs/hh.crt -days 365 -nodes -subj "/CN=hh-auto"
cat > /etc/nginx/sites-available/hh-auto << 'EOF'
server {
listen 80;
server_name hh.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name hh.example.com;
ssl_certificate /etc/letsencrypt/live/hh.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/hh.example.com/privkey.pem;
# Если самоподписанный:
# ssl_certificate /etc/ssl/certs/hh.crt;
# ssl_certificate_key /etc/ssl/private/hh.key;
client_max_body_size 20M;
location / {
proxy_pass http://127.0.0.1:18091;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 600;
}
}
EOF
ln -sf /etc/nginx/sites-available/hh-auto /etc/nginx/sites-enabled/hh-auto
rm -f /etc/nginx/sites-enabled/default
nginx -t && systemctl reload nginx
systemctl enable --now certbot.timer
```
---
## Этап 6 — первый вход и проверка
1. Откройте `https://hh.example.com/` → страница входа → логин `admin`
2. Создайте аккаунт hh.ru (имя, запросы, регион), загрузите резюме
3. Войдите на hh.ru **в своём браузере** → F12 → Application → Cookies → скопируйте **все** cookies (`hhtoken`, `hhuid`, `hhrole`, `_xsrf`) → вставьте в поле «Ключ сессии hh.ru» → «Сохранить ключ»
4. «Обновить список» рядом с «Резюме на hh.ru» → выберите резюме
5. «Найти вакансии» → отметьте нужные → «Сгенерировать тексты» → проверьте/отредактируйте
6. «Открыть в браузере» → появится **скриншот заполненной формы** → проверьте → «Отправить отклик»
7. То же самое друг делает под своим логином — видит только свои аккаунты и свой лимит
---
## Этап 7 — бэкапы и мониторинг
```bash
# /opt/hh_auto/backup.sh — бэкапим users.json + users/ + .env
#!/bin/bash
BACKUP_DIR=/var/backups/hh_auto
mkdir -p "$BACKUP_DIR"
DATE=$(date +%Y%m%d_%H%M)
tar czf "$BACKUP_DIR/hh_auto_$DATE.tar.gz" \
-C /opt/hh_auto users.json users .env
find "$BACKUP_DIR" -name '*.tar.gz' -mtime +30 -delete
echo "0 3 * * * root /opt/hh_auto/backup.sh" > /etc/cron.d/hh-backup
chmod 644 /etc/cron.d/hh-backup
```
```bash
# Логи
journalctl -u hh-web -f
# Здоровье
curl -sf http://127.0.0.1:18091/api/accounts > /dev/null || systemctl restart hh-web
```
---
## Чек-лист проверки
| Тест | Ожидаемый результат |
|---|---|
| `https://DOMAIN/` без логина | Редирект на страницу входа |
| Вход admin / friend1 | Каждый видит только свои аккаунты |
| Попытка friend1 открыть `/api/accounts/admin` | 404 |
| Поиск вакансий | Список вакансий (или капча — см. «Риски») |
| Генерация текстов | Текст отклика; счётчик в шапке растёт |
| Превышение лимита | «Достигнут дневной лимит генераций» |
| «Открыть в браузере» | Скриншот заполненной формы |
| «Отправить отклик» | Кнопка нажата, вакансия помечена |
| `journalctl -u hh-web` | Без ошибок |
---
## Риски и обходы
### hh.ru блокирует IP дата-центра / капча
- **Симптом**: капча при поиске, HTTP 403, пустой список
- **Обход**: `PROXY_URL` в `.env`; если не помогает — residential-прокси
### hh.ru распознаёт автоматизацию
- **Симптом**: капча именно при открытии вакансии в браузере
- **Обход**: `HH_HEADLESS=0` + Xvfb (видимый Chromium, сложнее распознать); свежие cookies; не слишком частые действия
### Cookies инвалидируются
- **Симптом**: поиск пуст, скриншот показывает страницу входа
- **Обход**: заново скопировать cookies из браузера
### Chromium не стартует
- **Симптом**: `hh-web` падает, в `journalctl` ошибки
- **Обход**: `HH_NO_SANDBOX=1`; проверить `DISPLAY=:99` и `systemctl status xvfb`; проверить зависимости (`ldd ~/.cache/ms-playwright/chromium-1234/chrome-linux64/chrome | grep "not found"`)
### Одновременные пользователи
- Один gunicorn-воркер и один браузерный поток — операции выполняются по очереди. Для 2–10 пользователей достаточно; при большей нагрузке — переработка архитектуры
### Расходы на LLM
- Ключ общий, генерации пользователей идут за ваш счёт. Лимиты задаются в `manage_users.py limit <логин> <N>`
### Миграция с однопользовательской версии
- Старые `accounts/*` → `users/admin/accounts/*` (просто `mv`)
---
## Итого
| Этап | Время |
|---|---|
| Подготовка сервера + пакеты | 15–20 мин |
| Деплой кода + .env | 5 мин |
| Xvfb (опционально) | 5 мин |
| Пользователи (manage_users) | 5 мин |
| Gunicorn + nginx + HTTPS | 15–20 мин |
| Первый вход + проверка | 15–20 мин |
| **Итого** | **~1–1.5 часа** |
### Что получают пользователи
- Свой логин/пароль → свой веб-интерфейс (только свои аккаунты, резюме, вакансии)
- Скриншоты заполненных форм → сами подтверждают отправку
- Свой дневной лимит генераций
- Не видят: чужие аккаунты, резюме, cookies, прогресс, LLM-ключ, пароли
+38 -8
View File
@@ -1,9 +1,9 @@
# Автоотклик на hh.ru (полуавтоматический, безопасный) # Автоотклик на hh.ru (полуавтоматический)
Скрипт ищет вакансии на hh.ru по ключевым словам, генерирует персонализированный Скрипт ищет вакансии на hh.ru по ключевым словам, генерирует персонализированный
текст отклика через ИИ (на основе вашего резюме и описания вакансии), открывает текст отклика через ИИ (на основе вашего резюме и описания вакансии), открывает
страницу в браузере и подставляет текст. **Кнопку «Отправить» всегда нажимаете вы** — страницу в браузере, подставляет текст и резюме. Отправка — в один клик
это не нарушает правила hh.ru и не рискует аккаунтом. («Отправить отклик»), либо с проверкой текста перед отправкой.
## Установка ## Установка
@@ -25,17 +25,30 @@ PLAYWRIGHT_DOWNLOAD_HOST=https://cdn.npmmirror.com/binaries/playwright .venv/bin
.venv/bin/python webui.py .venv/bin/python webui.py
``` ```
Откройте **http://localhost:5000** — там можно: Откройте **http://localhost:18091** (порт меняется через `PORT`) — там можно:
- регистрироваться и входить (открытая регистрация, отключается `ALLOW_REGISTRATION=0`)
- создавать аккаунты (имя, регион, поисковые запросы, стиль) - создавать аккаунты (имя, регион, поисковые запросы, стиль)
- загружать резюме файлом (**txt, md, docx, pdf**) - загружать резюме файлом (**txt, md, docx, pdf**)
- сохранять **ключ сессии hh.ru** (cookies из браузера) — нужен для поиска и списка резюме - сохранять **ключ сессии hh.ru** (cookies из браузера) — нужен для поиска и списка резюме
- выбирать **резюме на hh.ru** — оно автоматически подставляется в модалке отклика - выбирать **резюме на hh.ru** — оно автоматически подставляется в модалке отклика
- искать вакансии и видеть их списком (название, компания, зарплата, опыт) - искать вакансии и видеть их списком (название, компания, зарплата, опыт)
- генерировать тексты откликов через ИИ (редактируются перед отправкой) - генерировать тексты откликов через ИИ (редактируются перед отправкой)
- отправлять отклик в один клик: кнопка «Отправить отклик» в колонке действий —
браузер открывает вакансию, вставляет текст, жмёт «Отправить» и закрывается
(скриншот результата показывается в модалке)
- открывать вакансию в браузере с уже вставленным текстом и выбранным резюме - открывать вакансию в браузере с уже вставленным текстом и выбранным резюме
для ручной проверки перед отправкой
- отмечать отправленные — они больше не предлагаются - отмечать отправленные — они больше не предлагаются
### Регистрация
При первом запуске откройте `/register` и создайте логин с паролем (минимум
6 символов) — вы сразу войдёте. Каждый пользователь работает со своими
аккаунтами и лимитом генераций. Открытая регистрация отключается
`ALLOW_REGISTRATION=0` (пользователей тогда создаёт администратор через
`manage_users.py`).
### Ключ сессии hh.ru ### Ключ сессии hh.ru
Чтобы скрипт мог искать вакансии и видеть ваши резюме, нужны cookies из браузера: Чтобы скрипт мог искать вакансии и видеть ваши резюме, нужны cookies из браузера:
@@ -89,13 +102,30 @@ accounts/
1. Скрипт парсит страницу поиска hh.ru по вашим запросам 1. Скрипт парсит страницу поиска hh.ru по вашим запросам
2. Для каждой новой вакансии получает описание 2. Для каждой новой вакансии получает описание
3. ИИ генерирует текст отклика (вакансия + ваше резюме + стиль) 3. ИИ генерирует текст отклика (вакансия + ваше резюме + стиль)
4. Браузер открывает вакансию, жмёт «Откликнуться», вставляет текст 4. **Один клик**: кнопка «Отправить отклик» — браузер открывает вакансию,
5. **Вы проверяете текст и нажимаете «Отправить»** жмёт «Откликнуться», вставляет текст, выбирает резюме, жмёт «Отправить»
6. Отмечаете отправленное — скрипт переходит к следующей и автоматически закрывается (скриншот результата — в модалке)
5. **С проверкой**: «Открыть в браузере» — вы проверяете текст и отправляете
сами, либо жмёте «Отправить отклик» в модалке скриншота
6. Отправленные вакансии отмечаются и больше не предлагаются
## Переменные окружения (.env)
| Переменная | По умолчанию | Описание |
|---|---|---|
| `LLM_API_KEY` | — | Ключ API для генерации текстов откликов |
| `SESSION_SECRET` | `dev-secret-change-me` | Секрет сессий Flask (обязательно сменить) |
| `PORT` | `18091` | Порт веб-интерфейса |
| `HOST` | `127.0.0.1` | Адрес прослушивания |
| `ALLOW_REGISTRATION` | `1` | Открытая регистрация (`0` — выключить) |
| `REGISTER_LLM_LIMIT` | `50` | Лимит генераций для новых пользователей |
| `HH_HEADLESS` | `0` | `1` — браузер без окна (для сервера без дисплея) |
| `PROXY_URL` | — | Прокси для браузера: `http://LOGIN:PASS@HOST:PORT` |
## Важно ## Важно
- Скрипт не нарушает правила hh.ru: финальное действие всегда за человеком - Отправка в один клик автоматически жмёт «Отправить» — используйте с осторожностью
и не чаще, чем позволяет hh.ru (паузы между страницами уже стоят)
- Не запускайте поиск слишком часто — между страницами есть паузы - Не запускайте поиск слишком часто — между страницами есть паузы
- Если hh.ru покажет капчу — скрипт остановится, решите капчу вручную и запустите снова - Если hh.ru покажет капчу — скрипт остановится, решите капчу вручную и запустите снова
- `.env` содержит API-ключ — не коммитьте его в git (уже добавлен в `.gitignore`) - `.env` содержит API-ключ — не коммитьте его в git (уже добавлен в `.gitignore`)
+340 -26
View File
@@ -1,9 +1,26 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Работа с браузером: открытие вакансии, клик «Откликнуться», вставка текста.""" """Работа с браузером: открытие вакансии, клик «Откликнуться», вставка текста,
скриншот для проверки человеком и клик «Отправить» по подтверждению.
Без VNC: браузер работает headless (или на Xvfb-дисплее сервера), а человек
проверяет скриншот заполненной формы в веб-интерфейсе и подтверждает отправку.
Все операции с браузером выполняются в ОДНОМ выделенном потоке
(run_in_browser): Playwright sync API не потокобезопасен.
"""
import os import os
import queue
import threading
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
# Селекторы hh.ru (с запасными вариантами) # Селекторы hh.ru (с запасными вариантами)
RESPONSE_BUTTONS = [ RESPONSE_BUTTONS = [
'[data-qa="vacancy-response-link-top"]', '[data-qa="vacancy-response-link-top"]',
@@ -15,42 +32,218 @@ MESSAGE_FIELDS = [
'textarea[data-qa="vacancy-response-popup-form"] textarea', 'textarea[data-qa="vacancy-response-popup-form"] textarea',
'textarea', 'textarea',
] ]
SUBMIT_BUTTONS = [
'[data-qa="vacancy-response-submit"]',
'[data-qa="vacancy-response-popup-form"] button[type="submit"]',
'button:has-text("Отправить")',
]
# Кнопки подтверждения во ВТОРОЙ модалке hh.ru: после клика «Отправить»
# иногда появляется модалка «Отклик будет отправлен» с кнопкой
# «Отправить отклик» — её нужно нажать ещё раз.
CONFIRM_BUTTONS = [
'button:has-text("Отправить отклик")',
'button:has-text("Подтвердить")',
'button:has-text("Да, отправить")',
'[data-qa="vacancy-response-confirm"]',
]
# Живые браузерные контексты по аккаунтам: {account_name: context} # Живые браузерные контексты и страницы: {(user, account): ...}
# Доступны ТОЛЬКО из браузерного потока.
_browsers = {} _browsers = {}
_pages = {}
_playwrights = {}
# ---------- Браузерный поток ----------
_jobs = queue.Queue()
_browser_thread = None
def launch_context(account_dir): def _browser_worker():
"""Запускает браузер с профилем аккаунта (видимое окно).""" while True:
p = sync_playwright().start() job = _jobs.get()
context = p.chromium.launch_persistent_context( try:
job()
except Exception as e:
print(f" [browser] ошибка в фоновом задании: {e}")
finally:
_jobs.task_done()
def run_in_browser(fn):
"""Выполняет fn в единственном браузерном потоке и ждёт результат.
fn не принимает аргументов; исключения пробрасываются вызывающему.
"""
global _browser_thread
if _browser_thread is None or not _browser_thread.is_alive():
_browser_thread = threading.Thread(target=_browser_worker, daemon=True)
_browser_thread.start()
done = threading.Event()
result = {}
def wrapper():
try:
result["value"] = fn()
except Exception as e:
result["error"] = e
finally:
done.set()
_jobs.put(wrapper)
done.wait()
if "error" in result:
raise result["error"]
return result["value"]
# ---------- Настройки ----------
def is_headless():
"""HH_HEADLESS=1 — браузер без окна (не нужен дисплей)."""
return os.environ.get("HH_HEADLESS", "0") == "1"
def get_proxy_config():
"""Прокси для Playwright из .env: PROXY_URL=http://LOGIN:PASS@HOST:PORT"""
url = os.environ.get("PROXY_URL", "")
if not url:
return None
from urllib.parse import urlsplit
p = urlsplit(url)
cfg = {"server": f"{p.scheme}://{p.hostname}:{p.port or 8000}"}
if p.username:
cfg["username"] = p.username
cfg["password"] = p.password or ""
return cfg
# ---------- Внутренние функции (только из браузерного потока) ----------
def _launch_context_with(p, account_dir):
"""Запускает браузер с профилем аккаунта и подставляет cookies сессии."""
kwargs = dict(
user_data_dir=os.path.join(account_dir, "profile"), user_data_dir=os.path.join(account_dir, "profile"),
headless=False, headless=is_headless(),
viewport={"width": 1400, "height": 900}, viewport={"width": 1400, "height": 900},
proxy=get_proxy_config(),
) )
if os.environ.get("HH_NO_SANDBOX"):
kwargs["args"] = ["--no-sandbox"]
context = p.chromium.launch_persistent_context(**kwargs)
_inject_session_cookies(context, account_dir)
return context return context
def get_context(account_dir, account_name): def _context_alive(ctx):
"""Возвращает живой контекст аккаунта, создавая при необходимости.""" """Проверяет, жив ли контекст (лёгкая операция, не создаёт страниц)."""
if account_name not in _browsers:
_browsers[account_name] = launch_context(account_dir)
return _browsers[account_name]
def close_context(account_name):
if account_name in _browsers:
try: try:
_browsers[account_name].close() ctx.pages
return True
except Exception:
return False
def _inject_session_cookies(context, account_dir):
"""Подставляет cookies из session.txt — вход на hh.ru без ручного логина."""
path = os.path.join(account_dir, "session.txt")
if not os.path.exists(path):
return
with open(path, encoding="utf-8") as f:
cookie_str = f.read().strip()
if not cookie_str:
return
cookies = parse_cookies(cookie_str)
if not cookies:
return
try:
context.add_cookies([
{"name": k, "value": v, "url": "https://hh.ru"}
for k, v in cookies.items()
])
except Exception as e:
print(f" ⚠ Не удалось подставить cookies: {e}")
def _get_context(account_dir, user, account):
"""Возвращает живой контекст; если браузер умер — перезапускает."""
key = (user, account)
ctx = _browsers.get(key)
if ctx is None or not _context_alive(ctx):
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):
if not screenshot_dir:
return None
os.makedirs(screenshot_dir, exist_ok=True)
path = os.path.join(screenshot_dir, f"{vid}.png")
try:
page.screenshot(path=path)
return path
except Exception:
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,
resume_id=None, screenshot_dir=None, submit=False):
"""Открывает вакансию, жмёт «Откликнуться», выбирает резюме, вставляет текст.
При submit=True дополнительно жмёт «Отправить» и снимает скриншот результата.
Возвращает (ok, note, screenshot_path)."""
key = (user, account)
# Закрываем предыдущую страницу этого аккаунта
prev = _pages.pop(key, None)
if prev:
try:
prev.close()
except Exception: except Exception:
pass pass
_browsers.pop(account_name, None)
page = _new_page(account_dir, user, account)
def open_response_page(context, vacancy, message, resume_id=None): _pages[key] = page
"""Открывает вакансию, жмёт «Откликнуться», выбирает резюме и вставляет текст.
Возвращает (ok, note): ok=True если текст вставлен автоматически."""
page = context.new_page()
page.goto(vacancy["url"], timeout=45000) page.goto(vacancy["url"], timeout=45000)
page.wait_for_timeout(2500) page.wait_for_timeout(2500)
@@ -66,7 +259,8 @@ def open_response_page(context, vacancy, message, resume_id=None):
except Exception: except Exception:
continue continue
if not clicked: if not clicked:
return False, "Не нашёл кнопку «Откликнуться» — сделайте это вручную" shot = _save_screenshot(page, screenshot_dir, vacancy["id"])
return False, "Не нашёл кнопку «Откликнуться» — проверьте скриншот", shot
# Ждём модалку # Ждём модалку
page.wait_for_timeout(2000) page.wait_for_timeout(2000)
@@ -82,12 +276,132 @@ def open_response_page(context, vacancy, message, resume_id=None):
print(" ⚠ Не нашёл радио-кнопку резюме — выберите вручную") print(" ⚠ Не нашёл радио-кнопку резюме — выберите вручную")
# Вставляем текст # Вставляем текст
filled = False
for sel in MESSAGE_FIELDS: for sel in MESSAGE_FIELDS:
try: try:
field = page.locator(sel).first field = page.locator(sel).first
if field.is_visible(timeout=3000): if field.is_visible(timeout=3000):
field.fill(message) field.fill(message)
return True, "Текст вставлен. Проверьте и нажмите «Отправить»" filled = True
break
except Exception: except Exception:
continue continue
return False, "Не нашёл поле сообщения — вставьте текст вручную"
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"])
return True, "Текст вставлен. Проверьте скриншот и нажмите «Отправить»", shot
def confirm_send(user, account, vid):
"""Кликает «Отправить» на открытой странице. Возвращает (ok, note)."""
key = (user, account)
page = _pages.get(key)
if not page:
return False, "Страница не открыта — сначала нажмите «Открыть»"
for sel in SUBMIT_BUTTONS:
try:
btn = page.locator(sel).first
if btn.is_visible(timeout=3000):
btn.click()
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."
except Exception:
continue
return False, "Не нашёл кнопку «Отправить» — возможно, форма уже закрыта"
def open_login_page(account_dir, user, account):
"""Открывает hh.ru в браузере (для ручного входа, локальный режим с экраном)."""
key = (user, account)
page = _new_page(account_dir, user, account)
_pages[key] = page
page.goto("https://hh.ru", timeout=45000)
return True, "Страница hh.ru открыта"
def _close_context(key):
"""Закрывает контекст и останавливает playwright-инстанс (из браузерного потока)."""
if key in _browsers:
try:
_browsers[key].close()
except Exception:
pass
_browsers.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,
resume_id=None, screenshot_dir=None, submit=False):
return run_in_browser(lambda: open_response_page(
account_dir, user, account, vacancy, message,
resume_id=resume_id, screenshot_dir=screenshot_dir, submit=submit))
def browser_send(user, account, vid):
return run_in_browser(lambda: confirm_send(user, account, vid))
def browser_open_login(account_dir, user, account):
return run_in_browser(lambda: open_login_page(account_dir, user, account))
def browser_close(user, account):
return run_in_browser(lambda: close_context(user, account))
+52 -41
View File
@@ -2,16 +2,16 @@
"""Полуавтоматический отклик на вакансии hh.ru. """Полуавтоматический отклик на вакансии hh.ru.
Безопасный режим: скрипт ищет вакансии, генерирует текст отклика через ИИ, Безопасный режим: скрипт ищет вакансии, генерирует текст отклика через ИИ,
открывает страницу в браузере и подставляет текст. Финальное «Отправить» открывает страницу в браузере (headless или Xvfb), делает скриншот.
всегда нажимаете вы. Вы проверяете скриншот и подтверждаете отправку — скрипт нажимает «Отправить».
Аккаунты: каждая папка в accounts/ — отдельный аккаунт со своей сессией Аккаунты: users/<пользователь>/accounts/ (или accounts/ для старого формата).
(профиль браузера), резюме и настройками.
Использование: Использование:
python3 hh_respond.py --login my_account # войти в аккаунт (один раз) python3 hh_respond.py --login my_account # войти в аккаунт (локально, с экраном)
python3 hh_respond.py # обработать все аккаунты python3 hh_respond.py # обработать все аккаунты
python3 hh_respond.py --account my_account # только один аккаунт python3 hh_respond.py --account my_account # только один аккаунт
python3 hh_respond.py --user friend1 # пользователь сервиса
python3 hh_respond.py --limit 5 # максимум 5 откликов на аккаунт python3 hh_respond.py --limit 5 # максимум 5 откликов на аккаунт
python3 hh_respond.py --dry-run # без браузера, только текст python3 hh_respond.py --dry-run # без браузера, только текст
""" """
@@ -21,16 +21,22 @@ import os
import sys import sys
import time import time
from playwright.sync_api import sync_playwright from hh_search import search_vacancies, fetch_vacancy_details, get_proxies
from hh_search import search_vacancies, fetch_vacancy_details
from hh_llm import generate_response from hh_llm import generate_response
from hh_browser import get_context, close_context, open_response_page from hh_browser import browser_open, browser_send, browser_close, browser_open_login
BASE_DIR = os.path.dirname(os.path.abspath(__file__)) BASE_DIR = os.path.dirname(os.path.abspath(__file__))
ACCOUNTS_DIR = os.path.join(BASE_DIR, "accounts") ACCOUNTS_DIR = os.path.join(BASE_DIR, "accounts")
def resolve_accounts_dir(user):
"""Папка аккаунтов пользователя; старый формат accounts/ — запасной."""
d = os.path.join(BASE_DIR, "users", user, "accounts")
if os.path.isdir(d):
return d
return ACCOUNTS_DIR
def load_account_config(account_dir): def load_account_config(account_dir):
cfg_path = os.path.join(account_dir, "config.json") cfg_path = os.path.join(account_dir, "config.json")
with open(cfg_path, encoding="utf-8") as f: with open(cfg_path, encoding="utf-8") as f:
@@ -64,7 +70,8 @@ def collect_vacancies(cfg):
seen = set() seen = set()
for query in cfg.get("queries", []): for query in cfg.get("queries", []):
print(f"\n Поиск: «{query}»") print(f"\n Поиск: «{query}»")
found = search_vacancies(query, area=cfg.get("area"), pages=cfg.get("pages", 1)) found = search_vacancies(query, area=cfg.get("area"), pages=cfg.get("pages", 1),
proxies=get_proxies())
for v in found: for v in found:
if v["id"] and v["id"] not in seen: if v["id"] and v["id"] not in seen:
seen.add(v["id"]) seen.add(v["id"])
@@ -73,17 +80,18 @@ def collect_vacancies(cfg):
return result return result
def process_account(account_name, limit, dry_run): def process_account(account_name, limit, dry_run, user):
account_dir = os.path.join(ACCOUNTS_DIR, account_name) accounts_dir = resolve_accounts_dir(user)
account_dir = os.path.join(accounts_dir, account_name)
if not os.path.isdir(account_dir): if not os.path.isdir(account_dir):
print(f"Аккаунт «{account_name}» не найден в {ACCOUNTS_DIR}") print(f"Аккаунт «{account_name}» не найден в {accounts_dir}")
return return
cfg = load_account_config(account_dir) cfg = load_account_config(account_dir)
progress = load_progress(account_dir) progress = load_progress(account_dir)
responded = set(progress["responded"]) responded = set(progress["responded"])
print(f"\n=== Аккаунт: {account_name} ===") print(f"\n=== Аккаунт: {account_name} (пользователь: {user}) ===")
if not cfg.get("resume"): if not cfg.get("resume"):
print(" ⚠ Нет файла resume.txt — текст отклика будет без учёта резюме") print(" ⚠ Нет файла resume.txt — текст отклика будет без учёта резюме")
@@ -101,15 +109,12 @@ def process_account(account_name, limit, dry_run):
llm_cfg = cfg.get("llm", {}) llm_cfg = cfg.get("llm", {})
style = cfg.get("style", "") style = cfg.get("style", "")
with sync_playwright() as p:
context = get_context(account_dir, account_name)
for i, vacancy in enumerate(pending, 1): for i, vacancy in enumerate(pending, 1):
print(f"\n [{i}/{len(pending)}] {vacancy['title']} — {vacancy['company']}") print(f"\n [{i}/{len(pending)}] {vacancy['title']} — {vacancy['company']}")
print(f" {vacancy['url']}") print(f" {vacancy['url']}")
# Описание вакансии для ИИ # Описание вакансии для ИИ
details = fetch_vacancy_details(vacancy["id"]) or {} details = fetch_vacancy_details(vacancy["id"], proxies=get_proxies()) or {}
vacancy_full = {**vacancy, **details} vacancy_full = {**vacancy, **details}
# Генерация текста # Генерация текста
@@ -127,20 +132,26 @@ def process_account(account_name, limit, dry_run):
print(" [dry-run] браузер не открываю") print(" [dry-run] браузер не открываю")
continue continue
# Открываем в браузере # Открываем в браузере и делаем скриншот
ok, note = open_response_page(context, vacancy_full, message) shot_dir = os.path.join(account_dir, "screenshots")
ok, note, shot = browser_open(account_dir, user, account_name, vacancy_full, message,
resume_id=cfg.get("resume_id"), screenshot_dir=shot_dir)
print(f" {'✓' if ok else '⚠'} {note}") print(f" {'✓' if ok else '⚠'} {note}")
if shot:
print(f" Скриншот: {shot}")
# Ждём решения пользователя # Ждём решения пользователя
while True: while True:
choice = input(" Отправили? [Enter=да, s=пропустить, q=выйти]: ").strip().lower() choice = input(" Отправить отклик? [Enter=да, s=пропустить, q=выйти]: ").strip().lower()
if choice in ("", "д", "y", "yes", "да"): if choice in ("", "д", "y", "yes", "да"):
ok2, note2 = browser_send(user, account_name, vacancy["id"])
print(f" {'✓' if ok2 else '⚠'} {note2}")
responded.add(vacancy["id"]) responded.add(vacancy["id"])
break break
if choice == "s": if choice == "s":
break break
if choice == "q": if choice == "q":
close_context(account_name) browser_close(user, account_name)
progress["responded"] = sorted(responded) progress["responded"] = sorted(responded)
save_progress(account_dir, progress) save_progress(account_dir, progress)
print(" Выход. Прогресс сохранён.") print(" Выход. Прогресс сохранён.")
@@ -148,23 +159,21 @@ def process_account(account_name, limit, dry_run):
progress["responded"] = sorted(responded) progress["responded"] = sorted(responded)
save_progress(account_dir, progress) save_progress(account_dir, progress)
close_context(account_name) browser_close(user, account_name)
print(f"\n=== Аккаунт {account_name}: готово ===") print(f"\n=== Аккаунт {account_name}: готово ===")
def login_account(account_name): def login_account(account_name, user):
"""Открывает браузер с профилем аккаунта для ручного входа.""" """Открывает браузер для ручного входа (локальный режим с экраном)."""
account_dir = os.path.join(ACCOUNTS_DIR, account_name) accounts_dir = resolve_accounts_dir(user)
account_dir = os.path.join(accounts_dir, account_name)
if not os.path.isdir(account_dir): if not os.path.isdir(account_dir):
print(f"Аккаунт «{account_name}» не найден в {ACCOUNTS_DIR}") print(f"Аккаунт «{account_name}» не найден в {accounts_dir}")
return return
print(f"Открываю браузер для аккаунта «{account_name}». Войдите на hh.ru и закройте окно.") print(f"Открываю браузер для аккаунта «{account_name}». Войдите на hh.ru.")
context = get_context(account_dir, account_name) browser_open_login(account_dir, user, account_name)
page = context.new_page() input("Когда войдёте — нажмите Enter...")
page.goto("https://hh.ru", timeout=45000) browser_close(user, account_name)
print("Когда войдёте — закройте окно браузера.")
close_context(account_name)
print("Сессия сохранена.") print("Сессия сохранена.")
@@ -172,27 +181,29 @@ def main():
parser = argparse.ArgumentParser(description="Полуавтоматический отклик на hh.ru") parser = argparse.ArgumentParser(description="Полуавтоматический отклик на hh.ru")
parser.add_argument("--account", help="Имя аккаунта (папка в accounts/)") parser.add_argument("--account", help="Имя аккаунта (папка в accounts/)")
parser.add_argument("--login", help="Войти в аккаунт (открыть браузер для логина)") parser.add_argument("--login", help="Войти в аккаунт (открыть браузер для логина)")
parser.add_argument("--user", default="admin", help="Пользователь сервиса (users/<user>/accounts)")
parser.add_argument("--limit", type=int, help="Максимум откликов на аккаунт") parser.add_argument("--limit", type=int, help="Максимум откликов на аккаунт")
parser.add_argument("--dry-run", action="store_true", help="Без браузера, только текст") parser.add_argument("--dry-run", action="store_true", help="Без браузера, только текст")
args = parser.parse_args() args = parser.parse_args()
if args.login: if args.login:
login_account(args.login) login_account(args.login, args.user)
return return
if not os.path.isdir(ACCOUNTS_DIR): accounts_dir = resolve_accounts_dir(args.user)
print(f"Нет папки {ACCOUNTS_DIR}. Создайте аккаунт: см. README.md") if not os.path.isdir(accounts_dir):
print(f"Нет папки {accounts_dir}. Создайте аккаунт: см. README.md")
return return
accounts = [args.account] if args.account else sorted(os.listdir(ACCOUNTS_DIR)) accounts = [args.account] if args.account else sorted(os.listdir(accounts_dir))
accounts = [a for a in accounts if os.path.isdir(os.path.join(ACCOUNTS_DIR, a))] accounts = [a for a in accounts if os.path.isdir(os.path.join(accounts_dir, a))]
if not accounts: if not accounts:
print("Нет аккаунтов в accounts/. Создайте папку аккаунта (см. README.md)") print(f"Нет аккаунтов в {accounts_dir}. Создайте папку аккаунта (см. README.md)")
return return
for acc in accounts: for acc in accounts:
process_account(acc, args.limit, args.dry_run) process_account(acc, args.limit, args.dry_run, args.user)
if __name__ == "__main__": if __name__ == "__main__":
+20 -6
View File
@@ -1,12 +1,17 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Поиск вакансий на hh.ru через парсинг страницы поиска (без API).""" """Поиск вакансий на hh.ru через парсинг страницы поиска (без API)."""
import json import json
import os
import re import re
import time import time
import urllib.parse 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"),
@@ -16,6 +21,15 @@ HEADERS = {
SEARCH_URL = "https://hh.ru/search/vacancy" SEARCH_URL = "https://hh.ru/search/vacancy"
def get_proxies():
"""Прокси из .env: PROXY_URL=http://LOGIN:PASS@HOST:PORT.
Возвращает dict для requests или None."""
url = os.environ.get("PROXY_URL", "")
if not url:
return None
return {"http": url, "https": url}
def _clean(text): def _clean(text):
"""Убираем HTML-теги и лишние пробелы.""" """Убираем HTML-теги и лишние пробелы."""
text = re.sub(r"<[^>]+>", "", text) text = re.sub(r"<[^>]+>", "", text)
@@ -117,7 +131,7 @@ def _is_latin1(s):
return False return False
def search_vacancies(query, area=None, pages=1, per_page=20, delay=2.0, cookies=None): def search_vacancies(query, area=None, pages=1, per_page=20, delay=2.0, cookies=None, proxies=None):
"""Ищем вакансии по запросу. Возвращает список словарей.""" """Ищем вакансии по запросу. Возвращает список словарей."""
all_vacancies = [] all_vacancies = []
seen = set() seen = set()
@@ -126,7 +140,7 @@ def search_vacancies(query, area=None, pages=1, per_page=20, delay=2.0, cookies=
if area: if area:
params["area"] = area params["area"] = area
url = f"{SEARCH_URL}?{urllib.parse.urlencode(params)}" url = f"{SEARCH_URL}?{urllib.parse.urlencode(params)}"
resp = requests.get(url, headers=HEADERS, timeout=30, cookies=cookies) resp = requests.get(url, headers=HEADERS, timeout=30, cookies=cookies, proxies=proxies)
if resp.status_code != 200: if resp.status_code != 200:
print(f" [поиск] HTTP {resp.status_code} для страницы {page_num}") print(f" [поиск] HTTP {resp.status_code} для страницы {page_num}")
break break
@@ -144,10 +158,10 @@ def search_vacancies(query, area=None, pages=1, per_page=20, delay=2.0, cookies=
return all_vacancies return all_vacancies
def fetch_vacancy_details(vacancy_id, cookies=None): def fetch_vacancy_details(vacancy_id, cookies=None, proxies=None):
"""Получаем описание вакансии со страницы вакансии.""" """Получаем описание вакансии со страницы вакансии."""
url = f"https://hh.ru/vacancy/{vacancy_id}" url = f"https://hh.ru/vacancy/{vacancy_id}"
resp = requests.get(url, headers=HEADERS, timeout=30, cookies=cookies) resp = requests.get(url, headers=HEADERS, timeout=30, cookies=cookies, proxies=proxies)
if resp.status_code != 200: if resp.status_code != 200:
return None return None
html = resp.text html = resp.text
@@ -170,11 +184,11 @@ def fetch_vacancy_details(vacancy_id, cookies=None):
return details return details
def fetch_my_resumes(cookies=None): def fetch_my_resumes(cookies=None, proxies=None):
"""Получает список резюме пользователя со страницы hh.ru/applicant/resumes. """Получает список резюме пользователя со страницы hh.ru/applicant/resumes.
Возвращает список {"id": hash, "title": ..., "updated": ...}.""" Возвращает список {"id": hash, "title": ..., "updated": ...}."""
url = "https://hh.ru/applicant/resumes" url = "https://hh.ru/applicant/resumes"
resp = requests.get(url, headers=HEADERS, timeout=30, cookies=cookies) resp = requests.get(url, headers=HEADERS, timeout=30, cookies=cookies, proxies=proxies)
if resp.status_code != 200: if resp.status_code != 200:
return None, f"HTTP {resp.status_code} — проверьте ключ сессии" return None, f"HTTP {resp.status_code} — проверьте ключ сессии"
html = resp.text html = resp.text
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env python3
"""Управление пользователями сервиса hh_auto.
Примеры:
python3 manage_users.py add friend1 # спросит пароль
python3 manage_users.py add friend1 --password 123 --limit 50
python3 manage_users.py list
python3 manage_users.py passwd friend1
python3 manage_users.py limit friend1 100
python3 manage_users.py remove friend1
"""
import argparse
import datetime
import getpass
import json
import os
import shutil
import sys
from werkzeug.security import generate_password_hash
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
USERS_FILE = os.path.join(BASE_DIR, "users.json")
def load():
if os.path.exists(USERS_FILE):
with open(USERS_FILE, encoding="utf-8") as f:
return json.load(f)
return {}
def save(users):
tmp = USERS_FILE + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(users, f, ensure_ascii=False, indent=2)
os.replace(tmp, USERS_FILE)
os.chmod(USERS_FILE, 0o600)
def add(username, password, limit):
users = load()
if username in users:
sys.exit(f"Пользователь «{username}» уже существует")
users[username] = {
"password_hash": generate_password_hash(password),
"llm_limit": limit,
"llm_usage": {},
"created": datetime.date.today().isoformat(),
}
save(users)
os.makedirs(os.path.join(BASE_DIR, "users", username, "accounts"), exist_ok=True)
print(f"Пользователь «{username}» создан")
print(f" Данные: {BASE_DIR}/users/{username}/")
print(f" Дневной лимит генераций: {limit or 'без лимита'}")
print(f" Вход: https://ВАШ-ДОМЕН/ (логин: {username})")
def remove(username):
users = load()
if username not in users:
sys.exit(f"Пользователь «{username}» не найден")
del users[username]
save(users)
shutil.rmtree(os.path.join(BASE_DIR, "users", username), ignore_errors=True)
print(f"Пользователь «{username}» удалён вместе с данными")
def set_password(username, password):
users = load()
if username not in users:
sys.exit(f"Пользователь «{username}» не найден")
users[username]["password_hash"] = generate_password_hash(password)
save(users)
print(f"Пароль «{username}» обновлён")
def set_limit(username, limit):
users = load()
if username not in users:
sys.exit(f"Пользователь «{username}» не найден")
users[username]["llm_limit"] = limit
save(users)
print(f"Лимит «{username}»: {limit or 'без лимита'} генераций в день")
def list_users():
users = load()
if not users:
print("Пользователей нет")
return
today = datetime.date.today().isoformat()
print(f"{'Логин':20} {'Генерации сегодня':18} Создан")
for name, u in sorted(users.items()):
used = (u.get("llm_usage") or {}).get(today, 0)
limit = u.get("llm_limit") or 0
lim = f"{used}/{limit}" if limit else f"{used}/∞"
print(f" {name:18} {lim:16} {u.get('created', '?')}")
def main():
ap = argparse.ArgumentParser(description="Управление пользователями hh_auto")
sub = ap.add_subparsers(dest="cmd", required=True)
a = sub.add_parser("add", help="Создать пользователя")
a.add_argument("username")
a.add_argument("--password", help="Пароль (иначе спросит)")
a.add_argument("--limit", type=int, default=50,
help="Дневной лимит генераций (0 = без лимита)")
sub.add_parser("list", help="Список пользователей")
p = sub.add_parser("passwd", help="Сменить пароль")
p.add_argument("username")
l = sub.add_parser("limit", help="Установить дневной лимит генераций")
l.add_argument("username")
l.add_argument("limit", type=int)
r = sub.add_parser("remove", help="Удалить пользователя с данными")
r.add_argument("username")
args = ap.parse_args()
if args.cmd == "add":
password = args.password or getpass.getpass(f"Пароль для «{args.username}»: ")
add(args.username, password, args.limit)
elif args.cmd == "list":
list_users()
elif args.cmd == "passwd":
password = getpass.getpass(f"Новый пароль для «{args.username}»: ")
set_password(args.username, password)
elif args.cmd == "limit":
set_limit(args.username, args.limit)
elif args.cmd == "remove":
if input(f"Удалить «{args.username}» и все его данные? [y/N]: ").strip().lower() \
in ("y", "д", "yes", "да"):
remove(args.username)
if __name__ == "__main__":
main()
+1
View File
@@ -3,3 +3,4 @@ playwright>=1.40.0
flask>=3.0.0 flask>=3.0.0
pypdf>=4.0.0 pypdf>=4.0.0
python-docx>=1.1.0 python-docx>=1.1.0
python-dotenv>=1.0.0
+237 -7
View File
@@ -67,7 +67,7 @@
border-radius: 8px; margin-bottom: 8px; background: var(--card); } border-radius: 8px; margin-bottom: 8px; background: var(--card); }
.account-item .name { font-weight: 600; } .account-item .name { font-weight: 600; }
.account-item .actions { display: flex; gap: 8px; } .account-item .actions { display: flex; gap: 8px; }
.hidden { display: none; } .hidden { display: none !important; }
.texts-panel textarea { min-height: 90px; margin-bottom: 8px; } .texts-panel textarea { min-height: 90px; margin-bottom: 8px; }
.texts-panel .item { margin-bottom: 14px; } .texts-panel .item { margin-bottom: 14px; }
.texts-panel .item .title { font-weight: 600; margin-bottom: 4px; } .texts-panel .item .title { font-weight: 600; margin-bottom: 4px; }
@@ -79,6 +79,19 @@
.link:hover { text-decoration: underline; } .link:hover { text-decoration: underline; }
.actions-cell { display: flex; gap: 6px; flex-wrap: wrap; } .actions-cell { display: flex; gap: 6px; flex-wrap: wrap; }
.actions-cell button { padding: 4px 10px; font-size: 12px; } .actions-cell button { padding: 4px 10px; font-size: 12px; }
.modal { position: fixed; inset: 0; background: rgba(0,0,0,.75); display: flex;
align-items: center; justify-content: center; z-index: 100; }
.modal-box { background: var(--card); border: 1px solid var(--border);
border-radius: 12px; padding: 20px; max-width: 920px; width: 94%;
max-height: 92vh; overflow: auto; }
.modal-box img { max-width: 100%; border: 1px solid var(--border);
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; }
@@ -92,6 +105,10 @@
<button id="tab-btn-accounts" class="active" onclick="showTab('accounts')">Аккаунты</button> <button id="tab-btn-accounts" class="active" onclick="showTab('accounts')">Аккаунты</button>
<button id="tab-btn-vacancies" onclick="showTab('vacancies')">Вакансии</button> <button id="tab-btn-vacancies" onclick="showTab('vacancies')">Вакансии</button>
</nav> </nav>
<div style="margin-left:auto; display:flex; align-items:center; gap:12px">
<span class="muted" id="current-user"></span>
<button class="secondary" onclick="logout()">Выйти</button>
</div>
</header> </header>
<main> <main>
@@ -229,10 +246,53 @@
</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 class="modal-box">
<div class="toolbar">
<h2 style="margin:0">Проверьте отклик</h2>
<div class="spacer"></div>
<button class="secondary" onclick="closeShot()">Закрыть</button>
</div>
<div id="shot-note" class="muted" style="margin-bottom:8px"></div>
<img id="shot-img" alt="Скриншот вакансии">
<div class="toolbar" style="margin-top:12px">
<button id="btn-send" onclick="confirmSend()">Отправить отклик</button>
<button id="btn-done" class="secondary" onclick="markDone(currentShotId)">Отправил (отметить)</button>
<span id="send-note" class="muted"></span>
</div>
</div>
</div>
<script> <script>
let currentAccount = null; let currentAccount = null;
let vacancies = []; let vacancies = [];
let generatedTexts = {}; let generatedTexts = {};
let currentShotId = null;
let sentTexts = new Set();
let sendModalId = null;
// ---------- Вкладки ---------- // ---------- Вкладки ----------
function showTab(name) { function showTab(name) {
@@ -249,12 +309,20 @@ async function api(url, opts = {}) {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
...opts, ...opts,
}); });
if (resp.status === 401) { window.location = '/login'; throw new Error('Требуется вход'); }
const data = await resp.json().catch(() => ({})); const data = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(data.error || `HTTP ${resp.status}`); if (!resp.ok) throw new Error(data.error || `HTTP ${resp.status}`);
return data; return data;
} }
function logout() { window.location = '/logout'; }
async function loadAccounts() { async function loadAccounts() {
try {
const me = await api('/api/me');
const lim = me.limit ? ` · генерации сегодня: ${me.used}/${me.limit}` : '';
document.getElementById('current-user').textContent = me.user + lim;
} catch (e) { /* 401 уже редиректит */ }
const list = await api('/api/accounts'); const list = await api('/api/accounts');
const sel = document.getElementById('acc-select'); const sel = document.getElementById('acc-select');
const prev = sel.value; const prev = sel.value;
@@ -343,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 и нажмите «Сохранить ключ»';
@@ -469,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>
@@ -519,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);
@@ -543,28 +618,178 @@ function escapeHtml(s) {
.replace(/"/g,'&quot;'); .replace(/"/g,'&quot;');
} }
// ---------- Открытие и отметка ---------- // ---------- Открытие, проверка и отправка ----------
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] || '');
const note = document.getElementById(`note-${id}`); const note = document.getElementById(`note-${id}`);
if (note) { note.textContent = 'Открываю браузер...'; } if (note) { note.textContent = 'Открываю браузер...'; }
try { try {
await api(`/api/accounts/${currentAccount}/open`, { const data = await api(`/api/accounts/${currentAccount}/open`, {
method: 'POST', body: JSON.stringify({ id, message }), method: 'POST', body: JSON.stringify({ id, message }),
}); });
if (note) note.textContent = data.note || '';
if (data.screenshot) {
showShotModal(id, data.screenshot, data.note || '', false);
} else {
alert(data.note || 'Не удалось открыть вакансию');
}
} catch (e) { } catch (e) {
if (note) note.textContent = 'Ошибка: ' + e.message; if (note) note.textContent = 'Ошибка: ' + e.message;
alert('Ошибка: ' + e.message);
} }
} }
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() {
document.getElementById('shot-modal').classList.add('hidden');
}
async function confirmSend() {
if (!currentAccount) { alert('Сначала выберите аккаунт'); return; }
const btn = document.getElementById('btn-send');
const note = document.getElementById('send-note');
btn.disabled = true; note.textContent = 'Отправляю...';
try {
const data = await api(`/api/accounts/${currentAccount}/send`, {
method: 'POST', body: JSON.stringify({ id: currentShotId }),
});
note.textContent = data.note || '';
if (data.ok) {
const v = vacancies.find(x => x.id === currentShotId);
if (v) v.responded = true;
sentTexts.add(currentShotId);
renderVacancies();
renderTexts();
}
} catch (e) {
note.textContent = 'Ошибка: ' + e.message;
}
btn.disabled = false;
}
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 = 'Отмечено как отправленное';
} }
@@ -575,9 +800,14 @@ async function resetProgress() {
await runSearch(); await runSearch();
} }
function openLogin() { async function openLogin() {
if (!currentAccount) return; if (!currentAccount) return;
window.open(`/api/accounts/${currentAccount}/login`, '_blank'); try {
const data = await api(`/api/accounts/${currentAccount}/login`);
alert(data.note || 'Вход через cookies');
} catch (e) {
alert('Ошибка: ' + e.message);
}
} }
// ---------- Старт ---------- // ---------- Старт ----------
+70
View File
@@ -0,0 +1,70 @@
<!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="Логин" autocomplete="username" autofocus>
<input type="password" id="password" placeholder="Пароль" autocomplete="current-password">
<button onclick="doLogin()">Войти</button>
<div id="msg" class="error"></div>
<div class="link">Нет аккаунта? <a href="/register">Зарегистрироваться</a></div>
</div>
<script>
async function doLogin() {
const msg = document.getElementById('msg');
msg.textContent = '';
try {
const resp = await fetch('/login', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
username: document.getElementById('username').value,
password: document.getElementById('password').value,
}),
});
const data = await resp.json();
if (!resp.ok) { msg.textContent = data.error || 'Ошибка входа'; return; }
window.location = '/';
} catch (e) { msg.textContent = 'Ошибка сети'; }
}
document.getElementById('password').addEventListener('keydown', e => {
if (e.key === 'Enter') doLogin();
});
</script>
</body>
</html>
+72
View File
@@ -0,0 +1,72 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Регистрация — hh.ru автоотклик</title>
<style>
:root {
--bg: #0f1117; --card: #1a1d27; --border: #2a2f3a; --text: #e5e7eb;
--muted: #9ca3af; --accent: #3b82f6; --accent-hover: #60a5fa;
--input-bg: #12141c; --red: #ef4444;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
background: var(--bg); color: var(--text); font-size: 14px;
display: flex; align-items: center; justify-content: center;
min-height: 100vh; }
.login-box { background: var(--card); border: 1px solid var(--border);
border-radius: 12px; padding: 32px; width: 100%; max-width: 360px; }
.login-box h1 { font-size: 18px; text-align: center; margin-bottom: 4px; }
.login-box .muted { text-align: center; font-size: 13px; margin-bottom: 20px; }
input { width: 100%; padding: 10px 12px; border: 1px solid var(--border);
border-radius: 8px; font-size: 14px; font-family: inherit;
background: var(--input-bg); color: var(--text); margin-bottom: 12px; }
input:focus { outline: none; border-color: var(--accent); }
button { width: 100%; padding: 10px; border: none; border-radius: 8px;
cursor: pointer; font-size: 14px; background: var(--accent); color: #fff;
font-weight: 500; }
button:hover { background: var(--accent-hover); }
.error { color: var(--red); font-size: 13px; text-align: center; margin-top: 10px;
min-height: 20px; }
.link { text-align: center; margin-top: 14px; font-size: 13px; }
.link a { color: var(--accent); text-decoration: none; }
.link a:hover { color: var(--accent-hover); }
</style>
</head>
<body>
<div class="login-box">
<h1>hh.ru автоотклик</h1>
<p class="muted">Регистрация</p>
<input type="text" id="username" placeholder="Логин (3-30 символов)" autocomplete="username" autofocus>
<input type="password" id="password" placeholder="Пароль (мин. 6 символов)" autocomplete="new-password">
<input type="password" id="confirm" placeholder="Повторите пароль" autocomplete="new-password">
<button onclick="doRegister()">Зарегистрироваться</button>
<div id="msg" class="error"></div>
<div class="link">Уже есть аккаунт? <a href="/login">Войти</a></div>
</div>
<script>
async function doRegister() {
const msg = document.getElementById('msg');
msg.textContent = '';
try {
const resp = await fetch('/register', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
username: document.getElementById('username').value,
password: document.getElementById('password').value,
confirm: document.getElementById('confirm').value,
}),
});
const data = await resp.json();
if (!resp.ok) { msg.textContent = data.error || 'Ошибка регистрации'; return; }
window.location = '/';
} catch (e) { msg.textContent = 'Ошибка сети'; }
}
document.getElementById('confirm').addEventListener('keydown', e => {
if (e.key === 'Enter') doRegister();
});
</script>
</body>
</html>
+309 -55
View File
@@ -1,42 +1,129 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Веб-интерфейс для полуавтоматических откликов на hh.ru. """Веб-интерфейс для полуавтоматических откликов на hh.ru.
Многопользовательский режим: вход по логину/паролю, у каждого пользователя
своя папка users/<логин>/accounts/ с аккаунтами, резюме и сессиями.
Дневной лимит генераций на пользователя (users.json -> llm_limit).
Без VNC: браузер работает на сервере (headless или Xvfb), заполненная форма
показывается скриншотом, «Отправить» нажимается по подтверждению человека.
Запуск: .venv/bin/python webui.py Запуск: .venv/bin/python webui.py
Открыть: http://localhost:5000 Открыть: http://localhost:18091
""" """
import io import io
import json import json
import os import os
import re
import threading import threading
import time
from datetime import date
from functools import wraps
from flask import Flask, jsonify, render_template, request 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,
redirect, url_for, send_file)
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) parse_cookies, get_proxies)
from hh_llm import generate_response from hh_llm import generate_response
from hh_browser import get_context, close_context, open_response_page 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__))
ACCOUNTS_DIR = os.path.join(BASE_DIR, "accounts") USERS_DIR = os.path.join(BASE_DIR, "users")
USERS_FILE = os.path.join(BASE_DIR, "users.json")
app = Flask(__name__) app = Flask(__name__)
app.secret_key = os.environ.get("SESSION_SECRET", "dev-secret-change-me")
# Кэш найденных вакансий: {account: {vacancy_id: vacancy}} # Кэш найденных вакансий: {(user, account): {vacancy_id: vacancy}}
_cache = {} _cache = {}
_cache_lock = threading.Lock() _cache_lock = threading.Lock()
_users_lock = threading.Lock()
# ---------- Пользователи ----------
def load_users():
if os.path.exists(USERS_FILE):
with open(USERS_FILE, encoding="utf-8") as f:
return json.load(f)
return {}
def save_users(users):
with _users_lock:
tmp = USERS_FILE + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(users, f, ensure_ascii=False, indent=2)
os.replace(tmp, USERS_FILE)
def current_user():
return session.get("user")
def login_required(f):
@wraps(f)
def wrapper(*args, **kwargs):
if "user" not in session:
if request.path.startswith("/api/"):
return jsonify({"error": "Требуется вход"}), 401
return redirect(url_for("login_page"))
return f(*args, **kwargs)
return wrapper
def llm_limit_info(user):
"""Возвращает (limit, used_today) или (None, None), если лимита нет."""
users = load_users()
u = users.get(user) or {}
limit = u.get("llm_limit") or 0
if limit <= 0:
return None, None
usage = u.get("llm_usage") or {}
return limit, usage.get(date.today().isoformat(), 0)
def bump_llm_usage(user):
"""Увеличивает счётчик генераций пользователя на 1."""
users = load_users()
u = users.get(user)
if not u:
return
limit = u.get("llm_limit") or 0
if limit <= 0:
return
usage = u.setdefault("llm_usage", {})
today = date.today().isoformat()
usage[today] = usage.get(today, 0) + 1
# Чистим записи старше 7 дней
for d in [k for k in usage if k != today]:
try:
if (date.fromisoformat(today) - date.fromisoformat(d)).days > 7:
usage.pop(d, None)
except ValueError:
usage.pop(d, None)
save_users(users)
# ---------- Утилиты ---------- # ---------- Утилиты ----------
def account_path(name): def account_path(name):
return os.path.join(ACCOUNTS_DIR, name) return os.path.join(USERS_DIR, current_user(), "accounts", name)
def list_accounts(): def list_accounts():
if not os.path.isdir(ACCOUNTS_DIR): base = os.path.join(USERS_DIR, current_user(), "accounts")
if not os.path.isdir(base):
return [] return []
return sorted(d for d in os.listdir(ACCOUNTS_DIR) return sorted(d for d in os.listdir(base)
if os.path.isdir(os.path.join(ACCOUNTS_DIR, d))) if os.path.isdir(os.path.join(base, d)))
def read_json(path, default=None): def read_json(path, default=None):
@@ -86,9 +173,18 @@ 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 get_cache(account): 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):
with _cache_lock: with _cache_lock:
return _cache.setdefault(account, {}) return _cache.setdefault((user, account), {})
def extract_resume_text(file_storage): def extract_resume_text(file_storage):
@@ -111,19 +207,91 @@ def extract_resume_text(file_storage):
# ---------- Страницы ---------- # ---------- Страницы ----------
@app.route("/login", methods=["GET", "POST"])
def login_page():
if request.method == "POST":
data = request.get_json(force=True)
username = (data.get("username") or "").strip()
users = load_users()
u = users.get(username)
if u and check_password_hash(u.get("password_hash", ""), data.get("password", "")):
session["user"] = username
return jsonify({"ok": True})
time.sleep(1) # замедляем перебор паролей
return jsonify({"error": "Неверный логин или пароль"}), 401
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")
def logout():
session.clear()
return redirect(url_for("login_page"))
@app.route("/") @app.route("/")
@login_required
def index(): def index():
return render_template("index.html") return render_template("index.html")
@app.route("/api/me")
@login_required
def api_me():
limit, used = llm_limit_info(current_user())
return jsonify({"user": current_user(), "limit": limit, "used": used})
# ---------- API: аккаунты ---------- # ---------- API: аккаунты ----------
@app.route("/api/accounts") @app.route("/api/accounts")
@login_required
def api_accounts(): def api_accounts():
return jsonify(list_accounts()) return jsonify(list_accounts())
@app.route("/api/accounts", methods=["POST"]) @app.route("/api/accounts", methods=["POST"])
@login_required
def api_create_account(): def api_create_account():
data = request.get_json(force=True) data = request.get_json(force=True)
name = (data.get("name") or "").strip() name = (data.get("name") or "").strip()
@@ -152,19 +320,21 @@ def api_create_account():
@app.route("/api/accounts/<name>", methods=["DELETE"]) @app.route("/api/accounts/<name>", methods=["DELETE"])
@login_required
def api_delete_account(name): def api_delete_account(name):
acc_dir = account_path(name) acc_dir = account_path(name)
if not os.path.isdir(acc_dir): if not os.path.isdir(acc_dir):
return jsonify({"error": "Аккаунт не найден"}), 404 return jsonify({"error": "Аккаунт не найден"}), 404
close_context(name) browser_close(current_user(), name)
import shutil import shutil
shutil.rmtree(acc_dir) shutil.rmtree(acc_dir)
with _cache_lock: with _cache_lock:
_cache.pop(name, None) _cache.pop((current_user(), name), None)
return jsonify({"ok": True}) return jsonify({"ok": True})
@app.route("/api/accounts/<name>") @app.route("/api/accounts/<name>")
@login_required
def api_get_account(name): def api_get_account(name):
acc_dir = account_path(name) acc_dir = account_path(name)
if not os.path.isdir(acc_dir): if not os.path.isdir(acc_dir):
@@ -180,6 +350,7 @@ def api_get_account(name):
@app.route("/api/accounts/<name>", methods=["PUT"]) @app.route("/api/accounts/<name>", methods=["PUT"])
@login_required
def api_update_account(name): def api_update_account(name):
acc_dir = account_path(name) acc_dir = account_path(name)
if not os.path.isdir(acc_dir): if not os.path.isdir(acc_dir):
@@ -202,29 +373,25 @@ def api_update_account(name):
return jsonify({"ok": True}) return jsonify({"ok": True})
# ---------- API: поиск и отклики ---------- # ---------- API: сессия и резюме ----------
@app.route("/api/accounts/<name>/login", methods=["GET"]) @app.route("/api/accounts/<name>/login", methods=["GET"])
@login_required
def api_login(name): def api_login(name):
"""Открывает браузер с профилем аккаунта для входа на hh.ru.""" """Без VNC вход на hh.ru делается через cookies — браузер не открываем."""
acc_dir = account_path(name) acc_dir = account_path(name)
if not os.path.isdir(acc_dir): if not os.path.isdir(acc_dir):
return jsonify({"error": "Аккаунт не найден"}), 404 return jsonify({"error": "Аккаунт не найден"}), 404
return jsonify({
def worker(): "ok": True,
try: "note": ("Войдите на hh.ru в своём браузере, скопируйте cookies "
context = get_context(acc_dir, name) "(F12 → Application → Cookies → https://hh.ru) и вставьте "
page = context.new_page() "их в поле «Ключ сессии hh.ru» ниже."),
page.goto("https://hh.ru", timeout=45000) })
print(f"[{name}] Браузер открыт для входа. Закройте окно, когда войдёте.")
except Exception as e:
print(f"[{name}] Ошибка браузера: {e}")
threading.Thread(target=worker, daemon=True).start()
return jsonify({"ok": True, "note": "Браузер открывается. Войдите на hh.ru и закройте окно."})
@app.route("/api/accounts/<name>/resume-upload", methods=["POST"]) @app.route("/api/accounts/<name>/resume-upload", methods=["POST"])
@login_required
def api_resume_upload(name): def api_resume_upload(name):
"""Загрузка резюме файлом (.txt, .md, .docx, .pdf).""" """Загрузка резюме файлом (.txt, .md, .docx, .pdf)."""
acc_dir = account_path(name) acc_dir = account_path(name)
@@ -246,6 +413,7 @@ def api_resume_upload(name):
@app.route("/api/accounts/<name>/session", methods=["POST"]) @app.route("/api/accounts/<name>/session", methods=["POST"])
@login_required
def api_save_session(name): def api_save_session(name):
"""Сохраняет ключ сессии hh.ru (cookies) для аккаунта.""" """Сохраняет ключ сессии hh.ru (cookies) для аккаунта."""
acc_dir = account_path(name) acc_dir = account_path(name)
@@ -257,28 +425,32 @@ def api_save_session(name):
return jsonify({"error": "Ключ сессии пуст"}), 400 return jsonify({"error": "Ключ сессии пуст"}), 400
write_session(acc_dir, cookie_str) write_session(acc_dir, cookie_str)
# Проверяем валидность # Проверяем валидность
resumes, err = fetch_my_resumes(parse_cookies(cookie_str)) resumes, err = fetch_my_resumes(parse_cookies(cookie_str), proxies=get_proxies())
if err: if err:
return jsonify({"ok": True, "warning": f"Сохранено, но: {err}"}) return jsonify({"ok": True, "warning": f"Сохранено, но: {err}"})
return jsonify({"ok": True, "resumes": resumes}) return jsonify({"ok": True, "resumes": resumes})
@app.route("/api/accounts/<name>/resumes", methods=["GET"]) @app.route("/api/accounts/<name>/resumes", methods=["GET"])
@login_required
def api_get_resumes(name): def api_get_resumes(name):
"""Получает список резюме пользователя через ключ сессии.""" """Получает список резюме пользователя через ключ сессии."""
acc_dir = account_path(name) acc_dir = account_path(name)
if not os.path.isdir(acc_dir): if not os.path.isdir(acc_dir):
return jsonify({"error": "Аккаунт не найден"}), 404 return jsonify({"error": "Аккаунт не найден"}), 404
session = read_session(acc_dir) session_key = read_session(acc_dir)
if not session: if not session_key:
return jsonify({"error": "Ключ сессии не задан. Добавьте его в настройках аккаунта."}), 400 return jsonify({"error": "Ключ сессии не задан. Добавьте его в настройках аккаунта."}), 400
resumes, err = fetch_my_resumes(parse_cookies(session)) resumes, err = fetch_my_resumes(parse_cookies(session_key), proxies=get_proxies())
if err: if err:
return jsonify({"error": err}), 400 return jsonify({"error": err}), 400
return jsonify({"resumes": resumes}) return jsonify({"resumes": resumes})
# ---------- API: поиск и отклики ----------
@app.route("/api/accounts/<name>/search", methods=["POST"]) @app.route("/api/accounts/<name>/search", methods=["POST"])
@login_required
def api_search(name): def api_search(name):
acc_dir = account_path(name) acc_dir = account_path(name)
if not os.path.isdir(acc_dir): if not os.path.isdir(acc_dir):
@@ -286,14 +458,14 @@ def api_search(name):
cfg = read_json(os.path.join(acc_dir, "config.json"), {}) cfg = read_json(os.path.join(acc_dir, "config.json"), {})
progress = get_progress(acc_dir) progress = get_progress(acc_dir)
responded = set(progress.get("responded", [])) responded = set(progress.get("responded", []))
cache = get_cache(name) cache = get_cache(current_user(), name)
cookies = parse_cookies(read_session(acc_dir)) or None cookies = parse_cookies(read_session(acc_dir)) or None
all_vacancies = [] all_vacancies = []
seen = set() seen = set()
for query in cfg.get("queries", []): for query in cfg.get("queries", []):
found = search_vacancies(query, area=cfg.get("area"), pages=cfg.get("pages", 1), found = search_vacancies(query, area=cfg.get("area"), pages=cfg.get("pages", 1),
cookies=cookies) cookies=cookies, proxies=get_proxies())
for v in found: for v in found:
if v["id"] and v["id"] not in seen: if v["id"] and v["id"] not in seen:
seen.add(v["id"]) seen.add(v["id"])
@@ -311,76 +483,158 @@ def api_search(name):
@app.route("/api/accounts/<name>/generate", methods=["POST"]) @app.route("/api/accounts/<name>/generate", methods=["POST"])
@login_required
def api_generate(name): def api_generate(name):
acc_dir = account_path(name) acc_dir = account_path(name)
if not os.path.isdir(acc_dir):
return jsonify({"error": "Аккаунт не найден"}), 404
data = request.get_json(force=True) data = request.get_json(force=True)
ids = data.get("ids") or [] ids = data.get("ids") or []
cfg = read_json(os.path.join(acc_dir, "config.json"), {}) cfg = read_json(os.path.join(acc_dir, "config.json"), {})
resume = read_resume(acc_dir) resume = read_resume(acc_dir)
cache = get_cache(name) cache = get_cache(current_user(), name)
limit, used = llm_limit_info(current_user())
texts = {} texts = {}
errors = {} errors = {}
for vid in ids: for vid in ids:
if limit is not None and used >= limit:
errors[vid] = f"Достигнут дневной лимит генераций ({limit}). Попробуйте завтра."
continue
vacancy = cache.get(vid) vacancy = cache.get(vid)
if not vacancy: if not vacancy:
errors[vid] = "Вакансия не найдена в кэше — обновите поиск" errors[vid] = "Вакансия не найдена в кэше — обновите поиск"
continue continue
details = fetch_vacancy_details(vid) or {} details = fetch_vacancy_details(vid, proxies=get_proxies()) or {}
vacancy_full = {**vacancy, **details} vacancy_full = {**vacancy, **details}
try: try:
texts[vid] = generate_response(cfg.get("llm", {}), vacancy_full, resume, cfg.get("style", "")) texts[vid] = generate_response(cfg.get("llm", {}), vacancy_full,
resume, cfg.get("style", ""))
if limit is not None:
used += 1
bump_llm_usage(current_user())
except Exception as e: except Exception as e:
errors[vid] = str(e) errors[vid] = str(e)
return jsonify({"texts": texts, "errors": errors}) return jsonify({"texts": texts, "errors": errors, "limit": limit, "used": used})
@app.route("/api/accounts/<name>/open", methods=["POST"]) @app.route("/api/accounts/<name>/open", methods=["POST"])
@login_required
def api_open(name): def api_open(name):
"""Открывает вакансию в браузере и вставляет текст. Работает в фоне.""" """Открывает вакансию в браузере, вставляет текст, делает скриншот."""
acc_dir = account_path(name) acc_dir = account_path(name)
if not os.path.isdir(acc_dir):
return jsonify({"error": "Аккаунт не найден"}), 404
data = request.get_json(force=True) data = request.get_json(force=True)
vid = data.get("id") vid = data.get("id")
message = data.get("message") or "" message = data.get("message") or ""
cfg = read_json(os.path.join(acc_dir, "config.json"), {}) cfg = read_json(os.path.join(acc_dir, "config.json"), {})
resume_id = cfg.get("resume_id") resume_id = cfg.get("resume_id")
cache = get_cache(name) cache = get_cache(current_user(), name)
vacancy = cache.get(vid) vacancy = cache.get(vid)
if not vacancy: if not vacancy:
return jsonify({"error": "Вакансия не найдена в кэше"}), 404 return jsonify({"error": "Вакансия не найдена в кэше — обновите поиск"}), 404
def worker():
try: try:
context = get_context(acc_dir, name) shot_dir = os.path.join(acc_dir, "screenshots")
ok, note = open_response_page(context, vacancy, message, resume_id=resume_id) ok, note, shot = browser_open(acc_dir, current_user(), name, vacancy, message,
print(f"[{name}] {vid}: {note}") resume_id=resume_id, screenshot_dir=shot_dir)
return jsonify({
"ok": ok,
"note": note,
"screenshot": f"/api/accounts/{name}/screenshot/{vid}" if shot else None,
})
except Exception as e: except Exception as e:
print(f"[{name}] {vid}: ошибка браузера: {e}") return jsonify({"error": f"Ошибка браузера: {e}"}), 500
threading.Thread(target=worker, daemon=True).start()
return jsonify({"ok": True, "note": "Открываю браузер..."}) @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"])
@login_required
def api_send(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")
try:
ok, note = browser_send(current_user(), name, vid)
return jsonify({"ok": ok, "note": note})
except Exception as e:
return jsonify({"error": f"Ошибка браузера: {e}"}), 500
@app.route("/api/accounts/<name>/screenshot/<vid>")
@login_required
def api_screenshot(name, vid):
"""Отдаёт скриншот заполненной формы."""
if not re.fullmatch(r"[A-Za-z0-9_-]+", vid):
return jsonify({"error": "Неверный id"}), 404
acc_dir = account_path(name)
path = os.path.join(acc_dir, "screenshots", f"{vid}.png")
if not os.path.exists(path):
return jsonify({"error": "Скриншот не найден"}), 404
return send_file(path, mimetype="image/png")
@app.route("/api/accounts/<name>/done", methods=["POST"]) @app.route("/api/accounts/<name>/done", methods=["POST"])
@login_required
def api_done(name): def api_done(name):
acc_dir = account_path(name) acc_dir = account_path(name)
if not os.path.isdir(acc_dir):
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})
@app.route("/api/accounts/<name>/reset", methods=["POST"]) @app.route("/api/accounts/<name>/reset", methods=["POST"])
@login_required
def api_reset(name): def api_reset(name):
acc_dir = account_path(name) acc_dir = account_path(name)
if not os.path.isdir(acc_dir):
return jsonify({"error": "Аккаунт не найден"}), 404
save_progress(acc_dir, {"responded": []}) save_progress(acc_dir, {"responded": []})
return jsonify({"ok": True}) return jsonify({"ok": True})
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)