Полуавтоматический отклик на hh.ru: поиск, ИИ-генерация, веб-интерфейс

This commit is contained in:
apuc committed 2026-09-12 13:16:23 +03:00
commit 0ca55f3818
11 files changed
+1740

No files matched your search

+199
View File
@@ -0,0 +1,199 @@
#!/usr/bin/env python3
"""Полуавтоматический отклик на вакансии hh.ru.
Безопасный режим: скрипт ищет вакансии, генерирует текст отклика через ИИ,
открывает страницу в браузере и подставляет текст. Финальное «Отправить»
всегда нажимаете вы.
Аккаунты: каждая папка в accounts/ — отдельный аккаунт со своей сессией
(профиль браузера), резюме и настройками.
Использование:
python3 hh_respond.py --login my_account # войти в аккаунт (один раз)
python3 hh_respond.py # обработать все аккаунты
python3 hh_respond.py --account my_account # только один аккаунт
python3 hh_respond.py --limit 5 # максимум 5 откликов на аккаунт
python3 hh_respond.py --dry-run # без браузера, только текст
"""
import argparse
import json
import os
import sys
import time
from playwright.sync_api import sync_playwright
from hh_search import search_vacancies, fetch_vacancy_details
from hh_llm import generate_response
from hh_browser import get_context, close_context, open_response_page
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
ACCOUNTS_DIR = os.path.join(BASE_DIR, "accounts")
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))
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):
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} ===")
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", "")
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 {}
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
# Открываем в браузере
ok, note = open_response_page(context, vacancy_full, message)
print(f" {'✓' if ok else '⚠'} {note}")
# Ждём решения пользователя
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)
close_context(account_name)
print(f"\n=== Аккаунт {account_name}: готово ===")
def login_account(account_name):
"""Открывает браузер с профилем аккаунта для ручного входа."""
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 и закройте окно.")
context = get_context(account_dir, account_name)
page = context.new_page()
page.goto("https://hh.ru", timeout=45000)
print("Когда войдёте — закройте окно браузера.")
close_context(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("--limit", type=int, help="Максимум откликов на аккаунт")
parser.add_argument("--dry-run", action="store_true", help="Без браузера, только текст")
args = parser.parse_args()
if args.login:
login_account(args.login)
return
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("Нет аккаунтов в accounts/. Создайте папку аккаунта (см. README.md)")
return
for acc in accounts:
process_account(acc, args.limit, args.dry_run)
if __name__ == "__main__":
main()