#!/usr/bin/env python3 """Полуавтоматический отклик на вакансии hh.ru. Безопасный режим: скрипт ищет вакансии, генерирует текст отклика через ИИ, открывает страницу в браузере (headless или Xvfb), делает скриншот. Вы проверяете скриншот и подтверждаете отправку — скрипт нажимает «Отправить». Аккаунты: users/<пользователь>/accounts/ (или accounts/ для старого формата). Использование: 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 # без браузера, только текст """ import argparse import json import os import sys import time from hh_search import search_vacancies, fetch_vacancy_details, get_proxies from hh_llm import generate_response 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: cfg = json.load(f) resume_path = os.path.join(account_dir, "resume.txt") if os.path.exists(resume_path): with open(resume_path, encoding="utf-8") as f: cfg["resume"] = f.read() else: cfg["resume"] = "" return cfg def load_progress(account_dir): path = os.path.join(account_dir, "progress.json") if os.path.exists(path): with open(path, encoding="utf-8") as f: return json.load(f) return {"responded": []} def save_progress(account_dir, progress): path = os.path.join(account_dir, "progress.json") with open(path, "w", encoding="utf-8") as f: json.dump(progress, f, ensure_ascii=False, indent=2) def collect_vacancies(cfg): """Ищем вакансии по всем запросам аккаунта, убираем дубли.""" result = [] 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), proxies=get_proxies()) for v in found: if v["id"] and v["id"] not in seen: seen.add(v["id"]) result.append(v) time.sleep(1) return result 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}") return cfg = load_account_config(account_dir) progress = load_progress(account_dir) responded = set(progress["responded"]) print(f"\n=== Аккаунт: {account_name} (пользователь: {user}) ===") if not cfg.get("resume"): print(" ⚠ Нет файла resume.txt — текст отклика будет без учёта резюме") vacancies = collect_vacancies(cfg) pending = [v for v in vacancies if v["id"] not in responded] print(f" Найдено: {len(vacancies)}, новых: {len(pending)}") if limit: pending = pending[:limit] if not pending: print(" Новых вакансий нет.") return llm_cfg = cfg.get("llm", {}) style = cfg.get("style", "") 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} # Генерация текста 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] браузер не открываю") continue # Открываем в браузере и делаем скриншот 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, 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}") return print(f"Открываю браузер для аккаунта «{account_name}». Войдите на hh.ru.") browser_open_login(account_dir, user, account_name) input("Когда войдёте — нажмите Enter...") browser_close(user, account_name) print("Сессия сохранена.") 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, args.user) return 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))] if not accounts: print(f"Нет аккаунтов в {accounts_dir}. Создайте папку аккаунта (см. README.md)") return for acc in accounts: process_account(acc, args.limit, args.dry_run, args.user) if __name__ == "__main__": main()