Многопользовательский режим: авторизация, изоляция данных, лимиты генераций, деплой без VNC
This commit is contained in:
1 parent
0ca55f3818
commit
03555a400f
10 files changed
+1195
-154
No files matched your search
@@ -1,42 +1,124 @@
|
||||
#!/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
|
||||
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)
|
||||
parse_cookies, get_proxies)
|
||||
from hh_llm import generate_response
|
||||
from hh_browser import get_context, close_context, open_response_page
|
||||
from hh_browser import browser_open, browser_send, browser_close
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
ACCOUNTS_DIR = os.path.join(BASE_DIR, "accounts")
|
||||
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")
|
||||
|
||||
# Кэш найденных вакансий: {account: {vacancy_id: vacancy}}
|
||||
# Кэш найденных вакансий: {(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(ACCOUNTS_DIR, name)
|
||||
return os.path.join(USERS_DIR, current_user(), "accounts", name)
|
||||
|
||||
|
||||
def list_accounts():
|
||||
if not os.path.isdir(ACCOUNTS_DIR):
|
||||
base = os.path.join(USERS_DIR, current_user(), "accounts")
|
||||
if not os.path.isdir(base):
|
||||
return []
|
||||
return sorted(d for d in os.listdir(ACCOUNTS_DIR)
|
||||
if os.path.isdir(os.path.join(ACCOUNTS_DIR, d)))
|
||||
return sorted(d for d in os.listdir(base)
|
||||
if os.path.isdir(os.path.join(base, d)))
|
||||
|
||||
|
||||
def read_json(path, default=None):
|
||||
@@ -86,9 +168,9 @@ def save_progress(acc_dir, progress):
|
||||
write_json(os.path.join(acc_dir, "progress.json"), progress)
|
||||
|
||||
|
||||
def get_cache(account):
|
||||
def get_cache(user, account):
|
||||
with _cache_lock:
|
||||
return _cache.setdefault(account, {})
|
||||
return _cache.setdefault((user, account), {})
|
||||
|
||||
|
||||
def extract_resume_text(file_storage):
|
||||
@@ -111,19 +193,50 @@ def extract_resume_text(file_storage):
|
||||
|
||||
# ---------- Страницы ----------
|
||||
|
||||
@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()
|
||||
@@ -152,19 +265,21 @@ def api_create_account():
|
||||
|
||||
|
||||
@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
|
||||
close_context(name)
|
||||
browser_close(current_user(), name)
|
||||
import shutil
|
||||
shutil.rmtree(acc_dir)
|
||||
with _cache_lock:
|
||||
_cache.pop(name, None)
|
||||
_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):
|
||||
@@ -180,6 +295,7 @@ def api_get_account(name):
|
||||
|
||||
|
||||
@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):
|
||||
@@ -202,29 +318,25 @@ def api_update_account(name):
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
# ---------- API: поиск и отклики ----------
|
||||
# ---------- API: сессия и резюме ----------
|
||||
|
||||
@app.route("/api/accounts/<name>/login", methods=["GET"])
|
||||
@login_required
|
||||
def api_login(name):
|
||||
"""Открывает браузер с профилем аккаунта для входа на hh.ru."""
|
||||
"""Без VNC вход на hh.ru делается через cookies — браузер не открываем."""
|
||||
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 и закройте окно."})
|
||||
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)
|
||||
@@ -246,6 +358,7 @@ def api_resume_upload(name):
|
||||
|
||||
|
||||
@app.route("/api/accounts/<name>/session", methods=["POST"])
|
||||
@login_required
|
||||
def api_save_session(name):
|
||||
"""Сохраняет ключ сессии hh.ru (cookies) для аккаунта."""
|
||||
acc_dir = account_path(name)
|
||||
@@ -257,28 +370,32 @@ def api_save_session(name):
|
||||
return jsonify({"error": "Ключ сессии пуст"}), 400
|
||||
write_session(acc_dir, cookie_str)
|
||||
# Проверяем валидность
|
||||
resumes, err = fetch_my_resumes(parse_cookies(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 = read_session(acc_dir)
|
||||
if not session:
|
||||
session_key = read_session(acc_dir)
|
||||
if not session_key:
|
||||
return jsonify({"error": "Ключ сессии не задан. Добавьте его в настройках аккаунта."}), 400
|
||||
resumes, err = fetch_my_resumes(parse_cookies(session))
|
||||
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):
|
||||
@@ -286,14 +403,14 @@ def api_search(name):
|
||||
cfg = read_json(os.path.join(acc_dir, "config.json"), {})
|
||||
progress = get_progress(acc_dir)
|
||||
responded = set(progress.get("responded", []))
|
||||
cache = get_cache(name)
|
||||
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)
|
||||
cookies=cookies, proxies=get_proxies())
|
||||
for v in found:
|
||||
if v["id"] and v["id"] not in seen:
|
||||
seen.add(v["id"])
|
||||
@@ -311,59 +428,106 @@ def api_search(name):
|
||||
|
||||
|
||||
@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(name)
|
||||
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) or {}
|
||||
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", ""))
|
||||
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})
|
||||
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(name)
|
||||
cache = get_cache(current_user(), name)
|
||||
vacancy = cache.get(vid)
|
||||
if not vacancy:
|
||||
return jsonify({"error": "Вакансия не найдена в кэше"}), 404
|
||||
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}")
|
||||
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
|
||||
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
return jsonify({"ok": True, "note": "Открываю браузер..."})
|
||||
|
||||
@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")
|
||||
progress = get_progress(acc_dir)
|
||||
@@ -375,8 +539,11 @@ def api_done(name):
|
||||
|
||||
|
||||
@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})
|
||||
|
||||
|
||||
Reference in new issue
Block a user