Полуавтоматический отклик на 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