Полуавтоматический отклик на hh.ru: поиск, ИИ-генерация, веб-интерфейс
This commit is contained in:
commit
0ca55f3818
11 files changed
+1740
No files matched your search
+11
@@ -0,0 +1,11 @@
|
||||
# Секреты — не коммитить
|
||||
.env
|
||||
|
||||
# Аккаунты пользователя — личные данные (config, резюме, сессии, прогресс)
|
||||
accounts/*
|
||||
!accounts/example/
|
||||
|
||||
# Кэш Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
@@ -0,0 +1,101 @@
|
||||
# Автоотклик на hh.ru (полуавтоматический, безопасный)
|
||||
|
||||
Скрипт ищет вакансии на hh.ru по ключевым словам, генерирует персонализированный
|
||||
текст отклика через ИИ (на основе вашего резюме и описания вакансии), открывает
|
||||
страницу в браузере и подставляет текст. **Кнопку «Отправить» всегда нажимаете вы** —
|
||||
это не нарушает правила hh.ru и не рискует аккаунтом.
|
||||
|
||||
## Установка
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
.venv/bin/pip install -r requirements.txt
|
||||
.venv/bin/playwright install chromium
|
||||
```
|
||||
|
||||
Если скачивание браузера падает с 403 (регион), используйте зеркало:
|
||||
|
||||
```bash
|
||||
PLAYWRIGHT_DOWNLOAD_HOST=https://cdn.npmmirror.com/binaries/playwright .venv/bin/playwright install chromium
|
||||
```
|
||||
|
||||
## Запуск веб-интерфейса (рекомендуется)
|
||||
|
||||
```bash
|
||||
.venv/bin/python webui.py
|
||||
```
|
||||
|
||||
Откройте **http://localhost:5000** — там можно:
|
||||
|
||||
- создавать аккаунты (имя, регион, поисковые запросы, стиль)
|
||||
- загружать резюме файлом (**txt, md, docx, pdf**)
|
||||
- сохранять **ключ сессии hh.ru** (cookies из браузера) — нужен для поиска и списка резюме
|
||||
- выбирать **резюме на hh.ru** — оно автоматически подставляется в модалке отклика
|
||||
- искать вакансии и видеть их списком (название, компания, зарплата, опыт)
|
||||
- генерировать тексты откликов через ИИ (редактируются перед отправкой)
|
||||
- открывать вакансию в браузере с уже вставленным текстом и выбранным резюме
|
||||
- отмечать отправленные — они больше не предлагаются
|
||||
|
||||
### Ключ сессии hh.ru
|
||||
|
||||
Чтобы скрипт мог искать вакансии и видеть ваши резюме, нужны cookies из браузера:
|
||||
|
||||
1. Войдите на hh.ru в браузере
|
||||
2. Откройте DevTools (F12) → вкладка **Application/Хранилище** → **Cookies** → `https://hh.ru`
|
||||
3. Скопируйте **все** cookies (главные: `hhtoken`, `hhuid`, `hhrole`, `_xsrf` — старых `hh_sess`/`_hh_` больше нет)
|
||||
4. Вставьте в поле «Ключ сессии hh.ru» в настройках аккаунта и нажмите «Сохранить ключ»
|
||||
|
||||
После этого нажмите «Обновить список» рядом с «Резюме на hh.ru» и выберите резюме,
|
||||
которое должно подставляться при отклике. Ключ хранится в `accounts/<имя>/session.txt`
|
||||
(в git не попадает).
|
||||
|
||||
## Запуск из терминала (CLI)
|
||||
|
||||
```bash
|
||||
.venv/bin/python hh_respond.py --login my_account # войти в аккаунт (один раз)
|
||||
.venv/bin/python hh_respond.py --account my_account # обработать аккаунт
|
||||
.venv/bin/python hh_respond.py --limit 5 # максимум 5 откликов
|
||||
.venv/bin/python hh_respond.py --dry-run # без браузера, только текст
|
||||
```
|
||||
|
||||
## Настройка аккаунта
|
||||
|
||||
Каждая папка в `accounts/` — отдельный аккаунт со своей сессией:
|
||||
|
||||
```
|
||||
accounts/
|
||||
└── my_account/
|
||||
├── config.json # запросы, регион, стиль, выбранное резюме
|
||||
├── resume.txt # текст вашего резюме (для ИИ)
|
||||
├── session.txt # ключ сессии hh.ru (cookies, не коммитить)
|
||||
├── profile/ # сессия браузера (создаётся автоматически)
|
||||
└── progress.json # уже отправленные отклики (автоматически)
|
||||
```
|
||||
|
||||
В веб-интерфейсе всё это создаётся и редактируется без ручной работы с файлами.
|
||||
|
||||
## Поисковые запросы
|
||||
|
||||
Поддерживаются операторы hh.ru:
|
||||
|
||||
| Запрос | Что найдёт |
|
||||
|---|---|
|
||||
| `Python разработчик` | Всё, где упоминается запрос |
|
||||
| `NAME:Python разработчик` | Только по названию вакансии |
|
||||
| `NAME:Python AND NOT NAME:senior` | По названию, исключая сеньоров |
|
||||
|
||||
## Как это работает
|
||||
|
||||
1. Скрипт парсит страницу поиска hh.ru по вашим запросам
|
||||
2. Для каждой новой вакансии получает описание
|
||||
3. ИИ генерирует текст отклика (вакансия + ваше резюме + стиль)
|
||||
4. Браузер открывает вакансию, жмёт «Откликнуться», вставляет текст
|
||||
5. **Вы проверяете текст и нажимаете «Отправить»**
|
||||
6. Отмечаете отправленное — скрипт переходит к следующей
|
||||
|
||||
## Важно
|
||||
|
||||
- Скрипт не нарушает правила hh.ru: финальное действие всегда за человеком
|
||||
- Не запускайте поиск слишком часто — между страницами есть паузы
|
||||
- Если hh.ru покажет капчу — скрипт остановится, решите капчу вручную и запустите снова
|
||||
- `.env` содержит API-ключ — не коммитьте его в git (уже добавлен в `.gitignore`)
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"queries": [
|
||||
"python developer",
|
||||
"python разработчик"
|
||||
],
|
||||
"area": 1,
|
||||
"pages": 1,
|
||||
"style": "Кратко, по делу, без канцелярита",
|
||||
"llm": {
|
||||
"base_url": "https://opencode.ai/zen/v1",
|
||||
"api_key_env": "LLM_API_KEY",
|
||||
"model": "deepseek-v4-flash"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
Иван Иванов, Python-разработчик, 5 лет опыта.
|
||||
|
||||
Навыки: Python 3, Django, FastAPI, PostgreSQL, Redis, Docker, Git, CI/CD (GitHub Actions), REST API, SQLAlchemy, pytest.
|
||||
|
||||
Опыт:
|
||||
- 2021–н.в. ООО «Финтех» — Python-разработчик: разработка бэкенда платёжного сервиса (Django, PostgreSQL), интеграция с банковскими API, оптимизация запросов (снижение нагрузки на БД на 40%).
|
||||
- 2019–2021 ООО «ВебСтудия» — разработчик: сайты на Django, интернет-магазины, интеграции с CRM.
|
||||
|
||||
Образование: МГТУ им. Баумана, факультет информатики, бакалавр (2019).
|
||||
|
||||
Дополнительно: английский B1, открыт к релокации, готов к офису/гибриду/удалёнке.
|
||||
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Работа с браузером: открытие вакансии, клик «Откликнуться», вставка текста."""
|
||||
import os
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
# Селекторы hh.ru (с запасными вариантами)
|
||||
RESPONSE_BUTTONS = [
|
||||
'[data-qa="vacancy-response-link-top"]',
|
||||
'[data-qa="vacancy-response-link"]',
|
||||
'button:has-text("Откликнуться")',
|
||||
]
|
||||
MESSAGE_FIELDS = [
|
||||
'textarea[data-qa="vacancy-response-letter"]',
|
||||
'textarea[data-qa="vacancy-response-popup-form"] textarea',
|
||||
'textarea',
|
||||
]
|
||||
|
||||
# Живые браузерные контексты по аккаунтам: {account_name: context}
|
||||
_browsers = {}
|
||||
|
||||
|
||||
def launch_context(account_dir):
|
||||
"""Запускает браузер с профилем аккаунта (видимое окно)."""
|
||||
p = sync_playwright().start()
|
||||
context = p.chromium.launch_persistent_context(
|
||||
user_data_dir=os.path.join(account_dir, "profile"),
|
||||
headless=False,
|
||||
viewport={"width": 1400, "height": 900},
|
||||
)
|
||||
return context
|
||||
|
||||
|
||||
def get_context(account_dir, account_name):
|
||||
"""Возвращает живой контекст аккаунта, создавая при необходимости."""
|
||||
if account_name not in _browsers:
|
||||
_browsers[account_name] = launch_context(account_dir)
|
||||
return _browsers[account_name]
|
||||
|
||||
|
||||
def close_context(account_name):
|
||||
if account_name in _browsers:
|
||||
try:
|
||||
_browsers[account_name].close()
|
||||
except Exception:
|
||||
pass
|
||||
_browsers.pop(account_name, None)
|
||||
|
||||
|
||||
def open_response_page(context, vacancy, message, resume_id=None):
|
||||
"""Открывает вакансию, жмёт «Откликнуться», выбирает резюме и вставляет текст.
|
||||
Возвращает (ok, note): ok=True если текст вставлен автоматически."""
|
||||
page = context.new_page()
|
||||
page.goto(vacancy["url"], timeout=45000)
|
||||
page.wait_for_timeout(2500)
|
||||
|
||||
# Кнопка «Откликнуться»
|
||||
clicked = False
|
||||
for sel in RESPONSE_BUTTONS:
|
||||
try:
|
||||
btn = page.locator(sel).first
|
||||
if btn.is_visible(timeout=3000):
|
||||
btn.click()
|
||||
clicked = True
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if not clicked:
|
||||
return False, "Не нашёл кнопку «Откликнуться» — сделайте это вручную"
|
||||
|
||||
# Ждём модалку
|
||||
page.wait_for_timeout(2000)
|
||||
|
||||
# Выбор резюме (радио-кнопка name="resumeId" value="{hash}")
|
||||
if resume_id:
|
||||
try:
|
||||
radio = page.locator(f'input[name="resumeId"][value="{resume_id}"]').first
|
||||
if radio.is_visible(timeout=3000):
|
||||
radio.check(force=True)
|
||||
print(f" ✓ Резюме {resume_id} выбрано")
|
||||
except Exception:
|
||||
print(" ⚠ Не нашёл радио-кнопку резюме — выберите вручную")
|
||||
|
||||
# Вставляем текст
|
||||
for sel in MESSAGE_FIELDS:
|
||||
try:
|
||||
field = page.locator(sel).first
|
||||
if field.is_visible(timeout=3000):
|
||||
field.fill(message)
|
||||
return True, "Текст вставлен. Проверьте и нажмите «Отправить»"
|
||||
except Exception:
|
||||
continue
|
||||
return False, "Не нашёл поле сообщения — вставьте текст вручную"
|
||||
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Генерация персонализированного текста отклика через LLM (OpenAI-совместимый API)."""
|
||||
import json
|
||||
import os
|
||||
|
||||
import requests
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def load_env(path=None):
|
||||
"""Простая загрузка .env без внешних зависимостей."""
|
||||
path = path or os.path.join(BASE_DIR, ".env")
|
||||
if not os.path.exists(path):
|
||||
return
|
||||
with open(path, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
key, value = key.strip(), value.strip().strip('"').strip("'")
|
||||
os.environ.setdefault(key, value)
|
||||
|
||||
|
||||
load_env()
|
||||
|
||||
SYSTEM_PROMPT = """Ты — помощник соискателя на hh.ru. Твоя задача — написать короткое
|
||||
сопроводительное письмо (отклик) на конкретную вакансию от имени соискателя.
|
||||
|
||||
Требования к тексту:
|
||||
- 2-4 предложения, максимум 500 символов (ограничение hh.ru)
|
||||
- Обращение «Здравствуйте!»
|
||||
- Обязательно упомяни название вакансии и компанию
|
||||
- Подчеркни 1-2 релевантных навыка из резюме соискателя, которые подходят под требования вакансии
|
||||
- Без воды, без клише «ответственный, коммуникабельный»
|
||||
- Не выдумывай факты, которых нет в резюме
|
||||
- Только текст письма, без кавычек и пояснений"""
|
||||
|
||||
|
||||
def generate_response(llm_config, vacancy, resume_text, style_hint=""):
|
||||
"""Генерирует текст отклика для вакансии.
|
||||
|
||||
llm_config: {"base_url", "api_key", "model"}
|
||||
vacancy: {"title", "company", "description", "salary", "url"}
|
||||
resume_text: текст резюме соискателя
|
||||
style_hint: необязательные пожелания к стилю
|
||||
"""
|
||||
api_key = llm_config.get("api_key") or os.environ.get(llm_config.get("api_key_env", ""))
|
||||
if not api_key:
|
||||
raise RuntimeError("Не задан API-ключ для LLM (api_key или api_key_env в конфиге)")
|
||||
|
||||
base_url = llm_config["base_url"].rstrip("/")
|
||||
url = f"{base_url}/chat/completions"
|
||||
|
||||
# Урезаем описание вакансии: для генерации отклика достаточно начала
|
||||
description = (vacancy.get('description') or '').strip()
|
||||
if len(description) > 1000:
|
||||
description = description[:1000] + "…"
|
||||
|
||||
user_prompt = f"""ВАКАНСИЯ:
|
||||
Название: {vacancy['title']}
|
||||
Компания: {vacancy.get('company') or 'не указана'}
|
||||
Зарплата: {vacancy.get('salary') or 'не указана'}
|
||||
Описание: {description or 'нет описания'}
|
||||
|
||||
РЕЗЮМЕ СОИСКАТЕЛЯ:
|
||||
{resume_text}
|
||||
|
||||
{'ДОПОЛНИТЕЛЬНЫЕ ПОЖЕЛАНИЯ К СТИЛЮ: ' + style_hint if style_hint else ''}
|
||||
|
||||
Напиши текст отклика."""
|
||||
|
||||
payload = {
|
||||
"model": llm_config["model"],
|
||||
"messages": [
|
||||
{"role": "system", "content": SYSTEM_PROMPT},
|
||||
{"role": "user", "content": user_prompt},
|
||||
],
|
||||
"temperature": 0.7,
|
||||
"max_tokens": 300,
|
||||
}
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
resp = requests.post(url, json=payload, headers=headers, timeout=120)
|
||||
if resp.status_code != 200:
|
||||
raise RuntimeError(f"LLM ошибка {resp.status_code}: {resp.text[:300]}")
|
||||
|
||||
data = resp.json()
|
||||
return data["choices"][0]["message"]["content"].strip()
|
||||
|
||||
|
||||
def generate_response_with_retry(llm_config, vacancy, resume_text, style_hint="", attempts=2):
|
||||
"""Генерирует текст с повтором при таймауте/сбое сети."""
|
||||
last_err = None
|
||||
for i in range(attempts):
|
||||
try:
|
||||
return generate_response(llm_config, vacancy, resume_text, style_hint)
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
if i < attempts - 1:
|
||||
print(f" [llm] попытка {i+1} не удалась ({e}), повторяю...")
|
||||
raise last_err
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Быстрый тест: python3 hh_llm.py
|
||||
cfg = {
|
||||
"base_url": os.environ.get("LLM_BASE_URL", "https://api.openai.com/v1"),
|
||||
"api_key_env": "LLM_API_KEY",
|
||||
"model": os.environ.get("LLM_MODEL", "gpt-4o-mini"),
|
||||
}
|
||||
vac = {
|
||||
"title": "Python-разработчик",
|
||||
"company": "ООО Пример",
|
||||
"description": "Разработка бэкенда на Python, Django, PostgreSQL.",
|
||||
"salary": "от 150 000 ₽",
|
||||
}
|
||||
resume = "5 лет опыта в Python, Django, FastAPI, PostgreSQL. Работал в финтехе."
|
||||
print(generate_response(cfg, vac, resume))
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Полуавтоматический отклик на вакансии hh.ru.
|
||||
|
||||
Безопасный режим: скрипт ищет вакансии, генерирует текст отклика через ИИ,
|
||||
открывает страницу в браузере и подставляет текст. Финальное «Отправить»
|
||||
всегда нажимаете вы.
|
||||
|
||||
Аккаунты: каждая папка в accounts/ — отдельный аккаунт со своей сессией
|
||||
(профиль браузера), резюме и настройками.
|
||||
|
||||
Использование:
|
||||
python3 hh_respond.py --login my_account # войти в аккаунт (один раз)
|
||||
python3 hh_respond.py # обработать все аккаунты
|
||||
python3 hh_respond.py --account my_account # только один аккаунт
|
||||
python3 hh_respond.py --limit 5 # максимум 5 откликов на аккаунт
|
||||
python3 hh_respond.py --dry-run # без браузера, только текст
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
from hh_search import search_vacancies, fetch_vacancy_details
|
||||
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")
|
||||
|
||||
|
||||
def load_account_config(account_dir):
|
||||
cfg_path = os.path.join(account_dir, "config.json")
|
||||
with open(cfg_path, encoding="utf-8") as f:
|
||||
cfg = json.load(f)
|
||||
resume_path = os.path.join(account_dir, "resume.txt")
|
||||
if os.path.exists(resume_path):
|
||||
with open(resume_path, encoding="utf-8") as f:
|
||||
cfg["resume"] = f.read()
|
||||
else:
|
||||
cfg["resume"] = ""
|
||||
return cfg
|
||||
|
||||
|
||||
def load_progress(account_dir):
|
||||
path = os.path.join(account_dir, "progress.json")
|
||||
if os.path.exists(path):
|
||||
with open(path, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
return {"responded": []}
|
||||
|
||||
|
||||
def save_progress(account_dir, progress):
|
||||
path = os.path.join(account_dir, "progress.json")
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(progress, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def collect_vacancies(cfg):
|
||||
"""Ищем вакансии по всем запросам аккаунта, убираем дубли."""
|
||||
result = []
|
||||
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))
|
||||
for v in found:
|
||||
if v["id"] and v["id"] not in seen:
|
||||
seen.add(v["id"])
|
||||
result.append(v)
|
||||
time.sleep(1)
|
||||
return result
|
||||
|
||||
|
||||
def process_account(account_name, limit, dry_run):
|
||||
account_dir = os.path.join(ACCOUNTS_DIR, account_name)
|
||||
if not os.path.isdir(account_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} ===")
|
||||
if not cfg.get("resume"):
|
||||
print(" ⚠ Нет файла resume.txt — текст отклика будет без учёта резюме")
|
||||
|
||||
vacancies = collect_vacancies(cfg)
|
||||
pending = [v for v in vacancies if v["id"] not in responded]
|
||||
print(f" Найдено: {len(vacancies)}, новых: {len(pending)}")
|
||||
|
||||
if limit:
|
||||
pending = pending[:limit]
|
||||
|
||||
if not pending:
|
||||
print(" Новых вакансий нет.")
|
||||
return
|
||||
|
||||
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']}")
|
||||
|
||||
# Описание вакансии для ИИ
|
||||
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] браузер не открываю")
|
||||
continue
|
||||
|
||||
# Открываем в браузере
|
||||
ok, note = open_response_page(context, vacancy_full, message)
|
||||
print(f" {'✓' if ok else '⚠'} {note}")
|
||||
|
||||
# Ждём решения пользователя
|
||||
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)
|
||||
|
||||
close_context(account_name)
|
||||
|
||||
print(f"\n=== Аккаунт {account_name}: готово ===")
|
||||
|
||||
|
||||
def login_account(account_name):
|
||||
"""Открывает браузер с профилем аккаунта для ручного входа."""
|
||||
account_dir = os.path.join(ACCOUNTS_DIR, account_name)
|
||||
if not os.path.isdir(account_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("Сессия сохранена.")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Полуавтоматический отклик на hh.ru")
|
||||
parser.add_argument("--account", help="Имя аккаунта (папка в accounts/)")
|
||||
parser.add_argument("--login", help="Войти в аккаунт (открыть браузер для логина)")
|
||||
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)
|
||||
return
|
||||
|
||||
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))]
|
||||
|
||||
if not accounts:
|
||||
print("Нет аккаунтов в accounts/. Создайте папку аккаунта (см. README.md)")
|
||||
return
|
||||
|
||||
for acc in accounts:
|
||||
process_account(acc, args.limit, args.dry_run)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Поиск вакансий на hh.ru через парсинг страницы поиска (без API)."""
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import urllib.parse
|
||||
|
||||
import requests
|
||||
|
||||
HEADERS = {
|
||||
"User-Agent": ("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/120.0 Safari/537.36"),
|
||||
"Accept-Language": "ru-RU,ru;q=0.9",
|
||||
}
|
||||
|
||||
SEARCH_URL = "https://hh.ru/search/vacancy"
|
||||
|
||||
|
||||
def _clean(text):
|
||||
"""Убираем HTML-теги и лишние пробелы."""
|
||||
text = re.sub(r"<[^>]+>", "", text)
|
||||
text = text.replace("\xa0", " ")
|
||||
return re.sub(r"\s+", " ", text).strip()
|
||||
|
||||
|
||||
def _extract_salary(card_html):
|
||||
"""Зарплата из <data value='...'> внутри карточки."""
|
||||
values = re.findall(r'<data value="(\d+)">', card_html)
|
||||
if not values:
|
||||
return None
|
||||
nums = [int(v) for v in values]
|
||||
if len(nums) >= 2:
|
||||
return {"from": nums[0], "to": nums[1]}
|
||||
return {"from": nums[0], "to": None}
|
||||
|
||||
|
||||
def parse_search_page(html):
|
||||
"""Разбираем HTML страницы поиска в список вакансий."""
|
||||
vacancies = []
|
||||
# Разбиваем страницу на карточки
|
||||
card_parts = re.split(r'data-qa="vacancy-serp__vacancy"', html)
|
||||
for part in card_parts[1:]:
|
||||
# Ссылка и название
|
||||
m = re.search(r'data-qa="serp-item__title"[^>]*href="([^"]+)"[^>]*>(.*?)</a>', part, re.S)
|
||||
if not m:
|
||||
continue
|
||||
href, title_html = m.group(1), m.group(2)
|
||||
title = _clean(title_html)
|
||||
if not title:
|
||||
continue
|
||||
|
||||
# Компания
|
||||
m = re.search(r'data-qa="vacancy-serp__vacancy-employer"[^>]*>(.*?)</a>', part, re.S)
|
||||
company = _clean(m.group(1)) if m else None
|
||||
|
||||
# Адрес
|
||||
m = re.search(r'data-qa="vacancy-serp__vacancy-address"[^>]*>(.*?)</span>', part, re.S)
|
||||
address = _clean(m.group(1)) if m else None
|
||||
|
||||
# Опыт работы
|
||||
m = re.search(r'data-qa="vacancy-serp__vacancy-work-experience-([^"]+)"', part)
|
||||
experience = m.group(1) if m else None
|
||||
|
||||
# ID вакансии
|
||||
m = re.search(r"/vacancy/(\d+)", href)
|
||||
vacancy_id = m.group(1) if m else None
|
||||
|
||||
vacancies.append({
|
||||
"id": vacancy_id,
|
||||
"title": title,
|
||||
"company": company,
|
||||
"address": address,
|
||||
"experience": experience,
|
||||
"salary": _extract_salary(part),
|
||||
"url": href.split("?")[0],
|
||||
})
|
||||
return vacancies
|
||||
|
||||
|
||||
def parse_cookies(cookie_str):
|
||||
"""Разбирает cookies в словарь. Поддерживает два формата:
|
||||
1. JSON из расширений (Cookie-Editor): [{"name": ..., "value": ...}, ...]
|
||||
2. Строка вида 'name1=value1; name2=value2'
|
||||
"""
|
||||
cookies = {}
|
||||
if not cookie_str:
|
||||
return cookies
|
||||
s = cookie_str.strip()
|
||||
# Формат JSON
|
||||
if s.startswith("["):
|
||||
try:
|
||||
items = json.loads(s)
|
||||
for item in items:
|
||||
if isinstance(item, dict) and item.get("name"):
|
||||
val = str(item.get("value", ""))
|
||||
if _is_latin1(val):
|
||||
cookies[item["name"]] = val
|
||||
return cookies
|
||||
except Exception:
|
||||
pass
|
||||
# Формат name=value; ...
|
||||
for part in s.split(";"):
|
||||
part = part.strip()
|
||||
if "=" in part:
|
||||
k, v = part.split("=", 1)
|
||||
if _is_latin1(v):
|
||||
cookies[k.strip()] = v.strip()
|
||||
return cookies
|
||||
|
||||
|
||||
def _is_latin1(s):
|
||||
"""HTTP-заголовки требуют latin-1: пропускаем cookies с другими символами."""
|
||||
try:
|
||||
s.encode("latin-1")
|
||||
return True
|
||||
except UnicodeEncodeError:
|
||||
return False
|
||||
|
||||
|
||||
def search_vacancies(query, area=None, pages=1, per_page=20, delay=2.0, cookies=None):
|
||||
"""Ищем вакансии по запросу. Возвращает список словарей."""
|
||||
all_vacancies = []
|
||||
seen = set()
|
||||
for page_num in range(pages):
|
||||
params = {"text": query, "page": page_num, "per_page": per_page}
|
||||
if area:
|
||||
params["area"] = area
|
||||
url = f"{SEARCH_URL}?{urllib.parse.urlencode(params)}"
|
||||
resp = requests.get(url, headers=HEADERS, timeout=30, cookies=cookies)
|
||||
if resp.status_code != 200:
|
||||
print(f" [поиск] HTTP {resp.status_code} для страницы {page_num}")
|
||||
break
|
||||
found = parse_search_page(resp.text)
|
||||
if not found:
|
||||
print(f" [поиск] страница {page_num}: вакансий не найдено (возможно, капча)")
|
||||
break
|
||||
for v in found:
|
||||
if v["id"] and v["id"] not in seen:
|
||||
seen.add(v["id"])
|
||||
all_vacancies.append(v)
|
||||
if len(found) < per_page:
|
||||
break
|
||||
time.sleep(delay) # вежливая пауза между страницами
|
||||
return all_vacancies
|
||||
|
||||
|
||||
def fetch_vacancy_details(vacancy_id, cookies=None):
|
||||
"""Получаем описание вакансии со страницы вакансии."""
|
||||
url = f"https://hh.ru/vacancy/{vacancy_id}"
|
||||
resp = requests.get(url, headers=HEADERS, timeout=30, cookies=cookies)
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
html = resp.text
|
||||
details = {"id": vacancy_id, "url": url}
|
||||
|
||||
m = re.search(r'data-qa="vacancy-title"[^>]*>(.*?)</h1>', html, re.S)
|
||||
details["title"] = _clean(m.group(1)) if m else None
|
||||
|
||||
m = re.search(r'data-qa="vacancy-company-name"[^>]*>(.*?)</a>', html, re.S)
|
||||
details["company"] = _clean(m.group(1)) if m else None
|
||||
|
||||
m = re.search(r'data-qa="vacancy-salary"[^>]*>(.*?)</div>', html, re.S)
|
||||
details["salary"] = _clean(m.group(1)) if m else None
|
||||
|
||||
m = re.search(r'data-qa="vacancy-description"[^>]*>(.*?)</div>\s*</div>', html, re.S)
|
||||
details["description"] = _clean(m.group(1)) if m else None
|
||||
if details["description"]:
|
||||
details["description"] = details["description"][:3000]
|
||||
|
||||
return details
|
||||
|
||||
|
||||
def fetch_my_resumes(cookies=None):
|
||||
"""Получает список резюме пользователя со страницы hh.ru/applicant/resumes.
|
||||
Возвращает список {"id": hash, "title": ..., "updated": ...}."""
|
||||
url = "https://hh.ru/applicant/resumes"
|
||||
resp = requests.get(url, headers=HEADERS, timeout=30, cookies=cookies)
|
||||
if resp.status_code != 200:
|
||||
return None, f"HTTP {resp.status_code} — проверьте ключ сессии"
|
||||
html = resp.text
|
||||
if "resume" not in html.lower() and "Войти" in html:
|
||||
return None, "Сессия недействительна — страница требует входа"
|
||||
|
||||
resumes = []
|
||||
seen = set()
|
||||
# Блоки резюме: ищем ссылки /resume/{hash} (hash = hex-строка 30-50 символов)
|
||||
for m in re.finditer(r'/resume/([a-f0-9]{30,50})', html):
|
||||
hid = m.group(1)
|
||||
if hid in seen:
|
||||
continue
|
||||
seen.add(hid)
|
||||
# Ищем заголовок резюме рядом (до 1500 символов после ссылки)
|
||||
chunk = html[m.start():m.start() + 1500]
|
||||
title_m = re.search(r'data-qa="title"[^>]*>(.*?)</h3>', chunk, re.S)
|
||||
title = _clean(title_m.group(1)) if title_m else None
|
||||
if not title:
|
||||
title_m = re.search(r'data-qa="resume-title"[^>]*>(.*?)<', chunk, re.S)
|
||||
title = _clean(title_m.group(1)) if title_m else "Без названия"
|
||||
resumes.append({"id": hid, "title": title})
|
||||
return resumes, None
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
query = sys.argv[1] if len(sys.argv) > 1 else "python developer"
|
||||
print(f"Ищу: {query}")
|
||||
result = search_vacancies(query, pages=1)
|
||||
print(f"Найдено: {len(result)}")
|
||||
for v in result[:5]:
|
||||
print(f" {v['id']} | {v['title']} | {v['company']} | {v['salary']}")
|
||||
@@ -0,0 +1,5 @@
|
||||
requests>=2.31.0
|
||||
playwright>=1.40.0
|
||||
flask>=3.0.0
|
||||
pypdf>=4.0.0
|
||||
python-docx>=1.1.0
|
||||
@@ -0,0 +1,587 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>hh.ru автоотклик</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0f1117; --card: #1a1d27; --border: #2a2f3a; --text: #e5e7eb;
|
||||
--muted: #9ca3af; --accent: #3b82f6; --accent-hover: #60a5fa;
|
||||
--green: #22c55e; --red: #ef4444; --amber: #f59e0b;
|
||||
--input-bg: #12141c; --hover: #1e222d;
|
||||
--secondary: #2a2f3a; --secondary-hover: #333a47;
|
||||
--danger-bg: #3a1d1d; --danger-hover: #4a2525;
|
||||
--done-bg: #14351f; --new-bg: #1a2f4a; --err-bg: #3a1d1d;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
background: var(--bg); color: var(--text); font-size: 14px; }
|
||||
header { background: var(--card); border-bottom: 1px solid var(--border);
|
||||
padding: 12px 24px; display: flex; align-items: center; gap: 24px; }
|
||||
header h1 { font-size: 18px; font-weight: 600; }
|
||||
nav { display: flex; gap: 4px; }
|
||||
nav button { border: none; background: none; padding: 8px 14px; cursor: pointer;
|
||||
border-radius: 8px; font-size: 14px; color: var(--muted); }
|
||||
nav button.active { background: var(--accent); color: #fff; }
|
||||
main { max-width: 1100px; margin: 24px auto; padding: 0 24px; }
|
||||
.card { background: var(--card); border: 1px solid var(--border);
|
||||
border-radius: 12px; padding: 20px; margin-bottom: 16px; }
|
||||
.card h2 { font-size: 15px; margin-bottom: 14px; }
|
||||
.row { display: flex; gap: 12px; flex-wrap: wrap; }
|
||||
.field { margin-bottom: 12px; flex: 1; min-width: 200px; }
|
||||
.field label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 4px; }
|
||||
input[type=text], input[type=number], select, textarea {
|
||||
width: 100%; padding: 8px 10px; border: 1px solid var(--border);
|
||||
border-radius: 8px; font-size: 14px; font-family: inherit;
|
||||
background: var(--input-bg); color: var(--text); }
|
||||
input[type=file] { color: var(--muted); font-size: 13px; }
|
||||
input[type=file]::file-selector-button {
|
||||
background: var(--secondary); color: var(--text); border: none;
|
||||
border-radius: 6px; padding: 6px 12px; margin-right: 10px; cursor: pointer; }
|
||||
input[type=file]::file-selector-button:hover { background: var(--secondary-hover); }
|
||||
textarea { resize: vertical; }
|
||||
button { padding: 8px 16px; border: none; border-radius: 8px; cursor: pointer;
|
||||
font-size: 14px; background: var(--accent); color: #fff; }
|
||||
button:hover { background: var(--accent-hover); }
|
||||
button.secondary { background: var(--secondary); color: var(--text); }
|
||||
button.secondary:hover { background: var(--secondary-hover); }
|
||||
button.danger { background: var(--danger-bg); color: var(--red); }
|
||||
button.danger:hover { background: var(--danger-hover); }
|
||||
button:disabled { opacity: .5; cursor: not-allowed; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--border);
|
||||
vertical-align: top; }
|
||||
th { font-size: 12px; color: var(--muted); font-weight: 500; }
|
||||
tr:hover td { background: var(--hover); }
|
||||
.badge { display: inline-block; padding: 2px 8px; border-radius: 999px;
|
||||
font-size: 12px; }
|
||||
.badge.done { background: var(--done-bg); color: var(--green); }
|
||||
.badge.new { background: var(--new-bg); color: var(--accent); }
|
||||
.badge.err { background: var(--err-bg); color: var(--red); }
|
||||
.muted { color: var(--muted); font-size: 12px; }
|
||||
.error { color: var(--red); font-size: 13px; margin-top: 6px; }
|
||||
.ok { color: var(--green); font-size: 13px; margin-top: 6px; }
|
||||
.account-item { display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 10px 12px; border: 1px solid var(--border);
|
||||
border-radius: 8px; margin-bottom: 8px; background: var(--card); }
|
||||
.account-item .name { font-weight: 600; }
|
||||
.account-item .actions { display: flex; gap: 8px; }
|
||||
.hidden { display: none; }
|
||||
.texts-panel textarea { min-height: 90px; margin-bottom: 8px; }
|
||||
.texts-panel .item { margin-bottom: 14px; }
|
||||
.texts-panel .item .title { font-weight: 600; margin-bottom: 4px; }
|
||||
.toolbar { display: flex; gap: 8px; align-items: center; margin-bottom: 12px; flex-wrap: wrap; }
|
||||
.toolbar .spacer { flex: 1; }
|
||||
.status-line { font-size: 13px; margin-top: 8px; }
|
||||
.empty { color: var(--muted); text-align: center; padding: 30px 0; }
|
||||
.link { color: var(--accent); text-decoration: none; }
|
||||
.link:hover { text-decoration: underline; }
|
||||
.actions-cell { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.actions-cell button { padding: 4px 10px; font-size: 12px; }
|
||||
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
::-webkit-scrollbar-track { background: var(--bg); }
|
||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 5px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--secondary-hover); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>hh.ru автоотклик</h1>
|
||||
<nav>
|
||||
<button id="tab-btn-accounts" class="active" onclick="showTab('accounts')">Аккаунты</button>
|
||||
<button id="tab-btn-vacancies" onclick="showTab('vacancies')">Вакансии</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<!-- ============ ВКЛАДКА: АККАУНТЫ ============ -->
|
||||
<section id="tab-accounts">
|
||||
<div class="card">
|
||||
<h2>Новый аккаунт</h2>
|
||||
<div class="row">
|
||||
<div class="field" style="flex:0 0 220px">
|
||||
<label>Имя аккаунта</label>
|
||||
<input type="text" id="new-name" placeholder="например, my_account">
|
||||
</div>
|
||||
<div class="field" style="flex:0 0 160px">
|
||||
<label>Регион</label>
|
||||
<select id="new-area">
|
||||
<option value="1">Москва</option>
|
||||
<option value="2">Санкт-Петербург</option>
|
||||
<option value="113">Россия</option>
|
||||
<option value="3">Екатеринбург</option>
|
||||
<option value="4">Новосибирск</option>
|
||||
<option value="88">Казань</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Поисковые запросы (по одному на строку, можно с операторами: NAME:Python, NOT senior)</label>
|
||||
<textarea id="new-queries" rows="3" placeholder="Python разработчик NAME:Python backend NAME:Python AND NOT NAME:senior"></textarea>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Стиль отклика (необязательно)</label>
|
||||
<input type="text" id="new-style" placeholder="Кратко, по делу, без канцелярита">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Резюме (текст, который ИИ использует для откликов)</label>
|
||||
<textarea id="new-resume" rows="5" placeholder="Имя, опыт, навыки, проекты..."></textarea>
|
||||
<div style="margin-top:6px; display:flex; gap:8px; align-items:center">
|
||||
<input type="file" id="new-resume-file" accept=".txt,.md,.docx,.pdf" style="flex:1">
|
||||
<button class="secondary" onclick="uploadResume('new')">Загрузить файл</button>
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="createAccount()">Создать аккаунт</button>
|
||||
<div id="new-msg"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Аккаунты</h2>
|
||||
<div id="accounts-list"><div class="empty">Загрузка...</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ ВКЛАДКА: ВАКАНСИИ ============ -->
|
||||
<section id="tab-vacancies" class="hidden">
|
||||
<div class="card">
|
||||
<div class="toolbar">
|
||||
<select id="acc-select" style="max-width:260px" onchange="selectAccount()"></select>
|
||||
<button class="secondary" onclick="loadAccountSettings()">Обновить</button>
|
||||
<div class="spacer"></div>
|
||||
<button class="secondary" onclick="openLogin()">Войти в hh.ru</button>
|
||||
<button onclick="runSearch()">Найти вакансии</button>
|
||||
</div>
|
||||
<div id="acc-info" class="muted"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Настройки аккаунта</h2>
|
||||
<div class="row">
|
||||
<div class="field" style="flex:0 0 160px">
|
||||
<label>Регион</label>
|
||||
<select id="cfg-area">
|
||||
<option value="1">Москва</option>
|
||||
<option value="2">Санкт-Петербург</option>
|
||||
<option value="113">Россия</option>
|
||||
<option value="3">Екатеринбург</option>
|
||||
<option value="4">Новосибирск</option>
|
||||
<option value="88">Казань</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field" style="flex:0 0 120px">
|
||||
<label>Страниц</label>
|
||||
<input type="number" id="cfg-pages" min="1" max="10" value="1">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Поисковые запросы</label>
|
||||
<textarea id="cfg-queries" rows="3"></textarea>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Стиль отклика</label>
|
||||
<input type="text" id="cfg-style">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Резюме</label>
|
||||
<textarea id="cfg-resume" rows="6"></textarea>
|
||||
<div style="margin-top:6px; display:flex; gap:8px; align-items:center">
|
||||
<input type="file" id="cfg-resume-file" accept=".txt,.md,.docx,.pdf" style="flex:1">
|
||||
<button class="secondary" onclick="uploadResume('cfg')">Загрузить файл</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Ключ сессии hh.ru (все cookies из браузера)</label>
|
||||
<input type="password" id="cfg-session" placeholder="hhtoken=...; hhuid=...; hhrole=...; _xsrf=...">
|
||||
<div style="margin-top:6px; display:flex; gap:8px; align-items:center">
|
||||
<button class="secondary" onclick="saveSession()">Сохранить ключ</button>
|
||||
<span id="session-msg" class="muted"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Резюме на hh.ru (для автовыбора в модалке)</label>
|
||||
<div style="display:flex; gap:8px; align-items:center">
|
||||
<select id="cfg-resume-id" style="flex:1">
|
||||
<option value="">— не выбрано —</option>
|
||||
</select>
|
||||
<button class="secondary" onclick="loadResumes()">Обновить список</button>
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="saveSettings()">Сохранить настройки</button>
|
||||
<span id="cfg-msg"></span>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="toolbar">
|
||||
<h2 style="margin:0">Вакансии</h2>
|
||||
<div class="spacer"></div>
|
||||
<span id="search-status" class="muted"></span>
|
||||
<button class="secondary" onclick="resetProgress()">Сбросить прогресс</button>
|
||||
<button id="btn-generate" onclick="generateTexts()" disabled>Сгенерировать тексты</button>
|
||||
</div>
|
||||
<div id="vacancies-table"></div>
|
||||
</div>
|
||||
|
||||
<div class="card texts-panel hidden" id="texts-panel">
|
||||
<h2>Тексты откликов (можно редактировать)</h2>
|
||||
<div id="texts-list"></div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
let currentAccount = null;
|
||||
let vacancies = [];
|
||||
let generatedTexts = {};
|
||||
|
||||
// ---------- Вкладки ----------
|
||||
function showTab(name) {
|
||||
document.getElementById('tab-accounts').classList.toggle('hidden', name !== 'accounts');
|
||||
document.getElementById('tab-vacancies').classList.toggle('hidden', name !== 'vacancies');
|
||||
document.getElementById('tab-btn-accounts').classList.toggle('active', name === 'accounts');
|
||||
document.getElementById('tab-btn-vacancies').classList.toggle('active', name === 'vacancies');
|
||||
if (name === 'vacancies') loadAccounts();
|
||||
}
|
||||
|
||||
// ---------- Аккаунты ----------
|
||||
async function api(url, opts = {}) {
|
||||
const resp = await fetch(url, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...opts,
|
||||
});
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok) throw new Error(data.error || `HTTP ${resp.status}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
async function loadAccounts() {
|
||||
const list = await api('/api/accounts');
|
||||
const sel = document.getElementById('acc-select');
|
||||
const prev = sel.value;
|
||||
sel.innerHTML = '';
|
||||
list.forEach(a => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = a; opt.textContent = a;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
if (list.includes(prev)) sel.value = prev;
|
||||
renderAccounts(list);
|
||||
if (list.length) selectAccount();
|
||||
}
|
||||
|
||||
function renderAccounts(list) {
|
||||
const box = document.getElementById('accounts-list');
|
||||
if (!list.length) { box.innerHTML = '<div class="empty">Пока нет аккаунтов — создайте первый выше</div>'; return; }
|
||||
box.innerHTML = '';
|
||||
list.forEach(name => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'account-item';
|
||||
div.innerHTML = `
|
||||
<span class="name">${name}</span>
|
||||
<span class="actions">
|
||||
<button class="secondary" onclick="pickAccount('${name}')">Вакансии</button>
|
||||
<button class="danger" onclick="deleteAccount('${name}')">Удалить</button>
|
||||
</span>`;
|
||||
box.appendChild(div);
|
||||
});
|
||||
}
|
||||
|
||||
async function createAccount() {
|
||||
const msg = document.getElementById('new-msg');
|
||||
try {
|
||||
await api('/api/accounts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: document.getElementById('new-name').value,
|
||||
queries: document.getElementById('new-queries').value,
|
||||
area: document.getElementById('new-area').value,
|
||||
style: document.getElementById('new-style').value,
|
||||
resume: document.getElementById('new-resume').value,
|
||||
}),
|
||||
});
|
||||
msg.className = 'ok'; msg.textContent = 'Аккаунт создан';
|
||||
document.getElementById('new-name').value = '';
|
||||
await loadAccounts();
|
||||
} catch (e) { msg.className = 'error'; msg.textContent = e.message; }
|
||||
}
|
||||
|
||||
async function deleteAccount(name) {
|
||||
if (!confirm(`Удалить аккаунт «${name}»? Сессия и прогресс будут удалены.`)) return;
|
||||
await api(`/api/accounts/${name}`, { method: 'DELETE' });
|
||||
await loadAccounts();
|
||||
}
|
||||
|
||||
function pickAccount(name) {
|
||||
document.getElementById('acc-select').value = name;
|
||||
selectAccount();
|
||||
showTab('vacancies');
|
||||
}
|
||||
|
||||
// ---------- Настройки аккаунта ----------
|
||||
async function selectAccount() {
|
||||
currentAccount = document.getElementById('acc-select').value;
|
||||
if (!currentAccount) return;
|
||||
await loadAccountSettings();
|
||||
await runSearch();
|
||||
}
|
||||
|
||||
async function loadAccountSettings() {
|
||||
currentAccount = document.getElementById('acc-select').value;
|
||||
if (!currentAccount) return;
|
||||
const data = await api(`/api/accounts/${currentAccount}`);
|
||||
const cfg = data.config || {};
|
||||
document.getElementById('cfg-queries').value = (cfg.queries || []).join('\n');
|
||||
document.getElementById('cfg-area').value = cfg.area || 1;
|
||||
document.getElementById('cfg-pages').value = cfg.pages || 1;
|
||||
document.getElementById('cfg-style').value = cfg.style || '';
|
||||
document.getElementById('cfg-resume').value = data.resume || '';
|
||||
document.getElementById('cfg-resume-id').value = cfg.resume_id || '';
|
||||
document.getElementById('acc-info').textContent =
|
||||
`Откликнуто: ${data.responded.length}`;
|
||||
// Статус ключа сессии (сам ключ не показываем — он хранится на сервере)
|
||||
const sm = document.getElementById('session-msg');
|
||||
if (data.has_session) {
|
||||
sm.className = 'ok';
|
||||
sm.textContent = 'Ключ сохранён на сервере. Меняйте только если истёк.';
|
||||
} else {
|
||||
sm.className = 'error';
|
||||
sm.textContent = 'Ключ не задан — вставьте cookies и нажмите «Сохранить ключ»';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Ключ сессии hh.ru ----------
|
||||
async function saveSession() {
|
||||
const msg = document.getElementById('session-msg');
|
||||
const session = document.getElementById('cfg-session').value.trim();
|
||||
if (!session) { msg.className = 'error'; msg.textContent = 'Вставьте cookies'; return; }
|
||||
msg.className = ''; msg.textContent = 'Сохраняю и проверяю...';
|
||||
try {
|
||||
const data = await api(`/api/accounts/${currentAccount}/session`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ session }),
|
||||
});
|
||||
msg.className = 'ok';
|
||||
msg.textContent = data.warning || 'Сохранено, сессия работает';
|
||||
if (data.resumes) fillResumeSelect(data.resumes);
|
||||
} catch (e) {
|
||||
msg.className = 'error'; msg.textContent = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadResumes() {
|
||||
const msg = document.getElementById('session-msg');
|
||||
msg.className = ''; msg.textContent = 'Загружаю резюме...';
|
||||
try {
|
||||
const data = await api(`/api/accounts/${currentAccount}/resumes`);
|
||||
fillResumeSelect(data.resumes);
|
||||
msg.className = 'ok'; msg.textContent = `Найдено резюме: ${data.resumes.length}`;
|
||||
} catch (e) {
|
||||
msg.className = 'error'; msg.textContent = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
function fillResumeSelect(resumes) {
|
||||
const sel = document.getElementById('cfg-resume-id');
|
||||
const prev = sel.value;
|
||||
sel.innerHTML = '<option value="">— не выбрано —</option>' +
|
||||
resumes.map(r => `<option value="${r.id}">${r.title}</option>`).join('');
|
||||
if (prev) sel.value = prev;
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
const msg = document.getElementById('cfg-msg');
|
||||
try {
|
||||
await api(`/api/accounts/${currentAccount}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
queries: document.getElementById('cfg-queries').value,
|
||||
area: document.getElementById('cfg-area').value,
|
||||
pages: document.getElementById('cfg-pages').value,
|
||||
style: document.getElementById('cfg-style').value,
|
||||
resume: document.getElementById('cfg-resume').value,
|
||||
resume_id: document.getElementById('cfg-resume-id').value,
|
||||
}),
|
||||
});
|
||||
msg.className = 'ok'; msg.textContent = 'Сохранено';
|
||||
setTimeout(() => msg.textContent = '', 2000);
|
||||
} catch (e) { msg.className = 'error'; msg.textContent = e.message; }
|
||||
}
|
||||
|
||||
// ---------- Загрузка резюме файлом ----------
|
||||
async function uploadResume(which) {
|
||||
const fileInput = document.getElementById(which === 'cfg' ? 'cfg-resume-file' : 'new-resume-file');
|
||||
const file = fileInput.files[0];
|
||||
if (!file) { alert('Выберите файл'); return; }
|
||||
const target = which === 'cfg' ? currentAccount : document.getElementById('new-name').value.trim();
|
||||
if (!target) { alert('Сначала укажите имя аккаунта'); return; }
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
const resp = await fetch(`/api/accounts/${target}/resume-upload`, { method: 'POST', body: fd });
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) { alert('Ошибка: ' + data.error); return; }
|
||||
const ta = document.getElementById(which === 'cfg' ? 'cfg-resume' : 'new-resume');
|
||||
ta.value = data.text;
|
||||
alert(`Резюме загружено (${data.chars} символов). Проверьте текст и сохраните настройки.`);
|
||||
}
|
||||
|
||||
// ---------- Поиск ----------
|
||||
async function runSearch() {
|
||||
if (!currentAccount) return;
|
||||
const status = document.getElementById('search-status');
|
||||
status.textContent = 'Ищу...';
|
||||
try {
|
||||
const data = await api(`/api/accounts/${currentAccount}/search`, { method: 'POST' });
|
||||
vacancies = data.vacancies;
|
||||
status.textContent = `Найдено: ${data.total}`;
|
||||
renderVacancies();
|
||||
} catch (e) {
|
||||
status.textContent = '';
|
||||
document.getElementById('vacancies-table').innerHTML =
|
||||
`<div class="error">Ошибка поиска: ${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderVacancies() {
|
||||
const box = document.getElementById('vacancies-table');
|
||||
if (!vacancies.length) {
|
||||
box.innerHTML = '<div class="empty">Вакансий нет. Проверьте запросы или нажмите «Найти вакансии»</div>';
|
||||
return;
|
||||
}
|
||||
let html = `<table><thead><tr>
|
||||
<th style="width:28px"><input type="checkbox" id="check-all" onchange="toggleAll(this)"></th>
|
||||
<th>Вакансия</th><th>Компания</th><th>Зарплата</th><th>Опыт</th><th>Статус</th><th>Действия</th>
|
||||
</tr></thead><tbody>`;
|
||||
vacancies.forEach(v => {
|
||||
const salary = v.salary
|
||||
? (v.salary.from ? `от ${v.salary.from.toLocaleString('ru')}` : '') +
|
||||
(v.salary.to ? ` до ${v.salary.to.toLocaleString('ru')}` : '')
|
||||
: '—';
|
||||
const exp = { 'noExperience': 'без опыта', 'between1And3': '1–3 года',
|
||||
'between3And6': '3–6 лет', 'moreThan6': '6+ лет' }[v.experience] || '—';
|
||||
const status = v.responded
|
||||
? '<span class="badge done">откликнуто</span>'
|
||||
: '<span class="badge new">новая</span>';
|
||||
html += `<tr data-id="${v.id}">
|
||||
<td><input type="checkbox" class="v-check" data-id="${v.id}" onchange="updateGenerateBtn()"></td>
|
||||
<td><a class="link" href="${v.url}" target="_blank">${v.title}</a></td>
|
||||
<td>${v.company || '—'}</td>
|
||||
<td>${salary}</td>
|
||||
<td>${exp}</td>
|
||||
<td>${status}</td>
|
||||
<td class="actions-cell">
|
||||
<button class="secondary" onclick="openVacancy('${v.id}')">Открыть</button>
|
||||
<button class="secondary" onclick="markDone('${v.id}')">Отправил</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
box.innerHTML = html;
|
||||
updateGenerateBtn();
|
||||
}
|
||||
|
||||
function selectedIds() {
|
||||
return [...document.querySelectorAll('.v-check:checked')].map(c => c.dataset.id);
|
||||
}
|
||||
|
||||
function toggleAll(cb) {
|
||||
document.querySelectorAll('.v-check').forEach(c => c.checked = cb.checked);
|
||||
updateGenerateBtn();
|
||||
}
|
||||
|
||||
function updateGenerateBtn() {
|
||||
document.getElementById('btn-generate').disabled = selectedIds().length === 0;
|
||||
}
|
||||
|
||||
// ---------- Генерация текстов ----------
|
||||
async function generateTexts() {
|
||||
const ids = selectedIds();
|
||||
if (!ids.length) return;
|
||||
const btn = document.getElementById('btn-generate');
|
||||
btn.disabled = true; btn.textContent = 'Генерирую...';
|
||||
try {
|
||||
const data = await api(`/api/accounts/${currentAccount}/generate`, {
|
||||
method: 'POST', body: JSON.stringify({ ids }),
|
||||
});
|
||||
generatedTexts = { ...generatedTexts, ...data.texts };
|
||||
renderTexts(data.errors || {});
|
||||
} catch (e) {
|
||||
alert('Ошибка генерации: ' + e.message);
|
||||
}
|
||||
btn.textContent = 'Сгенерировать тексты';
|
||||
updateGenerateBtn();
|
||||
}
|
||||
|
||||
function renderTexts(errors = {}) {
|
||||
const panel = document.getElementById('texts-panel');
|
||||
const list = document.getElementById('texts-list');
|
||||
panel.classList.remove('hidden');
|
||||
list.innerHTML = '';
|
||||
vacancies.filter(v => generatedTexts[v.id]).forEach(v => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'item';
|
||||
div.innerHTML = `
|
||||
<div class="title">${v.title} — ${v.company || ''}</div>
|
||||
<textarea data-id="${v.id}">${escapeHtml(generatedTexts[v.id])}</textarea>
|
||||
<div class="toolbar">
|
||||
<button class="secondary" onclick="openVacancy('${v.id}')">Открыть в браузере</button>
|
||||
<button class="secondary" onclick="markDone('${v.id}')">Отправил</button>
|
||||
<span class="muted" id="note-${v.id}"></span>
|
||||
</div>`;
|
||||
list.appendChild(div);
|
||||
});
|
||||
vacancies.filter(v => errors[v.id]).forEach(v => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'item';
|
||||
div.innerHTML = `<div class="title">${v.title}</div>
|
||||
<div class="error">Ошибка ИИ: ${escapeHtml(errors[v.id])}</div>`;
|
||||
list.appendChild(div);
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
|
||||
.replace(/"/g,'"');
|
||||
}
|
||||
|
||||
// ---------- Открытие и отметка ----------
|
||||
async function openVacancy(id) {
|
||||
const ta = document.querySelector(`textarea[data-id="${id}"]`);
|
||||
const message = ta ? ta.value : (generatedTexts[id] || '');
|
||||
const note = document.getElementById(`note-${id}`);
|
||||
if (note) { note.textContent = 'Открываю браузер...'; }
|
||||
try {
|
||||
await api(`/api/accounts/${currentAccount}/open`, {
|
||||
method: 'POST', body: JSON.stringify({ id, message }),
|
||||
});
|
||||
} catch (e) {
|
||||
if (note) note.textContent = 'Ошибка: ' + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function markDone(id) {
|
||||
await api(`/api/accounts/${currentAccount}/done`, {
|
||||
method: 'POST', body: JSON.stringify({ id }),
|
||||
});
|
||||
const v = vacancies.find(x => x.id === id);
|
||||
if (v) v.responded = true;
|
||||
renderVacancies();
|
||||
const note = document.getElementById(`note-${id}`);
|
||||
if (note) note.textContent = 'Отмечено как отправленное';
|
||||
}
|
||||
|
||||
async function resetProgress() {
|
||||
if (!confirm('Сбросить прогресс (все вакансии снова будут «новыми»)?')) return;
|
||||
await api(`/api/accounts/${currentAccount}/reset`, { method: 'POST' });
|
||||
await runSearch();
|
||||
}
|
||||
|
||||
function openLogin() {
|
||||
if (!currentAccount) return;
|
||||
window.open(`/api/accounts/${currentAccount}/login`, '_blank');
|
||||
}
|
||||
|
||||
// ---------- Старт ----------
|
||||
loadAccounts();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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