#!/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, 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, get_proxies) from hh_llm import generate_response from hh_browser import browser_open, browser_send, browser_close BASE_DIR = os.path.dirname(os.path.abspath(__file__)) 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") # Кэш найденных вакансий: {(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(USERS_DIR, current_user(), "accounts", name) def list_accounts(): base = os.path.join(USERS_DIR, current_user(), "accounts") if not os.path.isdir(base): return [] return sorted(d for d in os.listdir(base) if os.path.isdir(os.path.join(base, 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(user, account): with _cache_lock: return _cache.setdefault((user, 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("/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() 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"]) @login_required def api_delete_account(name): acc_dir = account_path(name) if not os.path.isdir(acc_dir): return jsonify({"error": "Аккаунт не найден"}), 404 browser_close(current_user(), name) import shutil shutil.rmtree(acc_dir) with _cache_lock: _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): 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"]) @login_required 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"]) @login_required def api_login(name): """Без VNC вход на hh.ru делается через cookies — браузер не открываем.""" acc_dir = account_path(name) if not os.path.isdir(acc_dir): return jsonify({"error": "Аккаунт не найден"}), 404 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) 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"]) @login_required 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), 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_key = read_session(acc_dir) if not session_key: return jsonify({"error": "Ключ сессии не задан. Добавьте его в настройках аккаунта."}), 400 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): 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(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, proxies=get_proxies()) 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"]) @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(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, proxies=get_proxies()) or {} vacancy_full = {**vacancy, **details} try: 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, "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(current_user(), name) vacancy = cache.get(vid) if not vacancy: return jsonify({"error": "Вакансия не найдена в кэше — обновите поиск"}), 404 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 @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) 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"]) @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}) if __name__ == "__main__": port = int(os.environ.get("PORT", "18091")) host = os.environ.get("HOST", "127.0.0.1") print(f"Откройте http://localhost:{port}") app.run(host=host, port=port, debug=False)