Compare commits

...
10 changed files with 1163 additions and 122 deletions

No files matched your search

+17
View File
@@ -0,0 +1,17 @@
# Ключ 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
+4
View File
@@ -1,6 +1,10 @@
# Секреты — не коммитить
.env
# Пользователи сервиса — личные данные (пароли, аккаунты, резюме, сессии, прогресс)
users/
users.json
# Аккаунты пользователя — личные данные (config, резюме, сессии, прогресс)
accounts/*
!accounts/example/
+350
View File
@@ -0,0 +1,350 @@
# Деплой hh_auto на физический Linux-сервер (многопользовательский, без VNC)
## Архитектура
```
Браузер пользователя
│ HTTPS (443)
┌───────┴───────┐
│ nginx │ Let's Encrypt
│ reverse proxy│
└───────┬───────┘
│ :5000
▼
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 человек), пользователей создаёт владелец (без открытой регистрации)
---
## Этап 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 — пользователи
```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:5000 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:5000;
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:5000/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-ключ, пароли
+219 -24
View File
@@ -1,9 +1,22 @@
#!/usr/bin/env python3
"""Работа с браузером: открытие вакансии, клик «Откликнуться», вставка текста."""
"""Работа с браузером: открытие вакансии, клик «Откликнуться», вставка текста,
скриншот для проверки человеком и клик «Отправить» по подтверждению.
Без VNC: браузер работает headless (или на Xvfb-дисплее сервера), а человек
проверяет скриншот заполненной формы в веб-интерфейсе и подтверждает отправку.
Все операции с браузером выполняются в ОДНОМ выделенном потоке
(run_in_browser): Playwright sync API не потокобезопасен.
"""
import os
import queue
import threading
import time
from playwright.sync_api import sync_playwright
from hh_search import parse_cookies
# Селекторы hh.ru (с запасными вариантами)
RESPONSE_BUTTONS = [
'[data-qa="vacancy-response-link-top"]',
@@ -15,42 +28,157 @@ MESSAGE_FIELDS = [
'textarea[data-qa="vacancy-response-popup-form"] textarea',
'textarea',
]
SUBMIT_BUTTONS = [
'[data-qa="vacancy-response-submit"]',
'[data-qa="vacancy-response-popup-form"] button[type="submit"]',
'button:has-text("Отправить")',
]
# Живые браузерные контексты по аккаунтам: {account_name: context}
# Живые браузерные контексты и страницы: {(user, account): ...}
# Доступны ТОЛЬКО из браузерного потока.
_browsers = {}
_pages = {}
# ---------- Браузерный поток ----------
_jobs = queue.Queue()
_browser_thread = None
def launch_context(account_dir):
"""Запускает браузер с профилем аккаунта (видимое окно)."""
def _browser_worker():
while True:
job = _jobs.get()
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(account_dir):
"""Запускает браузер с профилем аккаунта и подставляет cookies сессии."""
p = sync_playwright().start()
context = p.chromium.launch_persistent_context(
kwargs = dict(
user_data_dir=os.path.join(account_dir, "profile"),
headless=False,
headless=is_headless(),
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
def get_context(account_dir, account_name):
"""Возвращает живой контекст аккаунта, создавая при необходимости."""
if account_name not in _browsers:
_browsers[account_name] = launch_context(account_dir)
return _browsers[account_name]
def close_context(account_name):
if account_name in _browsers:
def _inject_session_cookies(context, account_dir):
"""Подставляет cookies из session.txt — вход на hh.ru без ручного логина."""
path = os.path.join(account_dir, "session.txt")
if not os.path.exists(path):
return
with open(path, encoding="utf-8") as f:
cookie_str = f.read().strip()
if not cookie_str:
return
cookies = parse_cookies(cookie_str)
if not cookies:
return
try:
_browsers[account_name].close()
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)
if key not in _browsers:
_browsers[key] = _launch_context(account_dir)
return _browsers[key]
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 open_response_page(account_dir, user, account, vacancy, message,
resume_id=None, screenshot_dir=None):
"""Открывает вакансию, жмёт «Откликнуться», выбирает резюме, вставляет текст,
делает скриншот. Возвращает (ok, note, screenshot_path)."""
key = (user, account)
# Закрываем предыдущую страницу этого аккаунта
prev = _pages.pop(key, None)
if prev:
try:
prev.close()
except Exception:
pass
_browsers.pop(account_name, None)
def open_response_page(context, vacancy, message, resume_id=None):
"""Открывает вакансию, жмёт «Откликнуться», выбирает резюме и вставляет текст.
Возвращает (ok, note): ok=True если текст вставлен автоматически."""
context = _get_context(account_dir, user, account)
page = context.new_page()
_pages[key] = page
page.goto(vacancy["url"], timeout=45000)
page.wait_for_timeout(2500)
@@ -66,7 +194,8 @@ def open_response_page(context, vacancy, message, resume_id=None):
except Exception:
continue
if not clicked:
return False, "Не нашёл кнопку «Откликнуться» — сделайте это вручную"
shot = _save_screenshot(page, screenshot_dir, vacancy["id"])
return False, "Не нашёл кнопку «Откликнуться» — проверьте скриншот", shot
# Ждём модалку
page.wait_for_timeout(2000)
@@ -82,12 +211,78 @@ def open_response_page(context, vacancy, message, resume_id=None):
print(" ⚠ Не нашёл радио-кнопку резюме — выберите вручную")
# Вставляем текст
filled = False
for sel in MESSAGE_FIELDS:
try:
field = page.locator(sel).first
if field.is_visible(timeout=3000):
field.fill(message)
return True, "Текст вставлен. Проверьте и нажмите «Отправить»"
filled = True
break
except Exception:
continue
return False, "Не нашёл поле сообщения — вставьте текст вручную"
shot = _save_screenshot(page, screenshot_dir, vacancy["id"])
if filled:
return True, "Текст вставлен. Проверьте скриншот и нажмите «Отправить»", shot
return False, "Не нашёл поле сообщения — проверьте скриншот", shot
def confirm_send(user, account, vid):
"""Кликает «Отправить» на открытой странице. Возвращает (ok, note)."""
key = (user, account)
page = _pages.get(key)
if not page:
return False, "Страница не открыта — сначала нажмите «Открыть»"
for sel in SUBMIT_BUTTONS:
try:
btn = page.locator(sel).first
if btn.is_visible(timeout=3000):
btn.click()
page.wait_for_timeout(1500)
return True, "Кнопка «Отправить» нажата. Проверьте статус отклика на hh.ru."
except Exception:
continue
return False, "Не нашёл кнопку «Отправить» — возможно, форма уже закрыта"
def open_login_page(account_dir, user, account):
"""Открывает hh.ru в браузере (для ручного входа, локальный режим с экраном)."""
key = (user, account)
context = _get_context(account_dir, user, account)
page = context.new_page()
_pages[key] = page
page.goto("https://hh.ru", timeout=45000)
return True, "Страница hh.ru открыта"
def close_context(user, account):
key = (user, account)
if key in _browsers:
try:
_browsers[key].close()
except Exception:
pass
_browsers.pop(key, None)
_pages.pop(key, None)
# ---------- Публичные обёртки (можно вызывать из любого потока) ----------
def browser_open(account_dir, user, account, vacancy, message,
resume_id=None, screenshot_dir=None):
return run_in_browser(lambda: open_response_page(
account_dir, user, account, vacancy, message,
resume_id=resume_id, screenshot_dir=screenshot_dir))
def browser_send(user, account, vid):
return run_in_browser(lambda: confirm_send(user, account, vid))
def browser_open_login(account_dir, user, account):
return run_in_browser(lambda: open_login_page(account_dir, user, account))
def browser_close(user, account):
return run_in_browser(lambda: close_context(user, account))
+52 -41
View File
@@ -2,16 +2,16 @@
"""Полуавтоматический отклик на вакансии hh.ru.
Безопасный режим: скрипт ищет вакансии, генерирует текст отклика через ИИ,
открывает страницу в браузере и подставляет текст. Финальное «Отправить»
всегда нажимаете вы.
открывает страницу в браузере (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 --account my_account # только один аккаунт
python3 hh_respond.py --user friend1 # пользователь сервиса
python3 hh_respond.py --limit 5 # максимум 5 откликов на аккаунт
python3 hh_respond.py --dry-run # без браузера, только текст
"""
@@ -21,16 +21,22 @@ import os
import sys
import time
from playwright.sync_api import sync_playwright
from hh_search import search_vacancies, fetch_vacancy_details
from hh_search import search_vacancies, fetch_vacancy_details, get_proxies
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__))
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):
cfg_path = os.path.join(account_dir, "config.json")
with open(cfg_path, encoding="utf-8") as f:
@@ -64,7 +70,8 @@ def collect_vacancies(cfg):
seen = set()
for query in cfg.get("queries", []):
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:
if v["id"] and v["id"] not in seen:
seen.add(v["id"])
@@ -73,17 +80,18 @@ def collect_vacancies(cfg):
return result
def process_account(account_name, limit, dry_run):
account_dir = os.path.join(ACCOUNTS_DIR, account_name)
def process_account(account_name, limit, dry_run, user):
accounts_dir = resolve_accounts_dir(user)
account_dir = os.path.join(accounts_dir, account_name)
if not os.path.isdir(account_dir):
print(f"Аккаунт «{account_name}» не найден в {ACCOUNTS_DIR}")
print(f"Аккаунт «{account_name}» не найден в {accounts_dir}")
return
cfg = load_account_config(account_dir)
progress = load_progress(account_dir)
responded = set(progress["responded"])
print(f"\n=== Аккаунт: {account_name} ===")
print(f"\n=== Аккаунт: {account_name} (пользователь: {user}) ===")
if not cfg.get("resume"):
print(" ⚠ Нет файла resume.txt — текст отклика будет без учёта резюме")
@@ -101,15 +109,12 @@ def process_account(account_name, limit, dry_run):
llm_cfg = cfg.get("llm", {})
style = cfg.get("style", "")
with sync_playwright() as p:
context = get_context(account_dir, account_name)
for i, vacancy in enumerate(pending, 1):
print(f"\n [{i}/{len(pending)}] {vacancy['title']} — {vacancy['company']}")
print(f" {vacancy['url']}")
# Описание вакансии для ИИ
details = fetch_vacancy_details(vacancy["id"]) or {}
details = fetch_vacancy_details(vacancy["id"], proxies=get_proxies()) or {}
vacancy_full = {**vacancy, **details}
# Генерация текста
@@ -127,20 +132,26 @@ def process_account(account_name, limit, dry_run):
print(" [dry-run] браузер не открываю")
continue
# Открываем в браузере
ok, note = open_response_page(context, vacancy_full, message)
# Открываем в браузере и делаем скриншот
shot_dir = os.path.join(account_dir, "screenshots")
ok, note, shot = browser_open(account_dir, user, account_name, vacancy_full, message,
resume_id=cfg.get("resume_id"), screenshot_dir=shot_dir)
print(f" {'✓' if ok else '⚠'} {note}")
if shot:
print(f" Скриншот: {shot}")
# Ждём решения пользователя
while True:
choice = input(" Отправили? [Enter=да, s=пропустить, q=выйти]: ").strip().lower()
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":
close_context(account_name)
browser_close(user, account_name)
progress["responded"] = sorted(responded)
save_progress(account_dir, progress)
print(" Выход. Прогресс сохранён.")
@@ -148,23 +159,21 @@ def process_account(account_name, limit, dry_run):
progress["responded"] = sorted(responded)
save_progress(account_dir, progress)
close_context(account_name)
browser_close(user, account_name)
print(f"\n=== Аккаунт {account_name}: готово ===")
def login_account(account_name):
"""Открывает браузер с профилем аккаунта для ручного входа."""
account_dir = os.path.join(ACCOUNTS_DIR, account_name)
def login_account(account_name, user):
"""Открывает браузер для ручного входа (локальный режим с экраном)."""
accounts_dir = resolve_accounts_dir(user)
account_dir = os.path.join(accounts_dir, account_name)
if not os.path.isdir(account_dir):
print(f"Аккаунт «{account_name}» не найден в {ACCOUNTS_DIR}")
print(f"Аккаунт «{account_name}» не найден в {accounts_dir}")
return
print(f"Открываю браузер для аккаунта «{account_name}». Войдите на hh.ru и закройте окно.")
context = get_context(account_dir, account_name)
page = context.new_page()
page.goto("https://hh.ru", timeout=45000)
print("Когда войдёте — закройте окно браузера.")
close_context(account_name)
print(f"Открываю браузер для аккаунта «{account_name}». Войдите на hh.ru.")
browser_open_login(account_dir, user, account_name)
input("Когда войдёте — нажмите Enter...")
browser_close(user, account_name)
print("Сессия сохранена.")
@@ -172,27 +181,29 @@ def main():
parser = argparse.ArgumentParser(description="Полуавтоматический отклик на hh.ru")
parser.add_argument("--account", help="Имя аккаунта (папка в accounts/)")
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("--dry-run", action="store_true", help="Без браузера, только текст")
args = parser.parse_args()
if args.login:
login_account(args.login)
login_account(args.login, args.user)
return
if not os.path.isdir(ACCOUNTS_DIR):
print(f"Нет папки {ACCOUNTS_DIR}. Создайте аккаунт: см. README.md")
accounts_dir = resolve_accounts_dir(args.user)
if not os.path.isdir(accounts_dir):
print(f"Нет папки {accounts_dir}. Создайте аккаунт: см. README.md")
return
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 = [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))]
if not accounts:
print("Нет аккаунтов в accounts/. Создайте папку аккаунта (см. README.md)")
print(f"Нет аккаунтов в {accounts_dir}. Создайте папку аккаунта (см. README.md)")
return
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__":
+16 -6
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env python3
"""Поиск вакансий на hh.ru через парсинг страницы поиска (без API)."""
import json
import os
import re
import time
import urllib.parse
@@ -16,6 +17,15 @@ HEADERS = {
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):
"""Убираем HTML-теги и лишние пробелы."""
text = re.sub(r"<[^>]+>", "", text)
@@ -117,7 +127,7 @@ def _is_latin1(s):
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 = []
seen = set()
@@ -126,7 +136,7 @@ def search_vacancies(query, area=None, pages=1, per_page=20, delay=2.0, cookies=
if area:
params["area"] = area
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:
print(f" [поиск] HTTP {resp.status_code} для страницы {page_num}")
break
@@ -144,10 +154,10 @@ def search_vacancies(query, area=None, pages=1, per_page=20, delay=2.0, cookies=
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}"
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:
return None
html = resp.text
@@ -170,11 +180,11 @@ def fetch_vacancy_details(vacancy_id, cookies=None):
return details
def fetch_my_resumes(cookies=None):
def fetch_my_resumes(cookies=None, proxies=None):
"""Получает список резюме пользователя со страницы hh.ru/applicant/resumes.
Возвращает список {"id": hash, "title": ..., "updated": ...}."""
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:
return None, f"HTTP {resp.status_code} — проверьте ключ сессии"
html = resp.text
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env python3
"""Управление пользователями сервиса hh_auto.
Примеры:
python3 manage_users.py add friend1 # спросит пароль
python3 manage_users.py add friend1 --password 123 --limit 50
python3 manage_users.py list
python3 manage_users.py passwd friend1
python3 manage_users.py limit friend1 100
python3 manage_users.py remove friend1
"""
import argparse
import datetime
import getpass
import json
import os
import shutil
import sys
from werkzeug.security import generate_password_hash
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
USERS_FILE = os.path.join(BASE_DIR, "users.json")
def load():
if os.path.exists(USERS_FILE):
with open(USERS_FILE, encoding="utf-8") as f:
return json.load(f)
return {}
def save(users):
tmp = USERS_FILE + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(users, f, ensure_ascii=False, indent=2)
os.replace(tmp, USERS_FILE)
os.chmod(USERS_FILE, 0o600)
def add(username, password, limit):
users = load()
if username in users:
sys.exit(f"Пользователь «{username}» уже существует")
users[username] = {
"password_hash": generate_password_hash(password),
"llm_limit": limit,
"llm_usage": {},
"created": datetime.date.today().isoformat(),
}
save(users)
os.makedirs(os.path.join(BASE_DIR, "users", username, "accounts"), exist_ok=True)
print(f"Пользователь «{username}» создан")
print(f" Данные: {BASE_DIR}/users/{username}/")
print(f" Дневной лимит генераций: {limit or 'без лимита'}")
print(f" Вход: https://ВАШ-ДОМЕН/ (логин: {username})")
def remove(username):
users = load()
if username not in users:
sys.exit(f"Пользователь «{username}» не найден")
del users[username]
save(users)
shutil.rmtree(os.path.join(BASE_DIR, "users", username), ignore_errors=True)
print(f"Пользователь «{username}» удалён вместе с данными")
def set_password(username, password):
users = load()
if username not in users:
sys.exit(f"Пользователь «{username}» не найден")
users[username]["password_hash"] = generate_password_hash(password)
save(users)
print(f"Пароль «{username}» обновлён")
def set_limit(username, limit):
users = load()
if username not in users:
sys.exit(f"Пользователь «{username}» не найден")
users[username]["llm_limit"] = limit
save(users)
print(f"Лимит «{username}»: {limit or 'без лимита'} генераций в день")
def list_users():
users = load()
if not users:
print("Пользователей нет")
return
today = datetime.date.today().isoformat()
print(f"{'Логин':20} {'Генерации сегодня':18} Создан")
for name, u in sorted(users.items()):
used = (u.get("llm_usage") or {}).get(today, 0)
limit = u.get("llm_limit") or 0
lim = f"{used}/{limit}" if limit else f"{used}/∞"
print(f" {name:18} {lim:16} {u.get('created', '?')}")
def main():
ap = argparse.ArgumentParser(description="Управление пользователями hh_auto")
sub = ap.add_subparsers(dest="cmd", required=True)
a = sub.add_parser("add", help="Создать пользователя")
a.add_argument("username")
a.add_argument("--password", help="Пароль (иначе спросит)")
a.add_argument("--limit", type=int, default=50,
help="Дневной лимит генераций (0 = без лимита)")
sub.add_parser("list", help="Список пользователей")
p = sub.add_parser("passwd", help="Сменить пароль")
p.add_argument("username")
l = sub.add_parser("limit", help="Установить дневной лимит генераций")
l.add_argument("username")
l.add_argument("limit", type=int)
r = sub.add_parser("remove", help="Удалить пользователя с данными")
r.add_argument("username")
args = ap.parse_args()
if args.cmd == "add":
password = args.password or getpass.getpass(f"Пароль для «{args.username}»: ")
add(args.username, password, args.limit)
elif args.cmd == "list":
list_users()
elif args.cmd == "passwd":
password = getpass.getpass(f"Новый пароль для «{args.username}»: ")
set_password(args.username, password)
elif args.cmd == "limit":
set_limit(args.username, args.limit)
elif args.cmd == "remove":
if input(f"Удалить «{args.username}» и все его данные? [y/N]: ").strip().lower() \
in ("y", "д", "yes", "да"):
remove(args.username)
if __name__ == "__main__":
main()
+83 -4
View File
@@ -79,6 +79,13 @@
.link:hover { text-decoration: underline; }
.actions-cell { display: flex; gap: 6px; flex-wrap: wrap; }
.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; }
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: var(--bg); }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 5px; }
@@ -92,6 +99,10 @@
<button id="tab-btn-accounts" class="active" onclick="showTab('accounts')">Аккаунты</button>
<button id="tab-btn-vacancies" onclick="showTab('vacancies')">Вакансии</button>
</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>
<main>
@@ -229,10 +240,29 @@
</section>
</main>
<!-- ============ МОДАЛКА: ПРОВЕРКА ОТКЛИКА ============ -->
<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 class="secondary" onclick="markDone(currentShotId)">Отправил (отметить)</button>
<span id="send-note" class="muted"></span>
</div>
</div>
</div>
<script>
let currentAccount = null;
let vacancies = [];
let generatedTexts = {};
let currentShotId = null;
// ---------- Вкладки ----------
function showTab(name) {
@@ -249,12 +279,20 @@ async function api(url, opts = {}) {
headers: { 'Content-Type': 'application/json' },
...opts,
});
if (resp.status === 401) { window.location = '/login'; throw new Error('Требуется вход'); }
const data = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(data.error || `HTTP ${resp.status}`);
return data;
}
function logout() { window.location = '/logout'; }
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 sel = document.getElementById('acc-select');
const prev = sel.value;
@@ -543,21 +581,57 @@ function escapeHtml(s) {
.replace(/"/g,'&quot;');
}
// ---------- Открытие и отметка ----------
// ---------- Открытие, проверка и отправка ----------
async function openVacancy(id) {
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 {
await api(`/api/accounts/${currentAccount}/open`, {
const data = await api(`/api/accounts/${currentAccount}/open`, {
method: 'POST', body: JSON.stringify({ id, message }),
});
if (note) note.textContent = data.note || '';
if (data.screenshot) {
currentShotId = id;
document.getElementById('shot-img').src = data.screenshot + '?t=' + Date.now();
document.getElementById('shot-note').textContent = data.note || '';
document.getElementById('send-note').textContent = '';
document.getElementById('btn-send').disabled = false;
document.getElementById('shot-modal').classList.remove('hidden');
} else {
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() {
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;
renderVacancies();
}
} catch (e) {
note.textContent = 'Ошибка: ' + e.message;
}
btn.disabled = false;
}
async function markDone(id) {
await api(`/api/accounts/${currentAccount}/done`, {
method: 'POST', body: JSON.stringify({ id }),
@@ -575,9 +649,14 @@ async function resetProgress() {
await runSearch();
}
function openLogin() {
async function openLogin() {
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);
}
}
// ---------- Старт ----------
+66
View File
@@ -0,0 +1,66 @@
<!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; }
</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>
<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>
+214 -47
View File
@@ -1,42 +1,124 @@
#!/usr/bin/env python3
"""Веб-интерфейс для полуавтоматических откликов на hh.ru.
Многопользовательский режим: вход по логину/паролю, у каждого пользователя
своя папка users/<логин>/accounts/ с аккаунтами, резюме и сессиями.
Дневной лимит генераций на пользователя (users.json -> llm_limit).
Без VNC: браузер работает на сервере (headless или Xvfb), заполненная форма
показывается скриншотом, «Отправить» нажимается по подтверждению человека.
Запуск: .venv/bin/python webui.py
Открыть: http://localhost:5000
"""
import io
import json
import os
import re
import threading
import time
from datetime import date
from functools import wraps
from flask import Flask, jsonify, render_template, request
from flask import (Flask, jsonify, render_template, request, session,
redirect, url_for, send_file)
from werkzeug.security import check_password_hash
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_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__))
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.secret_key = os.environ.get("SESSION_SECRET", "dev-secret-change-me")
# Кэш найденных вакансий: {account: {vacancy_id: vacancy}}
# Кэш найденных вакансий: {(user, account): {vacancy_id: vacancy}}
_cache = {}
_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):
return os.path.join(ACCOUNTS_DIR, name)
return os.path.join(USERS_DIR, current_user(), "accounts", name)
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 sorted(d for d in os.listdir(ACCOUNTS_DIR)
if os.path.isdir(os.path.join(ACCOUNTS_DIR, d)))
return sorted(d for d in os.listdir(base)
if os.path.isdir(os.path.join(base, d)))
def read_json(path, default=None):
@@ -86,9 +168,9 @@ def save_progress(acc_dir, progress):
write_json(os.path.join(acc_dir, "progress.json"), progress)
def get_cache(account):
def get_cache(user, account):
with _cache_lock:
return _cache.setdefault(account, {})
return _cache.setdefault((user, account), {})
def extract_resume_text(file_storage):
@@ -111,19 +193,50 @@ 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("/logout")
def logout():
session.clear()
return redirect(url_for("login_page"))
@app.route("/")
@login_required
def index():
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: аккаунты ----------
@app.route("/api/accounts")
@login_required
def api_accounts():
return jsonify(list_accounts())
@app.route("/api/accounts", methods=["POST"])
@login_required
def api_create_account():
data = request.get_json(force=True)
name = (data.get("name") or "").strip()
@@ -152,19 +265,21 @@ def api_create_account():
@app.route("/api/accounts/<name>", methods=["DELETE"])
@login_required
def api_delete_account(name):
acc_dir = account_path(name)
if not os.path.isdir(acc_dir):
return jsonify({"error": "Аккаунт не найден"}), 404
close_context(name)
browser_close(current_user(), name)
import shutil
shutil.rmtree(acc_dir)
with _cache_lock:
_cache.pop(name, None)
_cache.pop((current_user(), name), None)
return jsonify({"ok": True})
@app.route("/api/accounts/<name>")
@login_required
def api_get_account(name):
acc_dir = account_path(name)
if not os.path.isdir(acc_dir):
@@ -180,6 +295,7 @@ def api_get_account(name):
@app.route("/api/accounts/<name>", methods=["PUT"])
@login_required
def api_update_account(name):
acc_dir = account_path(name)
if not os.path.isdir(acc_dir):
@@ -202,29 +318,25 @@ def api_update_account(name):
return jsonify({"ok": True})
# ---------- API: поиск и отклики ----------
# ---------- API: сессия и резюме ----------
@app.route("/api/accounts/<name>/login", methods=["GET"])
@login_required
def api_login(name):
"""Открывает браузер с профилем аккаунта для входа на hh.ru."""
"""Без VNC вход на hh.ru делается через cookies — браузер не открываем."""
acc_dir = account_path(name)
if not os.path.isdir(acc_dir):
return jsonify({"error": "Аккаунт не найден"}), 404
def worker():
try:
context = get_context(acc_dir, name)
page = context.new_page()
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 и закройте окно."})
return jsonify({
"ok": True,
"note": ("Войдите на hh.ru в своём браузере, скопируйте cookies "
"(F12 → Application → Cookies → https://hh.ru) и вставьте "
"их в поле «Ключ сессии hh.ru» ниже."),
})
@app.route("/api/accounts/<name>/resume-upload", methods=["POST"])
@login_required
def api_resume_upload(name):
"""Загрузка резюме файлом (.txt, .md, .docx, .pdf)."""
acc_dir = account_path(name)
@@ -246,6 +358,7 @@ def api_resume_upload(name):
@app.route("/api/accounts/<name>/session", methods=["POST"])
@login_required
def api_save_session(name):
"""Сохраняет ключ сессии hh.ru (cookies) для аккаунта."""
acc_dir = account_path(name)
@@ -257,28 +370,32 @@ def api_save_session(name):
return jsonify({"error": "Ключ сессии пуст"}), 400
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:
return jsonify({"ok": True, "warning": f"Сохранено, но: {err}"})
return jsonify({"ok": True, "resumes": resumes})
@app.route("/api/accounts/<name>/resumes", methods=["GET"])
@login_required
def api_get_resumes(name):
"""Получает список резюме пользователя через ключ сессии."""
acc_dir = account_path(name)
if not os.path.isdir(acc_dir):
return jsonify({"error": "Аккаунт не найден"}), 404
session = read_session(acc_dir)
if not session:
session_key = read_session(acc_dir)
if not session_key:
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:
return jsonify({"error": err}), 400
return jsonify({"resumes": resumes})
# ---------- API: поиск и отклики ----------
@app.route("/api/accounts/<name>/search", methods=["POST"])
@login_required
def api_search(name):
acc_dir = account_path(name)
if not os.path.isdir(acc_dir):
@@ -286,14 +403,14 @@ def api_search(name):
cfg = read_json(os.path.join(acc_dir, "config.json"), {})
progress = get_progress(acc_dir)
responded = set(progress.get("responded", []))
cache = get_cache(name)
cache = get_cache(current_user(), name)
cookies = parse_cookies(read_session(acc_dir)) or None
all_vacancies = []
seen = set()
for query in cfg.get("queries", []):
found = search_vacancies(query, area=cfg.get("area"), pages=cfg.get("pages", 1),
cookies=cookies)
cookies=cookies, proxies=get_proxies())
for v in found:
if v["id"] and v["id"] not in seen:
seen.add(v["id"])
@@ -311,59 +428,106 @@ def api_search(name):
@app.route("/api/accounts/<name>/generate", methods=["POST"])
@login_required
def api_generate(name):
acc_dir = account_path(name)
if not os.path.isdir(acc_dir):
return jsonify({"error": "Аккаунт не найден"}), 404
data = request.get_json(force=True)
ids = data.get("ids") or []
cfg = read_json(os.path.join(acc_dir, "config.json"), {})
resume = read_resume(acc_dir)
cache = get_cache(name)
cache = get_cache(current_user(), name)
limit, used = llm_limit_info(current_user())
texts = {}
errors = {}
for vid in ids:
if limit is not None and used >= limit:
errors[vid] = f"Достигнут дневной лимит генераций ({limit}). Попробуйте завтра."
continue
vacancy = cache.get(vid)
if not vacancy:
errors[vid] = "Вакансия не найдена в кэше — обновите поиск"
continue
details = fetch_vacancy_details(vid) or {}
details = fetch_vacancy_details(vid, proxies=get_proxies()) or {}
vacancy_full = {**vacancy, **details}
try:
texts[vid] = generate_response(cfg.get("llm", {}), vacancy_full, resume, cfg.get("style", ""))
texts[vid] = generate_response(cfg.get("llm", {}), vacancy_full,
resume, cfg.get("style", ""))
if limit is not None:
used += 1
bump_llm_usage(current_user())
except Exception as e:
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"])
@login_required
def api_open(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(name)
cache = get_cache(current_user(), name)
vacancy = cache.get(vid)
if not vacancy:
return jsonify({"error": "Вакансия не найдена в кэше"}), 404
return jsonify({"error": "Вакансия не найдена в кэше — обновите поиск"}), 404
def worker():
try:
context = get_context(acc_dir, name)
ok, note = open_response_page(context, vacancy, message, resume_id=resume_id)
print(f"[{name}] {vid}: {note}")
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)
return jsonify({
"ok": ok,
"note": note,
"screenshot": f"/api/accounts/{name}/screenshot/{vid}" if shot else None,
})
except Exception as e:
print(f"[{name}] {vid}: ошибка браузера: {e}")
return jsonify({"error": f"Ошибка браузера: {e}"}), 500
threading.Thread(target=worker, daemon=True).start()
return jsonify({"ok": True, "note": "Открываю браузер..."})
@app.route("/api/accounts/<name>/send", 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"])
@login_required
def api_done(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")
progress = get_progress(acc_dir)
@@ -375,8 +539,11 @@ def api_done(name):
@app.route("/api/accounts/<name>/reset", methods=["POST"])
@login_required
def api_reset(name):
acc_dir = account_path(name)
if not os.path.isdir(acc_dir):
return jsonify({"error": "Аккаунт не найден"}), 404
save_progress(acc_dir, {"responded": []})
return jsonify({"ok": True})