Files
hh_auto/webui.py

640 lines
23 KiB
Python

#!/usr/bin/env python3
"""Веб-интерфейс для полуавтоматических откликов на hh.ru.
Многопользовательский режим: вход по логину/паролю, у каждого пользователя
своя папка users/<логин>/accounts/ с аккаунтами, резюме и сессиями.
Дневной лимит генераций на пользователя (users.json -> llm_limit).
Без VNC: браузер работает на сервере (headless или Xvfb), заполненная форма
показывается скриншотом, «Отправить» нажимается по подтверждению человека.
Запуск: .venv/bin/python webui.py
Открыть: http://localhost:18091
"""
import io
import json
import os
import re
import threading
import time
from datetime import date
from functools import wraps
from dotenv import load_dotenv
# Загружаем .env (LLM_API_KEY, SESSION_SECRET, PORT, HH_HEADLESS и т.д.)
load_dotenv()
from flask import (Flask, jsonify, render_template, request, session,
redirect, url_for, send_file)
from werkzeug.security import check_password_hash, generate_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 mark_responded(acc_dir, vid):
"""Добавляет вакансию в список откликнутых."""
progress = get_progress(acc_dir)
responded = set(progress.get("responded", []))
responded.add(vid)
progress["responded"] = sorted(responded)
save_progress(acc_dir, 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("/register", methods=["GET", "POST"])
def register_page():
"""Простая регистрация: логин + пароль. Отключается через ALLOW_REGISTRATION=0."""
if os.environ.get("ALLOW_REGISTRATION", "1") != "1":
if request.method == "POST":
return jsonify({"error": "Регистрация отключена"}), 403
return redirect(url_for("login_page"))
if request.method == "POST":
data = request.get_json(force=True)
username = (data.get("username") or "").strip()
password = data.get("password") or ""
confirm = data.get("confirm") or ""
# Проверки
if not re.match(r"^[a-zA-Z0-9_.-]{3,30}$", username):
return jsonify({"error": "Логин: 3-30 символов, буквы/цифры/точка/дефис/подчёркивание"}), 400
if len(password) < 6:
return jsonify({"error": "Пароль: минимум 6 символов"}), 400
if password != confirm:
return jsonify({"error": "Пароли не совпадают"}), 400
users = load_users()
if username in users:
return jsonify({"error": "Логин уже занят"}), 409
limit = int(os.environ.get("REGISTER_LLM_LIMIT", "50") or 0)
users[username] = {
"password_hash": generate_password_hash(password),
"llm_limit": limit,
"llm_usage": {},
"created": date.today().isoformat(),
"registered": True,
}
save_users(users)
os.makedirs(os.path.join(USERS_DIR, username, "accounts"), exist_ok=True)
session["user"] = username # сразу входим
return jsonify({"ok": True, "limit": limit})
return render_template("register.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/<name>", 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/<name>")
@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/<name>", 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/<name>/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/<name>/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/<name>/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/<name>/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/<name>/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/<name>/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/<name>/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/<name>/send-now", methods=["POST"])
@login_required
def api_send_now(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,
submit=True)
if ok:
mark_responded(acc_dir, vid)
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/<name>/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/<name>/screenshot/<vid>")
@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/<name>/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")
mark_responded(acc_dir, vid)
return jsonify({"ok": True})
@app.route("/api/accounts/<name>/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)