Многопользовательский режим: авторизация, изоляция данных, лимиты генераций, деплой без VNC
This commit is contained in:
1 parent
0ca55f3818
commit
25b46a0b2a
10 files changed
+1195
-154
No files matched your search
+84
-73
@@ -2,16 +2,16 @@
|
||||
"""Полуавтоматический отклик на вакансии hh.ru.
|
||||
|
||||
Безопасный режим: скрипт ищет вакансии, генерирует текст отклика через ИИ,
|
||||
открывает страницу в браузере и подставляет текст. Финальное «Отправить»
|
||||
всегда нажимаете вы.
|
||||
открывает страницу в браузере (headless или Xvfb), делает скриншот.
|
||||
Вы проверяете скриншот и подтверждаете отправку — скрипт нажимает «Отправить».
|
||||
|
||||
Аккаунты: каждая папка в accounts/ — отдельный аккаунт со своей сессией
|
||||
(профиль браузера), резюме и настройками.
|
||||
Аккаунты: users/<пользователь>/accounts/ (или accounts/ для старого формата).
|
||||
|
||||
Использование:
|
||||
python3 hh_respond.py --login my_account # войти в аккаунт (один раз)
|
||||
python3 hh_respond.py --login my_account # войти в аккаунт (локально, с экраном)
|
||||
python3 hh_respond.py # обработать все аккаунты
|
||||
python3 hh_respond.py --account my_account # только один аккаунт
|
||||
python3 hh_respond.py --user friend1 # пользователь сервиса
|
||||
python3 hh_respond.py --limit 5 # максимум 5 откликов на аккаунт
|
||||
python3 hh_respond.py --dry-run # без браузера, только текст
|
||||
"""
|
||||
@@ -21,16 +21,22 @@ import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
from hh_search import search_vacancies, fetch_vacancy_details
|
||||
from hh_search import search_vacancies, fetch_vacancy_details, 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, browser_open_login
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
ACCOUNTS_DIR = os.path.join(BASE_DIR, "accounts")
|
||||
|
||||
|
||||
def resolve_accounts_dir(user):
|
||||
"""Папка аккаунтов пользователя; старый формат accounts/ — запасной."""
|
||||
d = os.path.join(BASE_DIR, "users", user, "accounts")
|
||||
if os.path.isdir(d):
|
||||
return d
|
||||
return ACCOUNTS_DIR
|
||||
|
||||
|
||||
def load_account_config(account_dir):
|
||||
cfg_path = os.path.join(account_dir, "config.json")
|
||||
with open(cfg_path, encoding="utf-8") as f:
|
||||
@@ -64,7 +70,8 @@ def collect_vacancies(cfg):
|
||||
seen = set()
|
||||
for query in cfg.get("queries", []):
|
||||
print(f"\n Поиск: «{query}»")
|
||||
found = search_vacancies(query, area=cfg.get("area"), pages=cfg.get("pages", 1))
|
||||
found = search_vacancies(query, area=cfg.get("area"), pages=cfg.get("pages", 1),
|
||||
proxies=get_proxies())
|
||||
for v in found:
|
||||
if v["id"] and v["id"] not in seen:
|
||||
seen.add(v["id"])
|
||||
@@ -73,17 +80,18 @@ def collect_vacancies(cfg):
|
||||
return result
|
||||
|
||||
|
||||
def process_account(account_name, limit, dry_run):
|
||||
account_dir = os.path.join(ACCOUNTS_DIR, account_name)
|
||||
def process_account(account_name, limit, dry_run, user):
|
||||
accounts_dir = resolve_accounts_dir(user)
|
||||
account_dir = os.path.join(accounts_dir, account_name)
|
||||
if not os.path.isdir(account_dir):
|
||||
print(f"Аккаунт «{account_name}» не найден в {ACCOUNTS_DIR}")
|
||||
print(f"Аккаунт «{account_name}» не найден в {accounts_dir}")
|
||||
return
|
||||
|
||||
cfg = load_account_config(account_dir)
|
||||
progress = load_progress(account_dir)
|
||||
responded = set(progress["responded"])
|
||||
|
||||
print(f"\n=== Аккаунт: {account_name} ===")
|
||||
print(f"\n=== Аккаунт: {account_name} (пользователь: {user}) ===")
|
||||
if not cfg.get("resume"):
|
||||
print(" ⚠ Нет файла resume.txt — текст отклика будет без учёта резюме")
|
||||
|
||||
@@ -101,70 +109,71 @@ def process_account(account_name, limit, dry_run):
|
||||
llm_cfg = cfg.get("llm", {})
|
||||
style = cfg.get("style", "")
|
||||
|
||||
with sync_playwright() as p:
|
||||
context = get_context(account_dir, account_name)
|
||||
for i, vacancy in enumerate(pending, 1):
|
||||
print(f"\n [{i}/{len(pending)}] {vacancy['title']} — {vacancy['company']}")
|
||||
print(f" {vacancy['url']}")
|
||||
|
||||
for i, vacancy in enumerate(pending, 1):
|
||||
print(f"\n [{i}/{len(pending)}] {vacancy['title']} — {vacancy['company']}")
|
||||
print(f" {vacancy['url']}")
|
||||
# Описание вакансии для ИИ
|
||||
details = fetch_vacancy_details(vacancy["id"], proxies=get_proxies()) or {}
|
||||
vacancy_full = {**vacancy, **details}
|
||||
|
||||
# Описание вакансии для ИИ
|
||||
details = fetch_vacancy_details(vacancy["id"]) or {}
|
||||
vacancy_full = {**vacancy, **details}
|
||||
|
||||
# Генерация текста
|
||||
try:
|
||||
message = generate_response(llm_cfg, vacancy_full, cfg.get("resume", ""), style)
|
||||
except Exception as e:
|
||||
print(f" ⚠ Ошибка ИИ: {e}")
|
||||
message = input(" Введите текст отклика вручную (Enter — пропустить): ").strip()
|
||||
if not message:
|
||||
continue
|
||||
|
||||
print(f"\n --- Текст отклика ---\n {message}\n --------------------")
|
||||
|
||||
if dry_run:
|
||||
print(" [dry-run] браузер не открываю")
|
||||
# Генерация текста
|
||||
try:
|
||||
message = generate_response(llm_cfg, vacancy_full, cfg.get("resume", ""), style)
|
||||
except Exception as e:
|
||||
print(f" ⚠ Ошибка ИИ: {e}")
|
||||
message = input(" Введите текст отклика вручную (Enter — пропустить): ").strip()
|
||||
if not message:
|
||||
continue
|
||||
|
||||
# Открываем в браузере
|
||||
ok, note = open_response_page(context, vacancy_full, message)
|
||||
print(f" {'✓' if ok else '⚠'} {note}")
|
||||
print(f"\n --- Текст отклика ---\n {message}\n --------------------")
|
||||
|
||||
# Ждём решения пользователя
|
||||
while True:
|
||||
choice = input(" Отправили? [Enter=да, s=пропустить, q=выйти]: ").strip().lower()
|
||||
if choice in ("", "д", "y", "yes", "да"):
|
||||
responded.add(vacancy["id"])
|
||||
break
|
||||
if choice == "s":
|
||||
break
|
||||
if choice == "q":
|
||||
close_context(account_name)
|
||||
progress["responded"] = sorted(responded)
|
||||
save_progress(account_dir, progress)
|
||||
print(" Выход. Прогресс сохранён.")
|
||||
return
|
||||
progress["responded"] = sorted(responded)
|
||||
save_progress(account_dir, progress)
|
||||
if dry_run:
|
||||
print(" [dry-run] браузер не открываю")
|
||||
continue
|
||||
|
||||
close_context(account_name)
|
||||
# Открываем в браузере и делаем скриншот
|
||||
shot_dir = os.path.join(account_dir, "screenshots")
|
||||
ok, note, shot = browser_open(account_dir, user, account_name, vacancy_full, message,
|
||||
resume_id=cfg.get("resume_id"), screenshot_dir=shot_dir)
|
||||
print(f" {'✓' if ok else '⚠'} {note}")
|
||||
if shot:
|
||||
print(f" Скриншот: {shot}")
|
||||
|
||||
# Ждём решения пользователя
|
||||
while True:
|
||||
choice = input(" Отправить отклик? [Enter=да, s=пропустить, q=выйти]: ").strip().lower()
|
||||
if choice in ("", "д", "y", "yes", "да"):
|
||||
ok2, note2 = browser_send(user, account_name, vacancy["id"])
|
||||
print(f" {'✓' if ok2 else '⚠'} {note2}")
|
||||
responded.add(vacancy["id"])
|
||||
break
|
||||
if choice == "s":
|
||||
break
|
||||
if choice == "q":
|
||||
browser_close(user, account_name)
|
||||
progress["responded"] = sorted(responded)
|
||||
save_progress(account_dir, progress)
|
||||
print(" Выход. Прогресс сохранён.")
|
||||
return
|
||||
progress["responded"] = sorted(responded)
|
||||
save_progress(account_dir, progress)
|
||||
|
||||
browser_close(user, account_name)
|
||||
print(f"\n=== Аккаунт {account_name}: готово ===")
|
||||
|
||||
|
||||
def login_account(account_name):
|
||||
"""Открывает браузер с профилем аккаунта для ручного входа."""
|
||||
account_dir = os.path.join(ACCOUNTS_DIR, account_name)
|
||||
def login_account(account_name, user):
|
||||
"""Открывает браузер для ручного входа (локальный режим с экраном)."""
|
||||
accounts_dir = resolve_accounts_dir(user)
|
||||
account_dir = os.path.join(accounts_dir, account_name)
|
||||
if not os.path.isdir(account_dir):
|
||||
print(f"Аккаунт «{account_name}» не найден в {ACCOUNTS_DIR}")
|
||||
print(f"Аккаунт «{account_name}» не найден в {accounts_dir}")
|
||||
return
|
||||
print(f"Открываю браузер для аккаунта «{account_name}». Войдите на hh.ru и закройте окно.")
|
||||
context = get_context(account_dir, account_name)
|
||||
page = context.new_page()
|
||||
page.goto("https://hh.ru", timeout=45000)
|
||||
print("Когда войдёте — закройте окно браузера.")
|
||||
close_context(account_name)
|
||||
print(f"Открываю браузер для аккаунта «{account_name}». Войдите на hh.ru.")
|
||||
browser_open_login(account_dir, user, account_name)
|
||||
input("Когда войдёте — нажмите Enter...")
|
||||
browser_close(user, account_name)
|
||||
print("Сессия сохранена.")
|
||||
|
||||
|
||||
@@ -172,27 +181,29 @@ def main():
|
||||
parser = argparse.ArgumentParser(description="Полуавтоматический отклик на hh.ru")
|
||||
parser.add_argument("--account", help="Имя аккаунта (папка в accounts/)")
|
||||
parser.add_argument("--login", help="Войти в аккаунт (открыть браузер для логина)")
|
||||
parser.add_argument("--user", default="admin", help="Пользователь сервиса (users/<user>/accounts)")
|
||||
parser.add_argument("--limit", type=int, help="Максимум откликов на аккаунт")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Без браузера, только текст")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.login:
|
||||
login_account(args.login)
|
||||
login_account(args.login, args.user)
|
||||
return
|
||||
|
||||
if not os.path.isdir(ACCOUNTS_DIR):
|
||||
print(f"Нет папки {ACCOUNTS_DIR}. Создайте аккаунт: см. README.md")
|
||||
accounts_dir = resolve_accounts_dir(args.user)
|
||||
if not os.path.isdir(accounts_dir):
|
||||
print(f"Нет папки {accounts_dir}. Создайте аккаунт: см. README.md")
|
||||
return
|
||||
|
||||
accounts = [args.account] if args.account else sorted(os.listdir(ACCOUNTS_DIR))
|
||||
accounts = [a for a in accounts if os.path.isdir(os.path.join(ACCOUNTS_DIR, a))]
|
||||
accounts = [args.account] if args.account else sorted(os.listdir(accounts_dir))
|
||||
accounts = [a for a in accounts if os.path.isdir(os.path.join(accounts_dir, a))]
|
||||
|
||||
if not accounts:
|
||||
print("Нет аккаунтов в accounts/. Создайте папку аккаунта (см. README.md)")
|
||||
print(f"Нет аккаунтов в {accounts_dir}. Создайте папку аккаунта (см. README.md)")
|
||||
return
|
||||
|
||||
for acc in accounts:
|
||||
process_account(acc, args.limit, args.dry_run)
|
||||
process_account(acc, args.limit, args.dry_run, args.user)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in new issue
Block a user