Полуавтоматический отклик на hh.ru: поиск, ИИ-генерация, веб-интерфейс

This commit is contained in:
apuc committed 2026-09-12 13:16:23 +03:00
commit 0ca55f3818
11 files changed
+1740

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
# Секреты — не коммитить
.env
# Аккаунты пользователя — личные данные (config, резюме, сессии, прогресс)
accounts/*
!accounts/example/
# Кэш Python
__pycache__/
*.pyc
.venv/
+101
View File
@@ -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`)
+14
View File
@@ -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"
}
}
+11
View File
@@ -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, открыт к релокации, готов к офису/гибриду/удалёнке.
+93
View File
@@ -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, "Не нашёл поле сообщения — вставьте текст вручную"
+123
View File
@@ -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
View File
@@ -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
View File
@@ -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']}")
+5
View File
@@ -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
+587
View File
@@ -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 разработчик&#10;NAME:Python backend&#10;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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/"/g,'&quot;');
}
// ---------- Открытие и отметка ----------
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>
+386
View File
@@ -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)