Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3179bac026 | ||
|
|
6b2c7a0f09 | ||
|
|
06c142b1ea | ||
|
|
8ca070ed70 | ||
|
|
d757d2f8c7 | ||
|
|
bd8d865798 | ||
|
|
bb105a08b1 | ||
|
|
0d2be8f873 | ||
|
|
ec9f342cf6 | ||
|
|
2d781737f2 | ||
|
|
e4cd06a23d | ||
|
|
4dcaffd4c4 | ||
|
|
7738d35f3d | ||
|
|
40c2c86452 | ||
|
|
a79be642f0 | ||
|
|
963c29084c | ||
|
|
adf6fd24f7 | ||
|
|
25b46a0b2a |
No files matched your search
@@ -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
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
# Секреты — не коммитить
|
# Секреты — не коммитить
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
# Пользователи сервиса — личные данные (пароли, аккаунты, резюме, сессии, прогресс)
|
||||||
|
users/
|
||||||
|
users.json
|
||||||
|
|
||||||
# Аккаунты пользователя — личные данные (config, резюме, сессии, прогресс)
|
# Аккаунты пользователя — личные данные (config, резюме, сессии, прогресс)
|
||||||
accounts/*
|
accounts/*
|
||||||
!accounts/example/
|
!accounts/example/
|
||||||
|
|||||||
@@ -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-ключ, пароли
|
||||||
@@ -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`)
|
||||||
+418
-49
@@ -1,93 +1,462 @@
|
|||||||
#!/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"]',
|
||||||
'[data-qa="vacancy-response-link"]',
|
'[data-qa="vacancy-response-link"]',
|
||||||
'button:has-text("Откликнуться")',
|
'button:has-text("Откликнуться")',
|
||||||
]
|
]
|
||||||
|
# hh.ru прячет поле письма за переключателем «Сопроводительное письмо / Добавить»:
|
||||||
|
# до клика по нему на форме есть только textarea вопроса скрининга (task_*).
|
||||||
|
# ВАЖНО: запасной селектор 'textarea' НЕ используется, если есть вопросы скрининга —
|
||||||
|
# иначе текст письма попадёт в первый вопрос вместо поля письма.
|
||||||
|
LETTER_TOGGLE = '[data-qa="vacancy-response-letter-toggle"]'
|
||||||
MESSAGE_FIELDS = [
|
MESSAGE_FIELDS = [
|
||||||
|
'textarea[data-qa="vacancy-response-popup-form-letter-input"]',
|
||||||
'textarea[data-qa="vacancy-response-letter"]',
|
'textarea[data-qa="vacancy-response-letter"]',
|
||||||
'textarea[data-qa="vacancy-response-popup-form"] textarea',
|
'textarea[data-qa="vacancy-response-popup-form"] textarea',
|
||||||
'textarea',
|
]
|
||||||
|
SUBMIT_BUTTONS = [
|
||||||
|
'[data-qa="vacancy-response-submit-popup"]',
|
||||||
|
'[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:
|
try:
|
||||||
_browsers[account_name] = launch_context(account_dir)
|
ctx.pages
|
||||||
return _browsers[account_name]
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def close_context(account_name):
|
def _inject_session_cookies(context, account_dir):
|
||||||
if account_name in _browsers:
|
"""Подставляет 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:
|
try:
|
||||||
_browsers[account_name].close()
|
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).
|
||||||
|
Браузер закрывается ВСЕГДА, кроме ручного режима (submit=False, успех) —
|
||||||
|
там он нужен для confirm_send."""
|
||||||
|
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)
|
||||||
|
_pages[key] = page
|
||||||
|
keep_open = False
|
||||||
|
try:
|
||||||
|
page.goto(vacancy["url"], timeout=45000)
|
||||||
|
page.wait_for_timeout(2500)
|
||||||
|
|
||||||
|
# Кнопка «Откликнуться»
|
||||||
|
clicked = False
|
||||||
|
for sel in RESPONSE_BUTTONS:
|
||||||
|
try:
|
||||||
|
btn = page.locator(sel).first
|
||||||
|
if btn.is_visible(timeout=3000):
|
||||||
|
btn.click()
|
||||||
|
clicked = True
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if not clicked:
|
||||||
|
shot = _save_screenshot(page, screenshot_dir, vacancy["id"])
|
||||||
|
return False, "Не нашёл кнопку «Откликнуться» — проверьте скриншот", shot
|
||||||
|
|
||||||
|
# Ждём модалку
|
||||||
|
page.wait_for_timeout(2500)
|
||||||
|
|
||||||
|
# hh.ru прячет поле письма за переключателем «Сопроводительное письмо» —
|
||||||
|
# раскрываем его, иначе заполнится textarea вопроса скрининга (task_*)
|
||||||
|
try:
|
||||||
|
toggle = page.locator(LETTER_TOGGLE).first
|
||||||
|
toggle.wait_for(state="visible", timeout=5000)
|
||||||
|
toggle.click()
|
||||||
|
page.wait_for_timeout(800)
|
||||||
|
print(" ✓ Переключатель «Сопроводительное письмо» раскрыт")
|
||||||
|
except Exception:
|
||||||
|
print(" ⚠ Переключатель письма не найден — поле может отсутствовать")
|
||||||
|
|
||||||
|
# Выбор резюме (радио-кнопка name="resumeId" value="{hash}")
|
||||||
|
if resume_id:
|
||||||
|
try:
|
||||||
|
radio = page.locator(f'input[name="resumeId"][value="{resume_id}"]').first
|
||||||
|
if radio.is_visible(timeout=3000):
|
||||||
|
radio.check(force=True)
|
||||||
|
print(f" ✓ Резюме {resume_id} выбрано")
|
||||||
|
except Exception:
|
||||||
|
print(" ⚠ Не нашёл радио-кнопку резюме — выберите вручную")
|
||||||
|
|
||||||
|
# Вставляем текст
|
||||||
|
filled = False
|
||||||
|
for sel in MESSAGE_FIELDS:
|
||||||
|
try:
|
||||||
|
field = page.locator(sel).first
|
||||||
|
if field.is_visible(timeout=3000):
|
||||||
|
field.fill(message)
|
||||||
|
filled = True
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not filled:
|
||||||
|
# Запасной вариант: если вопросов скрининга НЕТ, единственная
|
||||||
|
# textarea на форме — это поле письма (старая версия формы).
|
||||||
|
# При наличии task_* НЕ заполняем — иначе письмо попадёт в вопрос.
|
||||||
|
try:
|
||||||
|
if page.locator('textarea[name^="task_"]').count() == 0:
|
||||||
|
field = page.locator('textarea').first
|
||||||
|
if field.is_visible(timeout=2000):
|
||||||
|
field.fill(message)
|
||||||
|
filled = True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not filled:
|
||||||
|
shot = _save_screenshot(page, screenshot_dir, vacancy["id"])
|
||||||
|
return False, "Не нашёл поле сообщения — проверьте скриншот", shot
|
||||||
|
|
||||||
|
# Одно-кликовый режим: сразу жмём «Отправить»
|
||||||
|
if submit:
|
||||||
|
submitted = False
|
||||||
|
for sel in SUBMIT_BUTTONS:
|
||||||
|
try:
|
||||||
|
btn = page.locator(sel).first
|
||||||
|
if btn.is_visible(timeout=3000):
|
||||||
|
btn.click()
|
||||||
|
submitted = True
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if not submitted:
|
||||||
|
shot = _save_screenshot(page, screenshot_dir, vacancy["id"])
|
||||||
|
return False, "Не нашёл кнопку «Отправить» — проверьте скриншот", shot
|
||||||
|
# hh.ru иногда показывает вторую модалку подтверждения — жмём и её
|
||||||
|
try:
|
||||||
|
_click_second_confirm(page)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Ждём подтверждение и снимаем скриншот результата.
|
||||||
|
# После отправки hh.ru может закрыть модалку/перезагрузить страницу —
|
||||||
|
# ловим любые ошибки, но отклик уже отправлен.
|
||||||
|
try:
|
||||||
|
page.wait_for_timeout(2500)
|
||||||
|
# Проверяем, не осталась ли форма открытой с ошибкой валидации
|
||||||
|
# (например, не заполнены обязательные вопросы скрининга)
|
||||||
|
form_open = page.locator('[data-qa="vacancy-response-popup-form"]').count()
|
||||||
|
err_visible = False
|
||||||
|
if form_open:
|
||||||
|
for sel in ['[data-qa="vacancy-response-error"]',
|
||||||
|
'[data-qa="vacancy-response-popup-form"] [class*="error"]']:
|
||||||
|
try:
|
||||||
|
if page.locator(sel).first.is_visible(timeout=1000):
|
||||||
|
err_visible = True
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
shot = _save_screenshot(page, screenshot_dir, vacancy["id"])
|
||||||
|
if form_open and err_visible:
|
||||||
|
return False, "hh.ru не принял отклик — проверьте скриншот (возможно, не заполнены обязательные вопросы)", shot
|
||||||
|
except Exception:
|
||||||
|
shot = None
|
||||||
|
return True, "Отклик отправлен", shot
|
||||||
|
|
||||||
|
# Ручной режим: браузер оставляем открытым для confirm_send
|
||||||
|
keep_open = True
|
||||||
|
shot = _save_screenshot(page, screenshot_dir, vacancy["id"])
|
||||||
|
return True, "Текст вставлен. Проверьте скриншот и нажмите «Отправить»", shot
|
||||||
|
finally:
|
||||||
|
# Браузер закрываем ВСЕГДА, кроме ручного режима (нужен для confirm_send)
|
||||||
|
if not keep_open:
|
||||||
|
_close_context(key)
|
||||||
|
|
||||||
|
|
||||||
def open_response_page(context, vacancy, message, resume_id=None):
|
def confirm_send(user, account, vid):
|
||||||
"""Открывает вакансию, жмёт «Откликнуться», выбирает резюме и вставляет текст.
|
"""Кликает «Отправить» на открытой странице. Возвращает (ok, note)."""
|
||||||
Возвращает (ok, note): ok=True если текст вставлен автоматически."""
|
key = (user, account)
|
||||||
page = context.new_page()
|
page = _pages.get(key)
|
||||||
page.goto(vacancy["url"], timeout=45000)
|
if not page:
|
||||||
page.wait_for_timeout(2500)
|
return False, "Страница не открыта — сначала нажмите «Открыть»"
|
||||||
|
for sel in SUBMIT_BUTTONS:
|
||||||
# Кнопка «Откликнуться»
|
|
||||||
clicked = False
|
|
||||||
for sel in RESPONSE_BUTTONS:
|
|
||||||
try:
|
try:
|
||||||
btn = page.locator(sel).first
|
btn = page.locator(sel).first
|
||||||
if btn.is_visible(timeout=3000):
|
if btn.is_visible(timeout=3000):
|
||||||
btn.click()
|
btn.click()
|
||||||
clicked = True
|
try:
|
||||||
break
|
# Вторая модалка подтверждения (если появилась)
|
||||||
|
_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:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
if not clicked:
|
# Кнопка не найдена — браузер больше не нужен, закрываем
|
||||||
return False, "Не нашёл кнопку «Откликнуться» — сделайте это вручную"
|
_close_context(key)
|
||||||
|
return False, "Не нашёл кнопку «Отправить» — возможно, форма уже закрыта"
|
||||||
|
|
||||||
# Ждём модалку
|
|
||||||
page.wait_for_timeout(2000)
|
|
||||||
|
|
||||||
# Выбор резюме (радио-кнопка name="resumeId" value="{hash}")
|
def open_login_page(account_dir, user, account):
|
||||||
if resume_id:
|
"""Открывает 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:
|
try:
|
||||||
radio = page.locator(f'input[name="resumeId"][value="{resume_id}"]').first
|
_browsers[key].close()
|
||||||
if radio.is_visible(timeout=3000):
|
|
||||||
radio.check(force=True)
|
|
||||||
print(f" ✓ Резюме {resume_id} выбрано")
|
|
||||||
except Exception:
|
except Exception:
|
||||||
print(" ⚠ Не нашёл радио-кнопку резюме — выберите вручную")
|
pass
|
||||||
|
_browsers.pop(key, None)
|
||||||
# Вставляем текст
|
_pages.pop(key, None)
|
||||||
for sel in MESSAGE_FIELDS:
|
p = _playwrights.pop(key, None)
|
||||||
|
if p:
|
||||||
try:
|
try:
|
||||||
field = page.locator(sel).first
|
p.stop()
|
||||||
if field.is_visible(timeout=3000):
|
|
||||||
field.fill(message)
|
|
||||||
return True, "Текст вставлен. Проверьте и нажмите «Отправить»"
|
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
pass
|
||||||
return False, "Не нашёл поле сообщения — вставьте текст вручную"
|
|
||||||
|
|
||||||
|
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))
|
||||||
@@ -90,7 +90,10 @@ def generate_response(llm_config, vacancy, resume_text, style_hint=""):
|
|||||||
raise RuntimeError(f"LLM ошибка {resp.status_code}: {resp.text[:300]}")
|
raise RuntimeError(f"LLM ошибка {resp.status_code}: {resp.text[:300]}")
|
||||||
|
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
return data["choices"][0]["message"]["content"].strip()
|
content = (data["choices"][0]["message"].get("content") or "").strip()
|
||||||
|
if not content:
|
||||||
|
raise RuntimeError("LLM вернул пустой текст — попробуйте ещё раз")
|
||||||
|
return content
|
||||||
|
|
||||||
|
|
||||||
def generate_response_with_retry(llm_config, vacancy, resume_text, style_hint="", attempts=2):
|
def generate_response_with_retry(llm_config, vacancy, resume_text, style_hint="", attempts=2):
|
||||||
|
|||||||
+84
-73
@@ -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,70 +109,71 @@ 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:
|
for i, vacancy in enumerate(pending, 1):
|
||||||
context = get_context(account_dir, account_name)
|
print(f"\n [{i}/{len(pending)}] {vacancy['title']} — {vacancy['company']}")
|
||||||
|
print(f" {vacancy['url']}")
|
||||||
|
|
||||||
for i, vacancy in enumerate(pending, 1):
|
# Описание вакансии для ИИ
|
||||||
print(f"\n [{i}/{len(pending)}] {vacancy['title']} — {vacancy['company']}")
|
details = fetch_vacancy_details(vacancy["id"], proxies=get_proxies()) or {}
|
||||||
print(f" {vacancy['url']}")
|
vacancy_full = {**vacancy, **details}
|
||||||
|
|
||||||
# Описание вакансии для ИИ
|
# Генерация текста
|
||||||
details = fetch_vacancy_details(vacancy["id"]) or {}
|
try:
|
||||||
vacancy_full = {**vacancy, **details}
|
message = generate_response(llm_cfg, vacancy_full, cfg.get("resume", ""), style)
|
||||||
|
except Exception as e:
|
||||||
# Генерация текста
|
print(f" ⚠ Ошибка ИИ: {e}")
|
||||||
try:
|
message = input(" Введите текст отклика вручную (Enter — пропустить): ").strip()
|
||||||
message = generate_response(llm_cfg, vacancy_full, cfg.get("resume", ""), style)
|
if not message:
|
||||||
except Exception as e:
|
|
||||||
print(f" ⚠ Ошибка ИИ: {e}")
|
|
||||||
message = input(" Введите текст отклика вручную (Enter — пропустить): ").strip()
|
|
||||||
if not message:
|
|
||||||
continue
|
|
||||||
|
|
||||||
print(f"\n --- Текст отклика ---\n {message}\n --------------------")
|
|
||||||
|
|
||||||
if dry_run:
|
|
||||||
print(" [dry-run] браузер не открываю")
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Открываем в браузере
|
print(f"\n --- Текст отклика ---\n {message}\n --------------------")
|
||||||
ok, note = open_response_page(context, vacancy_full, message)
|
|
||||||
print(f" {'✓' if ok else '⚠'} {note}")
|
|
||||||
|
|
||||||
# Ждём решения пользователя
|
if dry_run:
|
||||||
while True:
|
print(" [dry-run] браузер не открываю")
|
||||||
choice = input(" Отправили? [Enter=да, s=пропустить, q=выйти]: ").strip().lower()
|
continue
|
||||||
if choice in ("", "д", "y", "yes", "да"):
|
|
||||||
responded.add(vacancy["id"])
|
|
||||||
break
|
|
||||||
if choice == "s":
|
|
||||||
break
|
|
||||||
if choice == "q":
|
|
||||||
close_context(account_name)
|
|
||||||
progress["responded"] = sorted(responded)
|
|
||||||
save_progress(account_dir, progress)
|
|
||||||
print(" Выход. Прогресс сохранён.")
|
|
||||||
return
|
|
||||||
progress["responded"] = sorted(responded)
|
|
||||||
save_progress(account_dir, progress)
|
|
||||||
|
|
||||||
close_context(account_name)
|
# Открываем в браузере и делаем скриншот
|
||||||
|
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}")
|
||||||
|
if shot:
|
||||||
|
print(f" Скриншот: {shot}")
|
||||||
|
|
||||||
|
# Ждём решения пользователя
|
||||||
|
while True:
|
||||||
|
choice = input(" Отправить отклик? [Enter=да, s=пропустить, q=выйти]: ").strip().lower()
|
||||||
|
if choice in ("", "д", "y", "yes", "да"):
|
||||||
|
ok2, note2 = browser_send(user, account_name, vacancy["id"])
|
||||||
|
print(f" {'✓' if ok2 else '⚠'} {note2}")
|
||||||
|
responded.add(vacancy["id"])
|
||||||
|
break
|
||||||
|
if choice == "s":
|
||||||
|
break
|
||||||
|
if choice == "q":
|
||||||
|
browser_close(user, account_name)
|
||||||
|
progress["responded"] = sorted(responded)
|
||||||
|
save_progress(account_dir, progress)
|
||||||
|
print(" Выход. Прогресс сохранён.")
|
||||||
|
return
|
||||||
|
progress["responded"] = sorted(responded)
|
||||||
|
save_progress(account_dir, progress)
|
||||||
|
|
||||||
|
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
@@ -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
@@ -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()
|
||||||
+2
-1
@@ -2,4 +2,5 @@ requests>=2.31.0
|
|||||||
playwright>=1.40.0
|
playwright>=1.40.0
|
||||||
flask>=3.0.0
|
flask>=3.0.0
|
||||||
pypdf>=4.0.0
|
pypdf>=4.0.0
|
||||||
python-docx>=1.1.0
|
python-docx>=1.1.0
|
||||||
|
python-dotenv>=1.0.0
|
||||||
+255
-7
@@ -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,196 @@ function escapeHtml(s) {
|
|||||||
.replace(/"/g,'"');
|
.replace(/"/g,'"');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Открытие и отметка ----------
|
// ---------- Открытие, проверка и отправка ----------
|
||||||
|
async function openSendModal(id) {
|
||||||
|
if (!currentAccount) { alert('Сначала выберите аккаунт'); return; }
|
||||||
|
const v = vacancies.find(x => x.id === id);
|
||||||
|
if (!v) return;
|
||||||
|
sendModalId = id;
|
||||||
|
document.getElementById('send-vacancy-title').textContent = `${v.title} — ${v.company || ''}`;
|
||||||
|
document.getElementById('send-letter').value = '';
|
||||||
|
document.getElementById('send-letter').disabled = true;
|
||||||
|
document.getElementById('btn-send-letter').disabled = true;
|
||||||
|
document.getElementById('send-modal-note').textContent = '';
|
||||||
|
document.getElementById('send-modal').classList.remove('hidden');
|
||||||
|
|
||||||
|
const cached = generatedTexts[id];
|
||||||
|
if (cached) {
|
||||||
|
// Текст уже сгенерирован ранее — показываем сразу
|
||||||
|
document.getElementById('send-letter').value = cached;
|
||||||
|
document.getElementById('send-letter').disabled = false;
|
||||||
|
document.getElementById('btn-send-letter').disabled = false;
|
||||||
|
document.getElementById('send-loading').classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Генерируем текст (прелоадер виден)
|
||||||
|
document.getElementById('send-loading').classList.remove('hidden');
|
||||||
|
try {
|
||||||
|
const data = await api(`/api/accounts/${currentAccount}/generate`, {
|
||||||
|
method: 'POST', body: JSON.stringify({ ids: [id] }),
|
||||||
|
});
|
||||||
|
document.getElementById('send-loading').classList.add('hidden');
|
||||||
|
if (data.texts && data.texts[id]) {
|
||||||
|
generatedTexts[id] = data.texts[id];
|
||||||
|
document.getElementById('send-letter').value = data.texts[id];
|
||||||
|
document.getElementById('send-letter').disabled = false;
|
||||||
|
document.getElementById('btn-send-letter').disabled = false;
|
||||||
|
renderTexts();
|
||||||
|
} else if (data.errors && data.errors[id]) {
|
||||||
|
document.getElementById('send-modal-note').textContent = 'Ошибка: ' + data.errors[id];
|
||||||
|
} else {
|
||||||
|
document.getElementById('send-modal-note').textContent = 'Не удалось сгенерировать текст — попробуйте ещё раз';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('send-loading').classList.add('hidden');
|
||||||
|
document.getElementById('send-modal-note').textContent = 'Ошибка: ' + e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSendModal() {
|
||||||
|
document.getElementById('send-modal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendFromModal() {
|
||||||
|
if (!currentAccount || !sendModalId) return;
|
||||||
|
const message = document.getElementById('send-letter').value;
|
||||||
|
const btn = document.getElementById('btn-send-letter');
|
||||||
|
const note = document.getElementById('send-modal-note');
|
||||||
|
btn.disabled = true; note.textContent = 'Отправляю...';
|
||||||
|
try {
|
||||||
|
const data = await api(`/api/accounts/${currentAccount}/send-now`, {
|
||||||
|
method: 'POST', body: JSON.stringify({ id: sendModalId, message }),
|
||||||
|
});
|
||||||
|
if (data.ok) {
|
||||||
|
// Отклик отправлен — статус меняем всегда, даже если скриншота нет
|
||||||
|
sentTexts.add(sendModalId);
|
||||||
|
const v = vacancies.find(x => x.id === sendModalId);
|
||||||
|
if (v) v.responded = true;
|
||||||
|
renderVacancies();
|
||||||
|
renderTexts();
|
||||||
|
closeSendModal();
|
||||||
|
if (data.screenshot) {
|
||||||
|
showShotModal(sendModalId, data.screenshot, data.note || 'Отклик отправлен', true);
|
||||||
|
} else {
|
||||||
|
alert(data.note || 'Отклик отправлен');
|
||||||
|
}
|
||||||
|
} else if (data.screenshot) {
|
||||||
|
// Бэкенд вернул скриншот, но отклик НЕ отправлен (например, не нашёл поле)
|
||||||
|
note.textContent = data.note || 'Не удалось отправить отклик';
|
||||||
|
btn.disabled = false;
|
||||||
|
showShotModal(sendModalId, data.screenshot, data.note || 'Не удалось отправить отклик', false);
|
||||||
|
} else {
|
||||||
|
note.textContent = data.note || 'Не удалось отправить отклик';
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
note.textContent = 'Ошибка: ' + e.message;
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showShotModal(id, screenshot, note, sent) {
|
||||||
|
currentShotId = id;
|
||||||
|
document.getElementById('shot-img').src = screenshot + '?t=' + Date.now();
|
||||||
|
document.getElementById('shot-note').textContent = note;
|
||||||
|
document.getElementById('send-note').textContent = '';
|
||||||
|
document.getElementById('btn-send').style.display = sent ? 'none' : '';
|
||||||
|
document.getElementById('btn-done').style.display = sent ? 'none' : '';
|
||||||
|
document.getElementById('shot-modal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
async function openVacancy(id) {
|
async function openVacancy(id) {
|
||||||
const ta = document.querySelector(`textarea[data-id="${id}"]`);
|
const ta = document.querySelector(`textarea[data-id="${id}"]`);
|
||||||
const message = ta ? ta.value : (generatedTexts[id] || '');
|
const message = ta ? ta.value : (generatedTexts[id] || '');
|
||||||
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.ok) {
|
||||||
|
sentTexts.add(id);
|
||||||
|
const v = vacancies.find(x => x.id === id);
|
||||||
|
if (v) v.responded = true;
|
||||||
|
renderVacancies();
|
||||||
|
renderTexts();
|
||||||
|
if (data.screenshot) {
|
||||||
|
showShotModal(id, data.screenshot, data.note || 'Отклик отправлен', true);
|
||||||
|
} else {
|
||||||
|
alert(data.note || 'Отклик отправлен');
|
||||||
|
}
|
||||||
|
} else if (data.screenshot) {
|
||||||
|
showShotModal(id, data.screenshot, data.note || 'Не удалось отправить отклик', false);
|
||||||
|
} else {
|
||||||
|
alert(data.note || 'Не удалось отправить отклик');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (note) note.textContent = 'Ошибка: ' + e.message;
|
||||||
|
alert('Ошибка: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeShot() {
|
||||||
|
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 +818,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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Старт ----------
|
// ---------- Старт ----------
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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_with_retry
|
||||||
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_with_retry(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:
|
shot_dir = os.path.join(acc_dir, "screenshots")
|
||||||
context = get_context(acc_dir, name)
|
ok, note, shot = browser_open(acc_dir, current_user(), name, vacancy, message,
|
||||||
ok, note = open_response_page(context, vacancy, message, resume_id=resume_id)
|
resume_id=resume_id, screenshot_dir=shot_dir)
|
||||||
print(f"[{name}] {vid}: {note}")
|
return jsonify({
|
||||||
except Exception as e:
|
"ok": ok,
|
||||||
print(f"[{name}] {vid}: ошибка браузера: {e}")
|
"note": note,
|
||||||
|
"screenshot": f"/api/accounts/{name}/screenshot/{vid}" if shot else None,
|
||||||
|
})
|
||||||
|
except Exception as 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)
|
||||||
Reference in new issue
Block a user