Полуавтоматический отклик на hh.ru: поиск, ИИ-генерация, веб-интерфейс
This commit is contained in:
commit
0ca55f3818
11 files changed
+1740
No files matched your search
@@ -0,0 +1,386 @@
|
||||
#!/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/<name>", 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/<name>")
|
||||
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"])
|
||||
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"])
|
||||
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/<name>/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/<name>/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/<name>/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/<name>/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/<name>/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/<name>/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/<name>/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/<name>/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)
|
||||
Reference in new issue
Block a user