From 0ca55f381847277df62efee1f673cb1ce3475e9c Mon Sep 17 00:00:00 2001 From: apuc Date: Sat, 12 Sep 2026 13:16:23 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=BE=D0=BB=D1=83=D0=B0=D0=B2=D1=82?= =?UTF-8?q?=D0=BE=D0=BC=D0=B0=D1=82=D0=B8=D1=87=D0=B5=D1=81=D0=BA=D0=B8?= =?UTF-8?q?=D0=B9=20=D0=BE=D1=82=D0=BA=D0=BB=D0=B8=D0=BA=20=D0=BD=D0=B0=20?= =?UTF-8?q?hh.ru:=20=D0=BF=D0=BE=D0=B8=D1=81=D0=BA,=20=D0=98=D0=98-=D0=B3?= =?UTF-8?q?=D0=B5=D0=BD=D0=B5=D1=80=D0=B0=D1=86=D0=B8=D1=8F,=20=D0=B2?= =?UTF-8?q?=D0=B5=D0=B1-=D0=B8=D0=BD=D1=82=D0=B5=D1=80=D1=84=D0=B5=D0=B9?= =?UTF-8?q?=D1=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 11 + README.md | 101 +++++ accounts/example/config.example.json | 14 + accounts/example/resume.example.txt | 11 + hh_browser.py | 93 +++++ hh_llm.py | 123 ++++++ hh_respond.py | 199 +++++++++ hh_search.py | 210 ++++++++++ requirements.txt | 5 + templates/index.html | 587 +++++++++++++++++++++++++++ webui.py | 386 ++++++++++++++++++ 11 files changed, 1740 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 accounts/example/config.example.json create mode 100644 accounts/example/resume.example.txt create mode 100644 hh_browser.py create mode 100644 hh_llm.py create mode 100644 hh_respond.py create mode 100644 hh_search.py create mode 100644 requirements.txt create mode 100644 templates/index.html create mode 100644 webui.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..264c99c --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# Секреты — не коммитить +.env + +# Аккаунты пользователя — личные данные (config, резюме, сессии, прогресс) +accounts/* +!accounts/example/ + +# Кэш Python +__pycache__/ +*.pyc +.venv/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..917e037 --- /dev/null +++ b/README.md @@ -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`) \ No newline at end of file diff --git a/accounts/example/config.example.json b/accounts/example/config.example.json new file mode 100644 index 0000000..ba65116 --- /dev/null +++ b/accounts/example/config.example.json @@ -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" + } +} \ No newline at end of file diff --git a/accounts/example/resume.example.txt b/accounts/example/resume.example.txt new file mode 100644 index 0000000..d98716e --- /dev/null +++ b/accounts/example/resume.example.txt @@ -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, открыт к релокации, готов к офису/гибриду/удалёнке. \ No newline at end of file diff --git a/hh_browser.py b/hh_browser.py new file mode 100644 index 0000000..3629531 --- /dev/null +++ b/hh_browser.py @@ -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, "Не нашёл поле сообщения — вставьте текст вручную" \ No newline at end of file diff --git a/hh_llm.py b/hh_llm.py new file mode 100644 index 0000000..030af68 --- /dev/null +++ b/hh_llm.py @@ -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)) \ No newline at end of file diff --git a/hh_respond.py b/hh_respond.py new file mode 100644 index 0000000..4215b18 --- /dev/null +++ b/hh_respond.py @@ -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() \ No newline at end of file diff --git a/hh_search.py b/hh_search.py new file mode 100644 index 0000000..7000ece --- /dev/null +++ b/hh_search.py @@ -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): + """Зарплата из внутри карточки.""" + values = re.findall(r'', 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="([^"]+)"[^>]*>(.*?)', 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"[^>]*>(.*?)', part, re.S) + company = _clean(m.group(1)) if m else None + + # Адрес + m = re.search(r'data-qa="vacancy-serp__vacancy-address"[^>]*>(.*?)', 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"[^>]*>(.*?)', html, re.S) + details["title"] = _clean(m.group(1)) if m else None + + m = re.search(r'data-qa="vacancy-company-name"[^>]*>(.*?)', html, re.S) + details["company"] = _clean(m.group(1)) if m else None + + m = re.search(r'data-qa="vacancy-salary"[^>]*>(.*?)', html, re.S) + details["salary"] = _clean(m.group(1)) if m else None + + m = re.search(r'data-qa="vacancy-description"[^>]*>(.*?)\s*', 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"[^>]*>(.*?)', 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']}") \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ed90c75 --- /dev/null +++ b/requirements.txt @@ -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 \ No newline at end of file diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..f5dd913 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,587 @@ + + + + + +hh.ru автоотклик + + + +
+

hh.ru автоотклик

+ +
+ +
+ +
+
+

Новый аккаунт

+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+ + +
+
+ +
+
+ +
+

Аккаунты

+
Загрузка...
+
+
+ + + +
+ + + + \ No newline at end of file diff --git a/webui.py b/webui.py new file mode 100644 index 0000000..3b845aa --- /dev/null +++ b/webui.py @@ -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/", 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/") +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/", 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//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//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//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//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//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//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//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//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//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) \ No newline at end of file