diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0ff258c --- /dev/null +++ b/.env.example @@ -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 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 264c99c..827cf94 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,10 @@ # Секреты — не коммитить .env +# Пользователи сервиса — личные данные (пароли, аккаунты, резюме, сессии, прогресс) +users/ +users.json + # Аккаунты пользователя — личные данные (config, резюме, сессии, прогресс) accounts/* !accounts/example/ diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..5f03032 --- /dev/null +++ b/DEPLOY.md @@ -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 <логин> ` (лимит, 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 <логин> ` + +### Миграция с однопользовательской версии +- Старые `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-ключ, пароли \ No newline at end of file diff --git a/hh_browser.py b/hh_browser.py index 3629531..c764d17 100644 --- a/hh_browser.py +++ b/hh_browser.py @@ -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 _inject_session_cookies(context, account_dir): + """Подставляет cookies из session.txt — вход на hh.ru без ручного логина.""" + path = os.path.join(account_dir, "session.txt") + if not os.path.exists(path): + return + with open(path, encoding="utf-8") as f: + cookie_str = f.read().strip() + if not cookie_str: + return + cookies = parse_cookies(cookie_str) + if not cookies: + return + try: + context.add_cookies([ + {"name": k, "value": v, "url": "https://hh.ru"} + for k, v in cookies.items() + ]) + except Exception as e: + print(f" ⚠ Не удалось подставить cookies: {e}") -def close_context(account_name): - if account_name in _browsers: +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: - _browsers[account_name].close() + 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, "Не нашёл поле сообщения — вставьте текст вручную" \ No newline at end of file + + 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)) \ No newline at end of file diff --git a/hh_respond.py b/hh_respond.py index 4215b18..8fca2de 100644 --- a/hh_respond.py +++ b/hh_respond.py @@ -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,70 +109,71 @@ 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']}") - 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"], proxies=get_proxies()) or {} + vacancy_full = {**vacancy, **details} - # Описание вакансии для ИИ - details = fetch_vacancy_details(vacancy["id"]) or {} - vacancy_full = {**vacancy, **details} - - # Генерация текста - try: - message = generate_response(llm_cfg, vacancy_full, cfg.get("resume", ""), style) - 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] браузер не открываю") + # Генерация текста + try: + message = generate_response(llm_cfg, vacancy_full, cfg.get("resume", ""), style) + except Exception as e: + print(f" ⚠ Ошибка ИИ: {e}") + message = input(" Введите текст отклика вручную (Enter — пропустить): ").strip() + if not message: continue - # Открываем в браузере - ok, note = open_response_page(context, vacancy_full, message) - print(f" {'✓' if ok else '⚠'} {note}") + print(f"\n --- Текст отклика ---\n {message}\n --------------------") - # Ждём решения пользователя - while True: - choice = input(" Отправили? [Enter=да, s=пропустить, q=выйти]: ").strip().lower() - 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) + if dry_run: + print(" [dry-run] браузер не открываю") + continue - 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}: готово ===") -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//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__": diff --git a/hh_search.py b/hh_search.py index 7000ece..9649c4e 100644 --- a/hh_search.py +++ b/hh_search.py @@ -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 diff --git a/manage_users.py b/manage_users.py new file mode 100644 index 0000000..6e997f0 --- /dev/null +++ b/manage_users.py @@ -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() \ No newline at end of file diff --git a/templates/index.html b/templates/index.html index f5dd913..dd18c3c 100644 --- a/templates/index.html +++ b/templates/index.html @@ -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 @@ +
+ + +
@@ -229,10 +240,29 @@
+ + + + + \ No newline at end of file diff --git a/webui.py b/webui.py index 3b845aa..8e94e75 100644 --- a/webui.py +++ b/webui.py @@ -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/", 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/") +@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/", 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//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//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//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//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//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//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//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}") - except Exception as e: - print(f"[{name}] {vid}: ошибка браузера: {e}") + 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) + 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 - threading.Thread(target=worker, daemon=True).start() - return jsonify({"ok": True, "note": "Открываю браузер..."}) + +@app.route("/api/accounts//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//screenshot/") +@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//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//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})