#!/usr/bin/env python3 """Веб-интерфейс для полуавтоматических откликов на hh.ru. Запуск: .venv/bin/python webui.py Открыть: http://localhost:5000 """ import io import json import os import threading from flask import Flask, jsonify, render_template, request from hh_search import (search_vacancies, fetch_vacancy_details, fetch_my_resumes, parse_cookies) 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") app = Flask(__name__) # Кэш найденных вакансий: {account: {vacancy_id: vacancy}} _cache = {} _cache_lock = threading.Lock() # ---------- Утилиты ---------- def account_path(name): return os.path.join(ACCOUNTS_DIR, name) def list_accounts(): if not os.path.isdir(ACCOUNTS_DIR): return [] return sorted(d for d in os.listdir(ACCOUNTS_DIR) if os.path.isdir(os.path.join(ACCOUNTS_DIR, d))) def read_json(path, default=None): if not os.path.exists(path): return default with open(path, encoding="utf-8") as f: return json.load(f) def write_json(path, data): with open(path, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) def read_resume(acc_dir): path = os.path.join(acc_dir, "resume.txt") if os.path.exists(path): with open(path, encoding="utf-8") as f: return f.read() return "" def write_resume(acc_dir, text): with open(os.path.join(acc_dir, "resume.txt"), "w", encoding="utf-8") as f: f.write(text) def read_session(acc_dir): """Читает ключ сессии hh.ru из session.txt.""" path = os.path.join(acc_dir, "session.txt") if os.path.exists(path): with open(path, encoding="utf-8") as f: return f.read().strip() return "" def write_session(acc_dir, cookie_str): with open(os.path.join(acc_dir, "session.txt"), "w", encoding="utf-8") as f: f.write(cookie_str.strip()) def get_progress(acc_dir): return read_json(os.path.join(acc_dir, "progress.json"), {"responded": []}) def save_progress(acc_dir, progress): write_json(os.path.join(acc_dir, "progress.json"), progress) def get_cache(account): with _cache_lock: return _cache.setdefault(account, {}) def extract_resume_text(file_storage): """Извлекает текст резюме из загруженного файла.""" filename = file_storage.filename or "" ext = os.path.splitext(filename)[1].lower() raw = file_storage.read() if ext in (".txt", ".md", ".csv", ".json"): return raw.decode("utf-8", errors="replace") if ext == ".docx": from docx import Document doc = Document(io.BytesIO(raw)) return "\n".join(p.text for p in doc.paragraphs if p.text.strip()) if ext == ".pdf": from pypdf import PdfReader reader = PdfReader(io.BytesIO(raw)) return "\n".join((page.extract_text() or "") for page in reader.pages) raise ValueError(f"Неподдерживаемый формат «{ext}». Используйте .txt, .md, .docx или .pdf") # ---------- Страницы ---------- @app.route("/") def index(): return render_template("index.html") # ---------- API: аккаунты ---------- @app.route("/api/accounts") def api_accounts(): return jsonify(list_accounts()) @app.route("/api/accounts", methods=["POST"]) def api_create_account(): data = request.get_json(force=True) name = (data.get("name") or "").strip() if not name: return jsonify({"error": "Укажите имя аккаунта"}), 400 if not name.replace("_", "").replace("-", "").isalnum(): return jsonify({"error": "Имя может содержать только буквы, цифры, _ и -"}), 400 acc_dir = account_path(name) if os.path.exists(acc_dir): return jsonify({"error": f"Аккаунт «{name}» уже существует"}), 400 os.makedirs(acc_dir) cfg = { "queries": [q.strip() for q in (data.get("queries") or "").splitlines() if q.strip()], "area": data.get("area") or 1, "pages": int(data.get("pages") or 1), "style": data.get("style") or "", "llm": { "base_url": "https://opencode.ai/zen/v1", "api_key_env": "LLM_API_KEY", "model": "deepseek-v4-flash", }, } write_json(os.path.join(acc_dir, "config.json"), cfg) write_resume(acc_dir, data.get("resume") or "") return jsonify({"ok": True}) @app.route("/api/accounts/", methods=["DELETE"]) def api_delete_account(name): acc_dir = account_path(name) if not os.path.isdir(acc_dir): return jsonify({"error": "Аккаунт не найден"}), 404 close_context(name) import shutil shutil.rmtree(acc_dir) with _cache_lock: _cache.pop(name, None) return jsonify({"ok": True}) @app.route("/api/accounts/") def api_get_account(name): acc_dir = account_path(name) if not os.path.isdir(acc_dir): return jsonify({"error": "Аккаунт не найден"}), 404 cfg = read_json(os.path.join(acc_dir, "config.json"), {}) progress = get_progress(acc_dir) return jsonify({ "config": cfg, "resume": read_resume(acc_dir), "responded": progress.get("responded", []), "has_session": bool(read_session(acc_dir)), }) @app.route("/api/accounts/", methods=["PUT"]) def api_update_account(name): acc_dir = account_path(name) if not os.path.isdir(acc_dir): return jsonify({"error": "Аккаунт не найден"}), 404 data = request.get_json(force=True) cfg = read_json(os.path.join(acc_dir, "config.json"), {}) if "queries" in data: cfg["queries"] = [q.strip() for q in data["queries"].splitlines() if q.strip()] if "area" in data: cfg["area"] = data["area"] if "pages" in data: cfg["pages"] = int(data["pages"]) if "style" in data: cfg["style"] = data["style"] if "resume_id" in data: cfg["resume_id"] = data["resume_id"] or None write_json(os.path.join(acc_dir, "config.json"), cfg) if "resume" in data: write_resume(acc_dir, data["resume"]) return jsonify({"ok": True}) # ---------- API: поиск и отклики ---------- @app.route("/api/accounts//login", methods=["GET"]) def api_login(name): """Открывает браузер с профилем аккаунта для входа на hh.ru.""" 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 и закройте окно."}) @app.route("/api/accounts//resume-upload", methods=["POST"]) def api_resume_upload(name): """Загрузка резюме файлом (.txt, .md, .docx, .pdf).""" acc_dir = account_path(name) if not os.path.isdir(acc_dir): return jsonify({"error": "Аккаунт не найден"}), 404 file = request.files.get("file") if not file: return jsonify({"error": "Файл не передан"}), 400 try: text = extract_resume_text(file) except ValueError as e: return jsonify({"error": str(e)}), 400 except Exception as e: return jsonify({"error": f"Не удалось прочитать файл: {e}"}), 400 if not text.strip(): return jsonify({"error": "В файле нет текста (возможно, это сканированный PDF)"}), 400 write_resume(acc_dir, text) return jsonify({"ok": True, "text": text, "chars": len(text)}) @app.route("/api/accounts//session", methods=["POST"]) def api_save_session(name): """Сохраняет ключ сессии hh.ru (cookies) для аккаунта.""" acc_dir = account_path(name) if not os.path.isdir(acc_dir): return jsonify({"error": "Аккаунт не найден"}), 404 data = request.get_json(force=True) cookie_str = data.get("session") or "" if not cookie_str.strip(): return jsonify({"error": "Ключ сессии пуст"}), 400 write_session(acc_dir, cookie_str) # Проверяем валидность resumes, err = fetch_my_resumes(parse_cookies(cookie_str)) if err: return jsonify({"ok": True, "warning": f"Сохранено, но: {err}"}) return jsonify({"ok": True, "resumes": resumes}) @app.route("/api/accounts//resumes", methods=["GET"]) 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: return jsonify({"error": "Ключ сессии не задан. Добавьте его в настройках аккаунта."}), 400 resumes, err = fetch_my_resumes(parse_cookies(session)) if err: return jsonify({"error": err}), 400 return jsonify({"resumes": resumes}) @app.route("/api/accounts//search", methods=["POST"]) def api_search(name): acc_dir = account_path(name) if not os.path.isdir(acc_dir): return jsonify({"error": "Аккаунт не найден"}), 404 cfg = read_json(os.path.join(acc_dir, "config.json"), {}) progress = get_progress(acc_dir) responded = set(progress.get("responded", [])) cache = get_cache(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) for v in found: if v["id"] and v["id"] not in seen: seen.add(v["id"]) all_vacancies.append(v) # Обновляем кэш и помечаем статус result = [] for v in all_vacancies: cache[v["id"]] = v result.append({ **v, "responded": v["id"] in responded, }) return jsonify({"vacancies": result, "total": len(result)}) @app.route("/api/accounts//generate", methods=["POST"]) def api_generate(name): acc_dir = account_path(name) 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) texts = {} errors = {} for vid in ids: vacancy = cache.get(vid) if not vacancy: errors[vid] = "Вакансия не найдена в кэше — обновите поиск" continue details = fetch_vacancy_details(vid) or {} vacancy_full = {**vacancy, **details} try: texts[vid] = generate_response(cfg.get("llm", {}), vacancy_full, resume, cfg.get("style", "")) except Exception as e: errors[vid] = str(e) return jsonify({"texts": texts, "errors": errors}) @app.route("/api/accounts//open", methods=["POST"]) def api_open(name): """Открывает вакансию в браузере и вставляет текст. Работает в фоне.""" acc_dir = account_path(name) 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) vacancy = cache.get(vid) if not vacancy: 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}") threading.Thread(target=worker, daemon=True).start() return jsonify({"ok": True, "note": "Открываю браузер..."}) @app.route("/api/accounts//done", methods=["POST"]) def api_done(name): acc_dir = account_path(name) data = request.get_json(force=True) vid = data.get("id") progress = get_progress(acc_dir) responded = set(progress.get("responded", [])) responded.add(vid) progress["responded"] = sorted(responded) save_progress(acc_dir, progress) return jsonify({"ok": True}) @app.route("/api/accounts//reset", methods=["POST"]) def api_reset(name): acc_dir = account_path(name) save_progress(acc_dir, {"responded": []}) return jsonify({"ok": True}) if __name__ == "__main__": print("Откройте http://localhost:5000") app.run(host="127.0.0.1", port=5000, debug=False)