diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f452d6a --- /dev/null +++ b/.gitignore @@ -0,0 +1,196 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +Pipfile.lock + +# poetry +poetry.lock + +# pdm +.pdm.toml + +# PEP 582 +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +.idea/ + +# VS Code +.vscode/ + +# Specific to this project +# ======================= + +# API Keys and sensitive data +.env +*.key +secrets/ + +# Logs +logs/ +*.log + +# Resume data (may contain personal info) +data/experience.txt +data/about_me.txt +data/skills.txt + +# Browser data +*.profile/ +downloads/ + +# Config files with personal data +config.local +local_config.py + +# Selenium WebDriver logs +geckodriver.log +chromedriver.log + +# Temporary files +tmp/ +temp/ +*.tmp + +# Screenshots (may contain sensitive info) +screenshots/ +*.png +*.jpg +*.jpeg + +# Database files +*.db +*.sqlite + +# OS specific +.DS_Store +Thumbs.db +desktop.ini + +# IDE specific +*.swp +*.swo +*~ \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..145852d --- /dev/null +++ b/LICENSE @@ -0,0 +1,35 @@ +HH.ru Bot - Автоматизация поиска работы +Copyright (c) 2025 + +Эта работа распространяется под лицензией Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License. + +УСЛОВИЯ ЛИЦЕНЗИИ: + +ВЫ ИМЕЕТЕ ПРАВО: +- Делиться — копировать и распространять материал на любом носителе и в любом формате +- Адаптировать — ремиксировать, изменять и создавать на основе данного материала + +ПРИ СОБЛЮДЕНИИ СЛЕДУЮЩИХ УСЛОВИЙ: +- Указание авторства — Вы должны указать авторство, предоставить ссылку на лицензию и указать, были ли внесены изменения +- Некоммерческое использование — Вы не можете использовать данный материал в коммерческих целях +- На тех же условиях — При ремиксе, изменении или создании на основе данного материала, Вы должны распространять ваши материалы на условиях той же лицензии + +ЗАПРЕЩАЕТСЯ: +- Коммерческое использование проекта или его частей +- Продажа доступа к функциональности проекта +- Использование в коммерческих продуктах или сервисах +- Монетизация проекта любым способом + +ОТКАЗ ОТ ГАРАНТИЙ: +Данное программное обеспечение предоставляется "как есть", без каких-либо гарантий, явных или подразумеваемых, включая, но не ограничиваясь гарантиями товарной пригодности, соответствия определенной цели и отсутствия нарушений. Ни при каких обстоятельствах авторы или владельцы авторских прав не несут ответственности за любые претензии, ущерб или другие обязательства. + +ДОПОЛНИТЕЛЬНЫЕ ОГРАНИЧЕНИЯ: +- Запрещается использование для создания коммерческих HR-платформ +- Запрещается интеграция в платные сервисы поиска работы +- Запрещается продажа доступа к API или функциональности +- Использование разрешено только в личных/образовательных целях + +Полный текст лицензии CC BY-NC-SA 4.0 доступен по адресу: +https://creativecommons.org/licenses/by-nc-sa/4.0/ + +Для получения разрешения на коммерческое использование обращайтесь к авторам проекта. \ No newline at end of file diff --git a/README.md b/README.md index e69de29..bfd3ba6 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,270 @@ +# 🤖 HH.ru Bot - Автоматизация поиска работы + +> Современный бот для автоматического поиска и отклика на вакансии с поддержкой ИИ-анализа + +[![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/) +[![License: CC BY-NC-SA 4.0](https://img.shields.io/badge/License-CC%20BY--NC--SA%204.0-lightgrey.svg)](https://creativecommons.org/licenses/by-nc-sa/4.0/) +[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) + +## 🎯 Что умеет бот + +- **🔍 Умный поиск** - Находит релевантные вакансии по ключевым словам +- **🤖 ИИ-анализ** - Gemini AI оценивает соответствие резюме требованиям +- **📝 Автоматические отклики** - Отправляет заявки на подходящие вакансии +- **⚙️ Гибкая настройка** - Настраиваемые критерии поиска и фильтрации +- **📊 Детальная статистика** - Отчёты о проделанной работе +- **🛡️ Безопасность** - Имитация человеческого поведения, паузы между действиями + +## 🚀 Быстрый старт + +### 1. Установка + +```bash +# Клонируем репозиторий +git clone https://github.com/your-username/hh-bot.git +cd hh-bot + +# Устанавливаем зависимости +pip install -r requirements.txt +``` + +### 2. Настройка + +Создайте файл `.env` в корне проекта: + +```env +GEMINI_API_KEY=your_gemini_api_key_here +``` + +### 3. Подготовка резюме + +Создайте файлы с информацией о себе в папке `data/`: + +- `experience.txt` - Опыт работы +- `about_me.txt` - О себе +- `skills.txt` - Навыки и технологии + +> 💡 При первом запуске бот автоматически создаст примеры этих файлов + +### 4. Запуск + +```bash +# Рекомендуемый способ +python -m hh_bot + +# Альтернативный способ +python main.py +``` + +## 📋 Пример использования + +1. **Запустите бота** одной из команд выше +2. **Настройте поиск** - введите ключевые слова (например: "Python junior") +3. **Выберите параметры** - максимальное количество откликов, использование ИИ +4. **Подтвердите запуск** - бот начнёт работу автоматически +5. **Дождитесь результатов** - получите подробный отчёт о проделанной работе + +``` +🚀 HH.ru АВТОМАТИЗАЦИЯ v2.0 +================================================== +🏗️ Архитектурно правильная версия +🤖 С поддержкой Gemini AI +📄 Загрузка резюме из файлов +================================================== + +⚙️ ТЕКУЩИЕ НАСТРОЙКИ: +🔍 Ключевые слова: python junior +📊 Максимум заявок: 40 +🤖 Gemini AI: ✅ Доступен +🌐 Режим браузера: Видимый + +🎯 НАСТРОЙКА ПОИСКА: +Ключевые слова [python junior]: python разработчик +Использовать AI фильтрацию? [y/n]: y +Максимум заявок [40]: 25 + +Начать автоматизацию? [y/n]: y +``` + +## 🏗️ Архитектура проекта + +``` +hh-bot/ +├── 📁 hh_bot/ # Основной пакет +│ ├── 📁 cli/ # Интерфейс командной строки +│ │ └── interface.py # CLI логика +│ ├── 📁 config/ # Конфигурация +│ │ ├── settings.py # Настройки и константы +│ │ └── logging_config.py # Логирование +│ ├── 📁 core/ # Бизнес-логика +│ │ └── job_application_manager.py # Главный менеджер +│ ├── 📁 models/ # Модели данных +│ │ └── vacancy.py # Структуры вакансий +│ └── 📁 services/ # Сервисы +│ ├── hh_api_service.py # API HH.ru +│ ├── gemini_service.py # Gemini AI +│ └── browser_service.py # Автоматизация браузера +├── 📁 data/ # Файлы резюме +├── 📁 logs/ # Логи работы +├── main.py # Точка входа +├── requirements.txt # Зависимости +└── README.md # Документация +``` + +## ⚙️ Конфигурация + +### Переменные окружения + +| Переменная | Описание | Значение по умолчанию | +|------------|----------|-----------------------| +| `GEMINI_API_KEY` | API ключ для Gemini AI | - | + +### Настройки в коде + +```python +# В hh_bot/config/settings.py +class AppConstants: + # Параметры поиска + MAX_VACANCIES_PER_PAGE = 50 + MAX_SEARCH_PAGES = 5 + DEFAULT_MAX_APPLICATIONS = 40 + + # ИИ анализ + DEFAULT_AI_THRESHOLD = 0.7 + + # Таймауты + DEFAULT_TIMEOUT = 30 + API_PAUSE_SECONDS = 0.5 +``` + +## 🤖 ИИ-функциональность + +### Как работает Gemini AI + +1. **Анализ вакансии** - Извлекает требования из описания +2. **Сопоставление с резюме** - Сравнивает навыки и опыт +3. **Оценка соответствия** - Выставляет балл от 0.0 до 1.0 +4. **Принятие решения** - Рекомендует откликаться или нет + +### Пример анализа + +``` +🤖 ЭТАП 2: AI анализ вакансий +Анализ 1/15: Python разработчик... +✅ Добавлено в список для отклика + +🎯 AI фильтрация завершена: + 🤖 Проанализировано: 15 + ✅ Рекомендовано: 8 + 📈 % одобрения: 53.3% +``` + +## 📊 Статистика работы + +После завершения работы бот предоставляет подробную статистику: + +``` +📊 ИТОГОВАЯ СТАТИСТИКА: +============================================================ +📝 Всего заявок: 25 +✅ Успешных: 18 +❌ Неудачных: 2 +⚠️ Уже откликались ранее: 5 +📈 Успешность: 72.0% +============================================================ + +🎉 Отлично! Отправлено 18 новых заявок! +💡 Рекомендуется запускать автоматизацию 2-3 раза в день +``` + +## 🔧 Разработка + +### Установка для разработки + +```bash +# Клонируем и устанавливаем в dev режиме +git clone https://github.com/your-username/hh-bot.git +cd hh-bot +pip install -e . + +# Устанавливаем dev зависимости +pip install pytest black flake8 +``` + +### Запуск тестов + +```bash +# Базовые тесты +python test_basic.py + +# Или через pytest +python -m pytest test_basic.py -v +``` + +### Проверка качества кода + +```bash +# Форматирование +python -m black hh_bot/ main.py + +# Линтер +python -m flake8 hh_bot/ main.py --max-line-length=100 +``` + +## 🛡️ Безопасность + +- **Случайные паузы** между действиями (3-6 секунд) +- **Имитация человеческого поведения** в браузере +- **Respect robots.txt** и ограничения API +- **Graceful degradation** при ошибках +- **Подробное логирование** для мониторинга + +## 📝 Получение Gemini API ключа + +1. Перейдите на [Google AI Studio](https://makersuite.google.com/) +2. Войдите в свой Google аккаунт +3. Создайте новый API ключ +4. Добавьте его в файл `.env` + +## ❓ FAQ + +**Q: Бот не находит вакансии** +A: Проверьте ключевые слова поиска и настройки фильтрации + +**Q: Gemini AI не работает** +A: Убедитесь что API ключ указан правильно в `.env` файле + +**Q: Браузер не открывается** +A: Установите Chrome браузер, драйвер установится автоматически + +**Q: Много ошибок в логах** +A: Проверьте подключение к интернету и доступность HH.ru + +## 📄 Лицензия + +**CC BY-NC-SA 4.0** - Некоммерческая лицензия с указанием авторства + +⚠️ **ВАЖНО**: Проект запрещено использовать в коммерческих целях! + +- ✅ Разрешено: Личное использование, обучение, исследования +- ❌ Запрещено: Продажа, коммерческие сервисы, монетизация + +Подробности в файле [LICENSE](LICENSE) + +## 🤝 Вклад в проект + +1. Форкните репозиторий +2. Создайте ветку для новой функции +3. Внесите изменения +4. Добавьте тесты +5. Создайте Pull Request + +## 📞 Поддержка + +- 🐛 **Баги**: [GitHub Issues](https://github.com/your-username/hh-bot/issues) +- 💡 **Предложения**: [GitHub Discussions](https://github.com/your-username/hh-bot/discussions) +- 📧 **Email**: your-email@example.com + +--- + +⭐ **Поставьте звезду, если проект был полезен!** diff --git a/config.example b/config.example new file mode 100644 index 0000000..73be7b5 --- /dev/null +++ b/config.example @@ -0,0 +1 @@ +GEMINI_API_KEY=your_gemini_api_key_here \ No newline at end of file diff --git a/hh_bot/__init__.py b/hh_bot/__init__.py new file mode 100644 index 0000000..22fa33b --- /dev/null +++ b/hh_bot/__init__.py @@ -0,0 +1,11 @@ +""" +🚀 HH.ru Автоматизация - Главный пакет +""" + +__version__ = "2.0.0" +__author__ = "HH Bot Team" + +from .core.job_application_manager import JobApplicationManager +from .config.settings import settings + +__all__ = ["JobApplicationManager", "settings"] diff --git a/hh_bot/__main__.py b/hh_bot/__main__.py new file mode 100644 index 0000000..960247f --- /dev/null +++ b/hh_bot/__main__.py @@ -0,0 +1,14 @@ +""" +🚀 HH.ru Автоматизация - Entry point для python -m hh_bot +""" + +from .cli import CLIInterface + + +def main(): + """Главная функция""" + CLIInterface.run_application() + + +if __name__ == "__main__": + main() diff --git a/hh_bot/cli/__init__.py b/hh_bot/cli/__init__.py new file mode 100644 index 0000000..138bb69 --- /dev/null +++ b/hh_bot/cli/__init__.py @@ -0,0 +1,7 @@ +""" +CLI модуль для HH.ru автоматизации +""" + +from .interface import CLIInterface + +__all__ = ["CLIInterface"] diff --git a/hh_bot/cli/interface.py b/hh_bot/cli/interface.py new file mode 100644 index 0000000..5d571c6 --- /dev/null +++ b/hh_bot/cli/interface.py @@ -0,0 +1,115 @@ +""" +🖥️ Интерфейс командной строки для HH.ru автоматизации +""" + +from ..core.job_application_manager import JobApplicationManager +from ..config.settings import settings, ResumeFileManager, UIFormatter + + +class CLIInterface: + """Интерфейс командной строки""" + + @staticmethod + def print_welcome(): + """Приветственное сообщение""" + print("🚀 HH.ru АВТОМАТИЗАЦИЯ v2.0") + print(UIFormatter.create_separator()) + print("🏗️ Архитектурно правильная версия") + print("🤖 С поддержкой Gemini AI") + print("📄 Загрузка резюме из файлов") + print(UIFormatter.create_separator()) + + @staticmethod + def print_settings_info(): + """Информация о настройках""" + print("\n⚙️ ТЕКУЩИЕ НАСТРОЙКИ:") + print(f"🔍 Ключевые слова: {settings.hh_search.keywords}") + print(f"📊 Максимум заявок: {settings.application.max_applications}") + print( + f"🤖 Gemini AI: " + f"{'✅ Доступен' if settings.enable_ai_matching() else '❌ Недоступен'}" + ) + print(f"🌐 Режим браузера: " f"{'Фоновый' if settings.browser.headless else 'Видимый'}") + + @staticmethod + def get_user_preferences(): + """Получение предпочтений пользователя""" + print("\n🎯 НАСТРОЙКА ПОИСКА:") + + keywords = input(f"Ключевые слова [{settings.hh_search.keywords}]: ").strip() + if not keywords: + keywords = settings.hh_search.keywords + + use_ai = True + if settings.enable_ai_matching(): + ai_choice = input("Использовать AI фильтрацию? [y/n]: ").lower() + use_ai = ai_choice != "n" + else: + print("⚠️ AI фильтрация недоступна (нет GEMINI_API_KEY)") + use_ai = False + + max_apps_input = input( + f"Максимум заявок [{settings.application.max_applications}]: " + ).strip() + try: + max_apps = ( + int(max_apps_input) if max_apps_input else settings.application.max_applications + ) + except ValueError: + max_apps = settings.application.max_applications + + return keywords, use_ai, max_apps + + @staticmethod + def print_final_stats(stats): + """Вывод итоговой статистики""" + UIFormatter.print_section_header("📊 ИТОГОВАЯ СТАТИСТИКА:", long=True) + + if "error" in stats: + print(f"❌ Ошибка: {stats['error']}") + else: + print(f"📝 Всего заявок: {stats['total_applications']}") + print(f"✅ Успешных: {stats['successful']}") + print(f"❌ Неудачных: {stats['failed']}") + + if stats["successful"] > 0: + print(f"\n🎉 Отлично! Отправлено {stats['successful']} заявок!") + else: + print("\n😕 Заявки не были отправлены") + + print(UIFormatter.create_separator(long=True)) + + @staticmethod + def run_application(): + """Главная функция запуска приложения""" + try: + cli = CLIInterface() + + cli.print_welcome() + ResumeFileManager.create_sample_files() + cli.print_settings_info() + keywords, use_ai, max_apps = cli.get_user_preferences() + + settings.update_search_keywords(keywords) + settings.application.max_applications = max_apps + + print("\n🎯 ЗАПУСК С ПАРАМЕТРАМИ:") + print(f"🔍 Поиск: {keywords}") + print(f"🤖 AI: {'Включен' if use_ai else 'Отключен'}") + print(f"📊 Максимум заявок: {max_apps}") + + confirm = input("\nНачать автоматизацию? [y/n]: ").lower() + if confirm != "y": + print("❌ Отменено пользователем") + return + + manager = JobApplicationManager() + stats = manager.run_automation(keywords=keywords, use_ai=use_ai) + cli.print_final_stats(stats) + + except KeyboardInterrupt: + print("\n⏹️ Программа остановлена пользователем") + except Exception as e: + print(f"\n❌ Неожиданная ошибка: {e}") + finally: + print("\n👋 До свидания!") diff --git a/hh_bot/config/__init__.py b/hh_bot/config/__init__.py new file mode 100644 index 0000000..85cb932 --- /dev/null +++ b/hh_bot/config/__init__.py @@ -0,0 +1,3 @@ +""" +⚙️ Пакет конфигурации +""" diff --git a/hh_bot/config/logging_config.py b/hh_bot/config/logging_config.py new file mode 100644 index 0000000..bcd6a39 --- /dev/null +++ b/hh_bot/config/logging_config.py @@ -0,0 +1,66 @@ +""" +📝 Конфигурация логирования для HH.ru автоматизации +""" + +import logging +import logging.handlers +from pathlib import Path +from typing import Optional +from .settings import AppConstants + + +class LoggingConfigurator: + """Конфигуратор системы логирования""" + + @staticmethod + def setup_logging( + log_level: int = logging.INFO, log_file: Optional[str] = None, console_output: bool = True + ) -> None: + """ + Настройка системы логирования + + Args: + log_level: Уровень логирования + log_file: Файл для записи логов (опционально) + console_output: Выводить ли логи в консоль + """ + + root_logger = logging.getLogger() + root_logger.setLevel(log_level) + + root_logger.handlers.clear() + + formatter = logging.Formatter( + fmt="%(asctime)s - %(name)s - %(levelname)s - %(message)s", datefmt="%Y-%m-%d %H:%M:%S" + ) + + if console_output: + console_handler = logging.StreamHandler() + console_handler.setLevel(log_level) + console_handler.setFormatter(formatter) + root_logger.addHandler(console_handler) + + if log_file: + log_path = Path(log_file) + log_path.parent.mkdir(parents=True, exist_ok=True) + + file_handler = logging.handlers.RotatingFileHandler( + log_file, + maxBytes=AppConstants.LOG_FILE_MAX_SIZE_MB * 1024 * 1024, + backupCount=5, + encoding="utf-8", + ) + file_handler.setLevel(log_level) + file_handler.setFormatter(formatter) + root_logger.addHandler(file_handler) + + logging.getLogger("selenium").setLevel(logging.WARNING) + logging.getLogger("urllib3").setLevel(logging.WARNING) + logging.getLogger("requests").setLevel(logging.WARNING) + + logging.info("Логирование настроено") + + +def get_logger(name: str) -> logging.Logger: + """Получение логгера для модуля""" + return logging.getLogger(name) diff --git a/hh_bot/config/settings.py b/hh_bot/config/settings.py new file mode 100644 index 0000000..8acf071 --- /dev/null +++ b/hh_bot/config/settings.py @@ -0,0 +1,234 @@ +""" +⚙️ Конфигурация для HH.ru автоматизации +""" + +import os +from dataclasses import dataclass +from pathlib import Path + + +class AppConstants: + """Константы приложения""" + + HH_BASE_URL = "https://api.hh.ru" + HH_SITE_URL = "https://hh.ru" + GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta" + GEMINI_MODEL = "gemini-2.0-flash" + + DEFAULT_TIMEOUT = 30 + API_PAUSE_SECONDS = 0.5 + AI_REQUEST_PAUSE = 1 + + MAX_VACANCIES_PER_PAGE = 50 + MAX_SEARCH_PAGES = 5 + DEFAULT_MAX_APPLICATIONS = 40 + + DEFAULT_EXPERIENCE_FILE = "data/experience.txt" + DEFAULT_ABOUT_FILE = "data/about_me.txt" + DEFAULT_SKILLS_FILE = "data/skills.txt" + + DEFAULT_AI_THRESHOLD = 0.7 + MIN_AI_SCORE = 0.0 + MAX_AI_SCORE = 1.0 + + SHORT_SEPARATOR_LENGTH = 50 + LONG_SEPARATOR_LENGTH = 60 + SHORT_TEXT_LIMIT = 50 + MEDIUM_TEXT_LIMIT = 60 + + GEMINI_TEMPERATURE = 0.3 + GEMINI_MAX_OUTPUT_TOKENS = 1000 + + LOG_FILE_MAX_SIZE_MB = 10 + PERCENT_MULTIPLIER = 100 + + +@dataclass +class HHSearchConfig: + """Настройки поиска вакансий""" + + keywords: str = "python junior" + area: str = "1" + experience: str = "noExperience" + per_page: int = AppConstants.MAX_VACANCIES_PER_PAGE + max_pages: int = 3 + order_by: str = "publication_time" + + +@dataclass +class BrowserConfig: + """Настройки браузера""" + + headless: bool = False + wait_timeout: int = 15 + page_load_timeout: int = 30 + implicit_wait: int = 10 + + +@dataclass +class ApplicationConfig: + """Настройки подачи заявок""" + + max_applications: int = AppConstants.DEFAULT_MAX_APPLICATIONS + pause_min: float = 3.0 + pause_max: float = 6.0 + manual_login: bool = True + + +@dataclass +class GeminiConfig: + """Настройки Gemini AI""" + + api_key: str = "" + model: str = AppConstants.GEMINI_MODEL + base_url: str = AppConstants.GEMINI_BASE_URL + match_threshold: float = AppConstants.DEFAULT_AI_THRESHOLD + + +@dataclass +class ResumeConfig: + """Настройки резюме""" + + experience_file: str = AppConstants.DEFAULT_EXPERIENCE_FILE + about_me_file: str = AppConstants.DEFAULT_ABOUT_FILE + skills_file: str = AppConstants.DEFAULT_SKILLS_FILE + + +class ResumeFileManager: + """Менеджер для работы с файлами резюме""" + + @staticmethod + def create_sample_files() -> None: + """Создание примеров файлов резюме""" + data_dir = Path("data") + data_dir.mkdir(exist_ok=True) + + experience_file = data_dir / "experience.txt" + if not experience_file.exists(): + experience_file.write_text( + """ +Опыт работы: +- Изучаю Python уже 6 месяцев +- Прошел курсы по основам программирования +- Делал учебные проекты: калькулятор, игра в крестики-нолики +- Изучаю Django и Flask для веб-разработки +- Базовые знания SQL и работы с базами данных +- Знаком с Git для контроля версий +""".strip(), + encoding="utf-8", + ) + print(f"✅ Создан файл: {experience_file}") + + about_file = data_dir / "about_me.txt" + if not about_file.exists(): + about_file.write_text( + """ +О себе: +Начинающий Python разработчик с большим желанием учиться и развиваться. +Интересуюсь веб-разработкой и анализом данных. +Быстро обучаюсь, ответственно подхожу к работе. +Готов к стажировке или junior позиции для получения практического опыта. +Хочу работать в команде опытных разработчиков и вносить вклад в интересные проекты. +""".strip(), + encoding="utf-8", + ) + print(f"✅ Создан файл: {about_file}") + + skills_file = data_dir / "skills.txt" + if not skills_file.exists(): + skills_file.write_text( + """ +Технические навыки: +- Python (основы, ООП, модули) +- SQL (SELECT, JOIN, базовые запросы) +- Git (commit, push, pull, merge) +- HTML/CSS (базовые знания) +- Django (учебные проекты) +- Flask (микрофреймворк) +- PostgreSQL, SQLite +- Linux (базовые команды) +- VS Code, PyCharm +""".strip(), + encoding="utf-8", + ) + print(f"✅ Создан файл: {skills_file}") + + +class UIFormatter: + """Утилиты для форматирования пользовательского интерфейса""" + + @staticmethod + def create_separator(long: bool = False) -> str: + """Создание разделительной линии""" + length = AppConstants.LONG_SEPARATOR_LENGTH if long else AppConstants.SHORT_SEPARATOR_LENGTH + return "=" * length + + @staticmethod + def truncate_text(text: str, medium: bool = False) -> str: + """Обрезание текста до заданного лимита""" + limit = AppConstants.MEDIUM_TEXT_LIMIT if medium else AppConstants.SHORT_TEXT_LIMIT + return text[:limit] + + @staticmethod + def format_percentage(value: float, total: float) -> str: + """Форматирование процентного соотношения""" + if total <= 0: + return "0.0%" + percentage = (value / total) * AppConstants.PERCENT_MULTIPLIER + return f"{percentage:.1f}%" + + @staticmethod + def print_section_header(title: str, long: bool = False) -> None: + """Печать заголовка секции с разделителями""" + separator = UIFormatter.create_separator(long) + print(f"\n{separator}") + print(title) + print(separator) + + +class Settings: + """Главный класс настроек""" + + def __init__(self): + + self._load_env() + + self.hh_search = HHSearchConfig() + self.browser = BrowserConfig() + self.application = ApplicationConfig() + self.gemini = GeminiConfig(api_key=os.getenv("GEMINI_API_KEY", "")) + self.resume = ResumeConfig() + + self._validate_config() + + def _load_env(self) -> None: + """Загрузка переменных окружения""" + try: + from dotenv import load_dotenv + + load_dotenv() + except ImportError: + print("💡 Установите python-dotenv для работы с .env файлами") + + def _validate_config(self) -> None: + """Валидация настроек""" + if not self.gemini.api_key: + print("⚠️ GEMINI_API_KEY не установлен в переменных окружения") + + data_dir = Path("data") + data_dir.mkdir(exist_ok=True) + + logs_dir = Path("logs") + logs_dir.mkdir(exist_ok=True) + + def update_search_keywords(self, keywords: str) -> None: + """Обновление ключевых слов поиска""" + self.hh_search.keywords = keywords + print(f"🔄 Обновлены ключевые слова: {keywords}") + + def enable_ai_matching(self) -> bool: + """Проверяем можно ли использовать AI сравнение""" + return bool(self.gemini.api_key) + + +settings = Settings() diff --git a/hh_bot/core/__init__.py b/hh_bot/core/__init__.py new file mode 100644 index 0000000..71ce07a --- /dev/null +++ b/hh_bot/core/__init__.py @@ -0,0 +1,3 @@ +""" +🎯 Пакет основной бизнес-логики +""" diff --git a/hh_bot/core/job_application_manager.py b/hh_bot/core/job_application_manager.py new file mode 100644 index 0000000..7fdd4a3 --- /dev/null +++ b/hh_bot/core/job_application_manager.py @@ -0,0 +1,261 @@ +""" +🎯 Главный менеджер для автоматизации откликов на вакансии +""" + +import logging +from typing import List, Dict, Optional +import time + +from ..config.settings import settings, AppConstants, UIFormatter +from ..config.logging_config import LoggingConfigurator +from ..models.vacancy import Vacancy, ApplicationResult +from ..services.hh_api_service import HHApiService +from ..services.gemini_service import GeminiAIService +from ..services.browser_service import BrowserService + +logger = logging.getLogger(__name__) + + +class AutomationOrchestrator: + """Оркестратор процесса автоматизации""" + + def __init__(self): + self.api_service = HHApiService() + self.ai_service = GeminiAIService() + self.browser_service = BrowserService() + + def execute_automation_pipeline( + self, keywords: Optional[str] = None, use_ai: bool = True + ) -> Dict: + """Выполнение полного пайплайна автоматизации""" + try: + + vacancies = self._search_and_filter_vacancies(keywords) + if not vacancies: + return {"error": "Подходящие вакансии не найдены"} + + if use_ai and self.ai_service.is_available(): + vacancies = self._ai_filter_vacancies(vacancies) + if not vacancies: + return {"error": "После AI фильтрации не осталось подходящих вакансий"} + + if not self._initialize_browser_and_auth(): + return {"error": "Ошибка инициализации браузера или авторизации"} + + application_results = self._apply_to_vacancies(vacancies) + + return self._create_stats(application_results) + + except KeyboardInterrupt: + logger.info("Процесс остановлен пользователем") + return {"error": "Остановлено пользователем"} + except Exception as e: + logger.error(f"Критическая ошибка: {e}") + return {"error": str(e)} + finally: + self._cleanup() + + def _search_and_filter_vacancies(self, keywords: Optional[str] = None) -> List[Vacancy]: + """Поиск и базовая фильтрация вакансий""" + logger.info("🔍 ЭТАП 1: Поиск вакансий") + + try: + all_vacancies = self.api_service.search_vacancies(keywords) + if not all_vacancies: + logger.warning("Вакансии не найдены через API") + return [] + + suitable_vacancies = self.api_service.filter_suitable_vacancies(all_vacancies) + + self._log_search_results(all_vacancies, suitable_vacancies) + return suitable_vacancies + + except Exception as e: + logger.error(f"Ошибка поиска вакансий: {e}") + return [] + + def _ai_filter_vacancies(self, vacancies: List[Vacancy]) -> List[Vacancy]: + """AI фильтрация вакансий""" + logger.info("🤖 ЭТАП 2: AI анализ вакансий") + + ai_suitable = [] + total_count = len(vacancies) + + for i, vacancy in enumerate(vacancies, 1): + truncated_name = UIFormatter.truncate_text(vacancy.name) + logger.info(f"Анализ {i}/{total_count}: {truncated_name}...") + + try: + if self.ai_service.should_apply(vacancy): + ai_suitable.append(vacancy) + logger.info("✅ Добавлено в список для отклика") + else: + logger.info("❌ Не рекомендуется") + + if i < total_count: + time.sleep(AppConstants.AI_REQUEST_PAUSE) + + except Exception as e: + logger.error(f"Ошибка AI анализа: {e}") + ai_suitable.append(vacancy) + + self._log_ai_results(total_count, ai_suitable) + return ai_suitable + + def _initialize_browser_and_auth(self) -> bool: + """Инициализация браузера и авторизация""" + logger.info("🌐 ЭТАП 3: Инициализация браузера и авторизация") + + try: + if not self.browser_service.initialize(): + logger.error("Не удалось инициализировать браузер") + return False + + if not self.browser_service.authenticate_interactive(): + logger.error("Не удалось авторизоваться") + return False + + logger.info("✅ Браузер готов к работе") + return True + + except Exception as e: + logger.error(f"Ошибка инициализации: {e}") + return False + + def _apply_to_vacancies(self, vacancies: List[Vacancy]) -> List[ApplicationResult]: + """Подача заявок на вакансии""" + max_apps = settings.application.max_applications + vacancies_to_process = vacancies[:max_apps] + + logger.info(f"📨 ЭТАП 4: Подача заявок (максимум {max_apps})") + logger.info("💡 Между заявками добавляются паузы для безопасности") + + application_results = [] + + for i, vacancy in enumerate(vacancies_to_process, 1): + truncated_name = UIFormatter.truncate_text(vacancy.name, medium=True) + logger.info(f"Обработка {i}/{len(vacancies_to_process)}: {truncated_name}...") + + try: + result = self.browser_service.apply_to_vacancy(vacancy.alternate_url, vacancy.name) + application_results.append(result) + self._log_application_result(result) + + if i < len(vacancies_to_process): + self.browser_service.add_random_pause() + + except Exception as e: + logger.error(f"Неожиданная ошибка при подаче заявки: {e}") + error_result = ApplicationResult( + vacancy_id="", + vacancy_name=vacancy.name, + success=False, + error_message=str(e), + ) + application_results.append(error_result) + + return application_results + + def _log_search_results(self, all_vacancies: List[Vacancy], suitable: List[Vacancy]): + """Логирование результатов поиска""" + logger.info("📊 Результат базовой фильтрации:") + logger.info(f" 🔍 Всего: {len(all_vacancies)}") + logger.info(f" ✅ Подходящих: {len(suitable)}") + if len(all_vacancies) > 0: + percentage = UIFormatter.format_percentage(len(suitable), len(all_vacancies)) + logger.info(f" 📈 % соответствия: {percentage}") + + def _log_ai_results(self, total_analyzed: int, ai_suitable: List[Vacancy]): + """Логирование результатов AI анализа""" + logger.info("🎯 AI фильтрация завершена:") + logger.info(f" 🤖 Проанализировано: {total_analyzed}") + logger.info(f" ✅ Рекомендовано: {len(ai_suitable)}") + if total_analyzed > 0: + percentage = UIFormatter.format_percentage(len(ai_suitable), total_analyzed) + logger.info(f" 📈 % одобрения: {percentage}") + + def _log_application_result(self, result: ApplicationResult): + """Логирование результата подачи заявки""" + if result.success: + logger.info(" ✅ Заявка отправлена успешно") + elif result.already_applied: + logger.info(" ⚠️ Уже откликались ранее") + else: + logger.warning(f" ❌ Ошибка: {result.error_message}") + + def _create_stats(self, application_results: List[ApplicationResult]) -> Dict: + """Создание итоговой статистики""" + total_applications = len(application_results) + successful = sum(1 for r in application_results if r.success) + already_applied = sum(1 for r in application_results if r.already_applied) + failed = total_applications - successful - already_applied + + return { + "total_applications": total_applications, + "successful": successful, + "failed": failed, + "already_applied": already_applied, + } + + def _cleanup(self): + """Очистка ресурсов""" + logger.info("🔒 Закрытие браузера...") + self.browser_service.close() + + +class JobApplicationManager: + """Главный менеджер для управления процессом поиска и откликов на вакансии""" + + def __init__(self): + + LoggingConfigurator.setup_logging(log_file="logs/hh_bot.log", console_output=False) + + self.orchestrator = AutomationOrchestrator() + self.application_results: List[ApplicationResult] = [] + + def run_automation(self, keywords: Optional[str] = None, use_ai: bool = True) -> Dict: + """Запуск полного цикла автоматизации""" + print("🚀 Запуск автоматизации HH.ru") + print(UIFormatter.create_separator()) + + stats = self.orchestrator.execute_automation_pipeline(keywords, use_ai) + + if "error" not in stats: + + pass + + return stats + + def get_application_results(self) -> List[ApplicationResult]: + """Получение результатов подачи заявок""" + return self.application_results.copy() + + def print_detailed_report(self, stats: Dict) -> None: + """Детальный отчет о работе""" + UIFormatter.print_section_header("📊 ДЕТАЛЬНЫЙ ОТЧЕТ", long=True) + + if "error" in stats: + print(f"❌ Ошибка выполнения: {stats['error']}") + return + + print(f"📝 Всего попыток подачи заявок: {stats['total_applications']}") + print(f"✅ Успешно отправлено: {stats['successful']}") + print(f"⚠️ Уже откликались ранее: {stats['already_applied']}") + print(f"❌ Неудачных попыток: {stats['failed']}") + + if stats["total_applications"] > 0: + success_rate = UIFormatter.format_percentage( + stats["successful"], stats["total_applications"] + ) + print(f"📈 Успешность: {success_rate}") + + print(UIFormatter.create_separator(long=True)) + + if stats["successful"] > 0: + print(f"🎉 Отлично! Отправлено {stats['successful']} новых заявок!") + print("💡 Рекомендуется запускать автоматизацию 2-3 раза в день") + elif stats["already_applied"] > 0: + print("💡 На большинство подходящих вакансий уже подавали заявки") + else: + print("😕 Новые заявки не были отправлены") + print("💡 Попробуйте изменить ключевые слова поиска или настройки") diff --git a/hh_bot/models/__init__.py b/hh_bot/models/__init__.py new file mode 100644 index 0000000..5ba6fe7 --- /dev/null +++ b/hh_bot/models/__init__.py @@ -0,0 +1,3 @@ +""" +📋 Пакет моделей данных +""" diff --git a/hh_bot/models/vacancy.py b/hh_bot/models/vacancy.py new file mode 100644 index 0000000..339cdc5 --- /dev/null +++ b/hh_bot/models/vacancy.py @@ -0,0 +1,247 @@ +""" +📋 Модели данных для работы с вакансиями HH.ru +""" + +from dataclasses import dataclass, field +from typing import List, Dict, Optional, Any +import re + + +@dataclass +class Employer: + """Информация о работодателе""" + + id: str + name: str + url: Optional[str] = None + alternate_url: Optional[str] = None + logo_urls: Optional[Dict[str, str]] = None + vacancies_url: Optional[str] = None + trusted: bool = False + + +@dataclass +class Experience: + """Информация об опыте работы""" + + id: str + name: str + + +@dataclass +class Snippet: + """Краткая информация о вакансии""" + + requirement: Optional[str] = None + responsibility: Optional[str] = None + + +@dataclass +class Salary: + """Информация о зарплате""" + + from_value: Optional[int] = None + to_value: Optional[int] = None + currency: str = "RUR" + gross: bool = False + + +@dataclass +class Vacancy: + """Модель вакансии HH.ru""" + + id: str + name: str + alternate_url: str + employer: Employer + experience: Experience + snippet: Snippet + premium: bool = False + has_test: bool = False + response_letter_required: bool = False + archived: bool = False + apply_alternate_url: Optional[str] = None + + ai_match_score: Optional[float] = None + ai_match_reasons: List[str] = field(default_factory=list) + + salary: Optional[Salary] = None + + @classmethod + def from_api_response(cls, data: Dict[str, Any]) -> "Vacancy": + """Создание экземпляра из ответа API HH.ru""" + try: + + employer_data = data.get("employer", {}) + employer = Employer( + id=employer_data.get("id", ""), + name=employer_data.get("name", "Неизвестная компания"), + url=employer_data.get("url"), + alternate_url=employer_data.get("alternate_url"), + logo_urls=employer_data.get("logo_urls"), + vacancies_url=employer_data.get("vacancies_url"), + trusted=employer_data.get("trusted", False), + ) + + experience_data = data.get("experience", {}) + experience = Experience( + id=experience_data.get("id", "noExperience"), + name=experience_data.get("name", "Без опыта"), + ) + + snippet_data = data.get("snippet", {}) + snippet = Snippet( + requirement=snippet_data.get("requirement"), + responsibility=snippet_data.get("responsibility"), + ) + + salary = None + salary_data = data.get("salary") + if salary_data: + salary = Salary( + from_value=salary_data.get("from"), + to_value=salary_data.get("to"), + currency=salary_data.get("currency", "RUR"), + gross=salary_data.get("gross", False), + ) + + return cls( + id=data.get("id", ""), + name=data.get("name", "Без названия"), + alternate_url=data.get("alternate_url", ""), + employer=employer, + experience=experience, + snippet=snippet, + premium=data.get("premium", False), + has_test=data.get("has_test", False), + response_letter_required=data.get("response_letter_required", False), + archived=data.get("archived", False), + apply_alternate_url=data.get("apply_alternate_url"), + salary=salary, + ) + except Exception as e: + print(f"❌ Ошибка парсинга вакансии: {e}") + + return cls( + id=data.get("id", "unknown"), + name=data.get("name", "Ошибка загрузки"), + alternate_url=data.get("alternate_url", ""), + employer=Employer(id="", name="Неизвестно"), + experience=Experience(id="noExperience", name="Без опыта"), + snippet=Snippet(), + ) + + def has_python(self) -> bool: + """Проверка упоминания Python в вакансии""" + text_to_check = ( + f"{self.name} {self.snippet.requirement or ''} " f"{self.snippet.responsibility or ''}" + ) + python_patterns = [ + r"\bpython\b", + r"\bпайтон\b", + r"\bджанго\b", + r"\bflask\b", + r"\bfastapi\b", + r"\bpandas\b", + r"\bnumpy\b", + ] + + for pattern in python_patterns: + if re.search(pattern, text_to_check, re.IGNORECASE): + return True + return False + + def is_junior_level(self) -> bool: + """Проверка на junior уровень""" + junior_keywords = [ + "junior", + "джуниор", + "стажер", + "стажёр", + "начинающий", + "intern", + "trainee", + "entry", + "младший", + ] + + text_to_check = f"{self.name} {self.snippet.requirement or ''}" + + for keyword in junior_keywords: + if keyword.lower() in text_to_check.lower(): + return True + return False + + def get_salary_info(self) -> str: + """Получение информации о зарплате в читаемом виде""" + if not self.salary: + return "Зарплата не указана" + + from_val = self.salary.from_value + to_val = self.salary.to_value + currency = self.salary.currency + gross_suffix = " (до вычета налогов)" if self.salary.gross else " (на руки)" + + if from_val and to_val: + return f"{from_val:,} - {to_val:,} {currency}{gross_suffix}" + elif from_val: + return f"от {from_val:,} {currency}{gross_suffix}" + elif to_val: + return f"до {to_val:,} {currency}{gross_suffix}" + else: + return "Зарплата не указана" + + def get_full_text(self) -> str: + """Получение полного текста вакансии для анализа""" + text_parts = [ + self.name, + self.employer.name, + self.snippet.requirement or "", + self.snippet.responsibility or "", + self.experience.name, + ] + return " ".join(filter(None, text_parts)) + + +@dataclass +class ApplicationResult: + """Результат подачи заявки на вакансию""" + + vacancy_id: str + vacancy_name: str + success: bool + already_applied: bool = False + error_message: Optional[str] = None + timestamp: Optional[str] = None + + def __post_init__(self): + """Устанавливаем timestamp если не указан""" + if self.timestamp is None: + from datetime import datetime + + self.timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + +@dataclass +class SearchStats: + """Статистика поиска вакансий""" + + total_found: int = 0 + pages_processed: int = 0 + filtered_count: int = 0 + python_vacancies: int = 0 + junior_vacancies: int = 0 + with_salary: int = 0 + without_test: int = 0 + + def __str__(self) -> str: + return f""" +📊 Статистика поиска: + 📋 Всего найдено: {self.total_found} + 📄 Страниц обработано: {self.pages_processed} + ✅ Прошло фильтрацию: {self.filtered_count} + 🐍 Python вакансий: {self.python_vacancies} + 👶 Junior уровня: {self.junior_vacancies} + 💰 С указанной ЗП: {self.with_salary} + 📝 Без тестов: {self.without_test} +""" diff --git a/hh_bot/services/__init__.py b/hh_bot/services/__init__.py new file mode 100644 index 0000000..c7d1530 --- /dev/null +++ b/hh_bot/services/__init__.py @@ -0,0 +1,3 @@ +""" +🔧 Пакет сервисов +""" diff --git a/hh_bot/services/browser_service.py b/hh_bot/services/browser_service.py new file mode 100644 index 0000000..dbd5c4d --- /dev/null +++ b/hh_bot/services/browser_service.py @@ -0,0 +1,273 @@ +""" +🌐 Сервис для работы с браузером +""" + +import time +import random +import logging +from typing import Optional +from selenium import webdriver +from selenium.webdriver.common.by import By +from selenium.webdriver.support.ui import WebDriverWait +from selenium.webdriver.chrome.options import Options +from selenium.webdriver.chrome.service import Service +from selenium.common.exceptions import NoSuchElementException +from webdriver_manager.chrome import ChromeDriverManager + +from ..config.settings import settings, AppConstants, UIFormatter +from ..models.vacancy import ApplicationResult + +logger = logging.getLogger(__name__) + + +class BrowserInitializer: + """Отвечает за инициализацию браузера""" + + @staticmethod + def create_chrome_options(headless: bool) -> Options: + """Создание опций Chrome""" + options = Options() + + if headless: + options.add_argument("--headless") + + options.add_argument("--no-sandbox") + options.add_argument("--disable-dev-shm-usage") + options.add_argument("--disable-blink-features=AutomationControlled") + + return options + + @staticmethod + def hide_automation(driver: webdriver.Chrome) -> None: + """Скрытие признаков автоматизации""" + try: + driver.execute_script( + """ + Object.defineProperty(navigator, 'webdriver', { + get: () => undefined + }); + """ + ) + except Exception as e: + logger.warning(f"Не удалось скрыть признаки автоматизации: {e}") + + +class AuthenticationHandler: + """Отвечает за процесс авторизации""" + + LOGIN_URL = f"{AppConstants.HH_SITE_URL}/account/login" + + def __init__(self, driver: webdriver.Chrome): + self.driver = driver + + def authenticate_interactive(self) -> bool: + """Интерактивная авторизация на HH.ru""" + try: + logger.info("Переход на страницу авторизации...") + self.driver.get(self.LOGIN_URL) + + print("\n🔐 РЕЖИМ РУЧНОЙ АВТОРИЗАЦИИ") + print("1. Авторизуйтесь в браузере") + print("2. Нажмите Enter для продолжения") + + input("⏳ Авторизуйтесь и нажмите Enter...") + + if self._check_authentication(): + logger.info("Авторизация успешна!") + return True + else: + logger.error("Авторизация не завершена") + return False + + except Exception as e: + logger.error(f"Ошибка при авторизации: {e}") + return False + + def _check_authentication(self) -> bool: + """Проверка успешности авторизации""" + try: + current_url = self.driver.current_url + page_text = self.driver.page_source.lower() + + success_indicators = [ + "applicant" in current_url, + "account" in current_url and "login" not in current_url, + "мои резюме" in page_text, + ] + + return any(success_indicators) + except Exception as e: + logger.error(f"Ошибка проверки авторизации: {e}") + return False + + +class VacancyApplicator: + """Отвечает за подачу заявок на вакансии""" + + APPLY_SELECTORS = [ + '[data-qa="vacancy-response-link-top"]', + '[data-qa="vacancy-response-button"]', + '.bloko-button[data-qa*="response"]', + 'button[data-qa*="response"]', + ".vacancy-response-link", + 'a[href*="response"]', + ] + + ALREADY_APPLIED_INDICATORS = [ + "откликнулись", + "отклик отправлен", + "заявка отправлена", + "response sent", + "уже откликнулись", + "чат", + ] + + def __init__(self, driver: webdriver.Chrome): + self.driver = driver + + def apply_to_vacancy(self, vacancy_url: str, vacancy_name: str) -> ApplicationResult: + """Подача заявки на вакансию""" + try: + truncated_name = UIFormatter.truncate_text(vacancy_name) + logger.info(f"Переход к вакансии: {truncated_name}...") + self.driver.get(vacancy_url) + time.sleep(3) + + apply_button = self._find_apply_button() + if not apply_button: + return ApplicationResult( + vacancy_id="", + vacancy_name=vacancy_name, + success=False, + error_message="Кнопка отклика не найдена", + ) + + button_text = apply_button.text.lower() + if self._is_already_applied(button_text): + return ApplicationResult( + vacancy_id="", + vacancy_name=vacancy_name, + success=False, + already_applied=True, + error_message="Уже откликались на эту вакансию", + ) + + self.driver.execute_script("arguments[0].click();", apply_button) + time.sleep(2) + + logger.info("Кнопка отклика нажата") + return ApplicationResult(vacancy_id="", vacancy_name=vacancy_name, success=True) + + except Exception as e: + logger.error(f"Ошибка при подаче заявки: {e}") + return ApplicationResult( + vacancy_id="", + vacancy_name=vacancy_name, + success=False, + error_message=str(e), + ) + + def _find_apply_button(self): + """Поиск кнопки отклика""" + for selector in self.APPLY_SELECTORS: + try: + button = self.driver.find_element(By.CSS_SELECTOR, selector) + return button + except NoSuchElementException: + continue + return None + + def _is_already_applied(self, button_text: str) -> bool: + """Проверка, не откликались ли уже""" + return any(indicator in button_text for indicator in self.ALREADY_APPLIED_INDICATORS) + + +class BrowserService: + """Главный сервис для управления браузером и автоматизации""" + + def __init__(self): + self.driver: Optional[webdriver.Chrome] = None + self.wait: Optional[WebDriverWait] = None + self._is_authenticated = False + self.auth_handler: Optional[AuthenticationHandler] = None + self.applicator: Optional[VacancyApplicator] = None + + def initialize(self, headless: bool = None) -> bool: + """Инициализация браузера""" + if headless is None: + headless = settings.browser.headless + + try: + logger.info("Инициализация браузера...") + + options = BrowserInitializer.create_chrome_options(headless) + service = Service(ChromeDriverManager().install()) + + self.driver = webdriver.Chrome(service=service, options=options) + self.wait = WebDriverWait(self.driver, settings.browser.wait_timeout) + + self.auth_handler = AuthenticationHandler(self.driver) + self.applicator = VacancyApplicator(self.driver) + + BrowserInitializer.hide_automation(self.driver) + + logger.info("Браузер успешно инициализирован") + return True + + except Exception as e: + logger.error(f"Ошибка инициализации браузера: {e}") + return False + + def authenticate_interactive(self) -> bool: + """Интерактивная авторизация на HH.ru""" + if not self.driver or not self.auth_handler: + logger.error("Браузер не инициализирован") + return False + + success = self.auth_handler.authenticate_interactive() + if success: + self._is_authenticated = True + return success + + def apply_to_vacancy(self, vacancy_url: str, vacancy_name: str) -> ApplicationResult: + """Подача заявки на вакансию""" + if not self.is_ready(): + return ApplicationResult( + vacancy_id="", + vacancy_name=vacancy_name, + success=False, + error_message="Браузер не готов или нет авторизации", + ) + + return self.applicator.apply_to_vacancy(vacancy_url, vacancy_name) + + def add_random_pause(self) -> None: + """Случайная пауза между действиями""" + try: + pause_time = random.uniform( + settings.application.pause_min, settings.application.pause_max + ) + logger.info(f"Пауза {pause_time:.1f} сек...") + time.sleep(pause_time) + except Exception as e: + logger.warning(f"Ошибка паузы: {e}") + time.sleep(3) + + def close(self) -> None: + """Закрытие браузера""" + try: + if self.driver: + self.driver.quit() + logger.info("Браузер закрыт") + except Exception as e: + logger.warning(f"Ошибка при закрытии: {e}") + finally: + self.driver = None + self.wait = None + self._is_authenticated = False + self.auth_handler = None + self.applicator = None + + def is_ready(self) -> bool: + """Проверка готовности к работе""" + return self.driver is not None and self._is_authenticated and self.applicator is not None diff --git a/hh_bot/services/gemini_service.py b/hh_bot/services/gemini_service.py new file mode 100644 index 0000000..35c2d25 --- /dev/null +++ b/hh_bot/services/gemini_service.py @@ -0,0 +1,332 @@ +""" +🤖 Сервис для работы с Gemini AI +""" + +import json +import requests +import logging +from typing import Dict, Optional, Tuple, List +import traceback +from pathlib import Path + +from ..config.settings import settings, AppConstants +from ..models.vacancy import Vacancy + +logger = logging.getLogger(__name__) + + +class GeminiApiClient: + """Клиент для работы с Gemini API""" + + def __init__(self, api_key: str): + self.api_key = api_key + self.base_url = AppConstants.GEMINI_BASE_URL + self.model = AppConstants.GEMINI_MODEL + + def generate_content(self, prompt: str) -> Optional[Dict]: + """Генерация контента через Gemini API""" + url = f"{self.base_url}/models/{self.model}:generateContent" + + headers = {"Content-Type": "application/json"} + params = {"key": self.api_key} + + payload = { + "contents": [{"parts": [{"text": prompt}]}], + "generationConfig": { + "temperature": AppConstants.GEMINI_TEMPERATURE, + "maxOutputTokens": AppConstants.GEMINI_MAX_OUTPUT_TOKENS, + }, + } + + try: + logger.info("Отправка запроса к Gemini API") + response = requests.post( + url, + headers=headers, + params=params, + json=payload, + timeout=AppConstants.DEFAULT_TIMEOUT, + ) + + if response.status_code != 200: + logger.error(f"Ошибка API Gemini: {response.status_code}, {response.text}") + return None + + result = response.json() + return self._parse_response(result) + + except requests.RequestException as e: + logger.error(f"Ошибка сети при запросе к Gemini: {e}") + return None + except Exception as e: + logger.error(f"Неожиданная ошибка Gemini API: {e}") + return None + + def _parse_response(self, result: Dict) -> Optional[Dict]: + """Парсинг ответа от Gemini API""" + try: + if "candidates" not in result or not result["candidates"]: + logger.warning("Пустой ответ от Gemini") + return None + + content = result["candidates"][0]["content"]["parts"][0]["text"] + return self._extract_json_from_text(content) + + except (KeyError, IndexError) as e: + logger.error(f"Ошибка структуры ответа Gemini: {e}") + return None + + def _extract_json_from_text(self, content: str) -> Optional[Dict]: + """Извлечение JSON из текстового ответа""" + try: + json_start = content.find("{") + json_end = content.rfind("}") + 1 + + if json_start >= 0 and json_end > json_start: + json_str = content[json_start:json_end] + parsed_response = json.loads(json_str) + + score = parsed_response.get("match_score", 0) + logger.info(f"Gemini анализ завершен: {score}") + return parsed_response + else: + logger.error("JSON не найден в ответе Gemini") + return None + + except json.JSONDecodeError as e: + logger.error(f"Ошибка парсинга JSON от Gemini: {e}") + logger.debug(f"Контент: {content}") + return None + + +class ResumeDataLoader: + """Загрузчик данных резюме из файлов""" + + def __init__(self): + self._cache: Optional[Dict[str, str]] = None + + def load(self) -> Dict[str, str]: + """Загрузка данных резюме с кэшированием""" + if self._cache is not None: + return self._cache + + try: + resume_data = self._load_from_files() + self._cache = resume_data + return resume_data + except Exception as e: + logger.error(f"Ошибка загрузки файлов резюме: {e}") + return self._get_default_resume_data() + + def _load_from_files(self) -> Dict[str, str]: + """Загрузка из файлов""" + resume_data = {} + + file_mappings = { + "experience": settings.resume.experience_file, + "about_me": settings.resume.about_me_file, + "skills": settings.resume.skills_file, + } + + for key, relative_path in file_mappings.items(): + file_path = self._get_file_path(relative_path) + + if file_path.exists(): + resume_data[key] = file_path.read_text(encoding="utf-8") + logger.info(f"Загружен {key} из {file_path}") + else: + resume_data[key] = self._get_default_value(key) + logger.warning(f"Файл {file_path} не найден, используем заглушку") + + return resume_data + + def _get_file_path(self, relative_path: str) -> Path: + """Получение абсолютного пути к файлу""" + if Path(relative_path).is_absolute(): + return Path(relative_path) + return Path.cwd() / relative_path + + def _get_default_value(self, key: str) -> str: + """Получение значений по умолчанию""" + defaults = { + "experience": "Без опыта работы. Начинающий разработчик.", + "about_me": "Начинающий Python разработчик, изучающий программирование.", + "skills": "Python, SQL, Git, основы веб-разработки", + } + return defaults.get(key, "Не указано") + + def _get_default_resume_data(self) -> Dict[str, str]: + """Полный набор данных по умолчанию""" + return { + "experience": self._get_default_value("experience"), + "about_me": self._get_default_value("about_me"), + "skills": self._get_default_value("skills"), + } + + +class VacancyAnalyzer: + """Анализатор соответствия вакансий""" + + def __init__(self, api_client: GeminiApiClient, resume_loader: ResumeDataLoader): + self.api_client = api_client + self.resume_loader = resume_loader + self.match_threshold = settings.gemini.match_threshold + + def analyze(self, vacancy: Vacancy) -> Tuple[float, List[str]]: + """Анализ соответствия вакансии резюме""" + try: + resume_data = self.resume_loader.load() + prompt = self._create_prompt(vacancy, resume_data) + + response = self.api_client.generate_content(prompt) + + if response and "match_score" in response: + score = float(response["match_score"]) + reasons = response.get("match_reasons", ["AI анализ выполнен"]) + return self._validate_score(score), reasons + else: + logger.warning("Ошибка анализа Gemini, используем базовую фильтрацию") + return self._basic_analysis(vacancy) + + except Exception as e: + logger.error(f"Ошибка в анализе Gemini: {e}") + logger.debug(f"Traceback: {traceback.format_exc()}") + return self._basic_analysis(vacancy) + + def _create_prompt(self, vacancy: Vacancy, resume_data: Dict[str, str]) -> str: + """Создание промпта для анализа соответствия""" + prompt = f""" +Проанализируй соответствие между резюме кандидата и вакансией. +Верни ТОЛЬКО JSON с такой структурой: +{{ + "match_score": 0.85, + "match_reasons": ["причина1", "причина2"], + "recommendation": "стоит откликаться" +}} + +РЕЗЮМЕ КАНДИДАТА: +Опыт работы: {resume_data.get('experience', 'Не указан')} +О себе: {resume_data.get('about_me', 'Не указано')} +Навыки: {resume_data.get('skills', 'Не указаны')} + +ВАКАНСИЯ: +Название: {vacancy.name} +Компания: {vacancy.employer.name} +Требования: {vacancy.snippet.requirement or 'Не указаны'} +Обязанности: {vacancy.snippet.responsibility or 'Не указаны'} +Опыт: {vacancy.experience.name} + +Оцени соответствие от {AppConstants.MIN_AI_SCORE} до {AppConstants.MAX_AI_SCORE}, где: +- 0.0-0.3: Не подходит +- 0.4-0.6: Частично подходит +- 0.7-0.9: Хорошо подходит +- 0.9-1.0: Отлично подходит + +В match_reasons укажи конкретные причины оценки. +В recommendation: "стоит откликаться", "стоит подумать" или "не стоит откликаться". +""" + return prompt.strip() + + def _validate_score(self, score: float) -> float: + """Валидация оценки AI""" + return max(AppConstants.MIN_AI_SCORE, min(AppConstants.MAX_AI_SCORE, score)) + + def _basic_analysis(self, vacancy: Vacancy) -> Tuple[float, List[str]]: + """Базовый анализ без AI (фолбэк)""" + score = 0.0 + reasons = [] + + try: + + if vacancy.has_python(): + score += 0.4 + reasons.append("Содержит Python в требованиях") + else: + reasons.append("Не содержит Python в требованиях") + return 0.1, reasons + + if vacancy.is_junior_level(): + score += 0.3 + reasons.append("Подходящий уровень (junior)") + elif vacancy.experience.id in ["noExperience", "between1And3"]: + score += 0.2 + reasons.append("Приемлемый опыт работы") + + if not vacancy.has_test: + score += 0.1 + reasons.append("Без обязательного тестирования") + else: + reasons.append("Есть обязательное тестирование") + + if not vacancy.archived: + score += 0.1 + reasons.append("Актуальная вакансия") + + return min(score, 1.0), reasons + except Exception as e: + logger.error(f"Ошибка базового анализа: {e}") + return 0.0, ["Ошибка анализа"] + + def should_apply(self, vacancy: Vacancy) -> bool: + """Принятие решения о подаче заявки""" + try: + score, reasons = self.analyze(vacancy) + + vacancy.ai_match_score = score + vacancy.ai_match_reasons = reasons + + should_apply = score >= self.match_threshold + + if should_apply: + logger.info(f"Рекомендуется откликаться (score: {score:.2f})") + else: + logger.info(f"Не рекомендуется откликаться (score: {score:.2f})") + + return should_apply + except Exception as e: + logger.error(f"Ошибка в should_apply: {e}") + return False + + +class GeminiAIService: + """Главный сервис для анализа вакансий с помощью Gemini AI""" + + def __init__(self): + self.api_key = settings.gemini.api_key + + if self.api_key: + self.api_client = GeminiApiClient(self.api_key) + else: + self.api_client = None + + self.resume_loader = ResumeDataLoader() + + if self.api_client: + self.analyzer = VacancyAnalyzer(self.api_client, self.resume_loader) + else: + self.analyzer = None + + def is_available(self) -> bool: + """Проверка доступности сервиса""" + return bool(self.api_key) + + def load_resume_data(self) -> Dict[str, str]: + """Загрузка данных резюме из файлов""" + return self.resume_loader.load() + + def analyze_vacancy_match(self, vacancy: Vacancy) -> Tuple[float, List[str]]: + """Анализ соответствия вакансии резюме""" + if not self.is_available() or not self.analyzer: + logger.warning("Gemini API недоступен, используем базовую фильтрацию") + return VacancyAnalyzer(None, self.resume_loader)._basic_analysis(vacancy) + + return self.analyzer.analyze(vacancy) + + def should_apply(self, vacancy: Vacancy) -> bool: + """Принятие решения о подаче заявки""" + if not self.is_available() or not self.analyzer: + + score, _ = VacancyAnalyzer(None, self.resume_loader)._basic_analysis(vacancy) + return score >= settings.gemini.match_threshold + + return self.analyzer.should_apply(vacancy) diff --git a/hh_bot/services/hh_api_service.py b/hh_bot/services/hh_api_service.py new file mode 100644 index 0000000..7dd4645 --- /dev/null +++ b/hh_bot/services/hh_api_service.py @@ -0,0 +1,252 @@ +""" +🔍 Сервис для работы с API HH.ru +""" + +import requests +import time +from typing import List, Dict, Any, Optional +import traceback + +from ..config.settings import settings, AppConstants +from ..models.vacancy import Vacancy, SearchStats + + +class VacancySearcher: + """Отвечает только за поиск вакансий""" + + def __init__(self): + self.base_url = AppConstants.HH_BASE_URL + self.headers = {"User-Agent": "HH-Search-Bot/2.0 (job-search-automation)"} + + def search(self, keywords: Optional[str] = None) -> List[Vacancy]: + """Поиск вакансий через API""" + if keywords: + settings.update_search_keywords(keywords) + + print(f"🔍 Поиск вакансий: {settings.hh_search.keywords}") + all_vacancies = [] + + try: + for page in range(settings.hh_search.max_pages): + print(f"📄 Обработка страницы {page + 1}...") + + page_vacancies = self._fetch_page(page) + if not page_vacancies: + print(f"⚠️ Страница {page + 1} пуста, прекращаем поиск") + break + + all_vacancies.extend(page_vacancies) + print(f"📋 Найдено {len(page_vacancies)} вакансий на странице {page + 1}") + + time.sleep(AppConstants.API_PAUSE_SECONDS) + + print(f"\n📊 Всего найдено: {len(all_vacancies)} вакансий") + return all_vacancies + + except Exception as e: + print(f"❌ Ошибка поиска вакансий: {e}") + print(f"🔍 Traceback: {traceback.format_exc()}") + return [] + + def _fetch_page(self, page: int) -> List[Vacancy]: + """Получение одной страницы результатов""" + params = self._build_search_params(page) + + try: + response = requests.get( + f"{self.base_url}/vacancies", + params=params, + headers=self.headers, + timeout=AppConstants.DEFAULT_TIMEOUT, + ) + response.raise_for_status() + + data = response.json() + items = data.get("items", []) + + vacancies = [] + for item in items: + try: + vacancy = Vacancy.from_api_response(item) + vacancies.append(vacancy) + except Exception as e: + print(f"⚠️ Ошибка парсинга вакансии: {e}") + continue + + return vacancies + + except requests.RequestException as e: + print(f"❌ Ошибка запроса к API HH.ru: {e}") + return [] + except Exception as e: + print(f"❌ Неожиданная ошибка при получении страницы: {e}") + return [] + + def _build_search_params(self, page: int) -> Dict[str, str]: + """Построение параметров поиска""" + config = settings.hh_search + search_query = QueryBuilder.build_search_query(config.keywords) + + params = { + "text": search_query, + "area": config.area, + "experience": config.experience, + "per_page": str(config.per_page), + "page": str(page), + "order_by": config.order_by, + "employment": "full,part", + "schedule": "fullDay,remote,flexible", + "only_with_salary": "false", + } + + return params + + +class QueryBuilder: + """Отвечает за построение поисковых запросов""" + + @staticmethod + def build_search_query(keywords: str) -> str: + """Построение умного поискового запроса""" + base_queries = [ + keywords, + f"{keywords} junior", + f"{keywords} стажер", + f"{keywords} начинающий", + f"{keywords} без опыта", + ] + return " OR ".join(f"({query})" for query in base_queries) + + @staticmethod + def suggest_keywords(base_keyword: str = "python") -> List[str]: + """Предложения ключевых слов для поиска""" + return [ + f"{base_keyword} junior", + f"{base_keyword} стажер", + f"{base_keyword} django", + f"{base_keyword} flask", + f"{base_keyword} fastapi", + f"{base_keyword} web", + f"{base_keyword} backend", + f"{base_keyword} разработчик", + f"{base_keyword} developer", + f"{base_keyword} программист", + ] + + +class VacancyFilter: + """Отвечает за фильтрацию вакансий""" + + EXCLUDE_KEYWORDS = [ + "senior", + "lead", + "старший", + "ведущий", + "главный", + "team lead", + "tech lead", + "архитектор", + "head", + "руководитель", + "manager", + "director", + ] + + @staticmethod + def filter_suitable(vacancies: List[Vacancy]) -> List[Vacancy]: + """Фильтрация подходящих вакансий""" + suitable = [] + + for vacancy in vacancies: + if VacancyFilter._is_suitable_basic(vacancy): + suitable.append(vacancy) + + print(f"✅ После базовой фильтрации: {len(suitable)} подходящих вакансий") + return suitable + + @staticmethod + def _is_suitable_basic(vacancy: Vacancy) -> bool: + """Базовая проверка подходящести вакансии""" + + if not vacancy.has_python(): + print(f"❌ Пропускаем '{vacancy.name}' - нет Python") + return False + + text = vacancy.get_full_text().lower() + for exclude in VacancyFilter.EXCLUDE_KEYWORDS: + if exclude in text: + print(f"❌ Пропускаем '{vacancy.name}' - содержит '{exclude}'") + return False + + if vacancy.archived: + print(f"❌ Пропускаем '{vacancy.name}' - архивная") + return False + + print(f"✅ Подходящая вакансия: '{vacancy.name}'") + return True + + +class VacancyDetailsFetcher: + """Отвечает за получение детальной информации о вакансиях""" + + def __init__(self): + self.base_url = AppConstants.HH_BASE_URL + self.headers = {"User-Agent": "HH-Search-Bot/2.0 (job-search-automation)"} + + def get_details(self, vacancy_id: str) -> Optional[Dict[str, Any]]: + """Получение детальной информации о вакансии""" + try: + response = requests.get( + f"{self.base_url}/vacancies/{vacancy_id}", + headers=self.headers, + timeout=AppConstants.DEFAULT_TIMEOUT, + ) + response.raise_for_status() + return response.json() + + except requests.RequestException as e: + print(f"❌ Ошибка получения деталей вакансии {vacancy_id}: {e}") + return None + + +class HHApiService: + """Главный сервис для работы с API HH.ru""" + + def __init__(self): + self.searcher = VacancySearcher() + self.filter = VacancyFilter() + self.details_fetcher = VacancyDetailsFetcher() + self.stats = SearchStats() + + def search_vacancies(self, keywords: Optional[str] = None) -> List[Vacancy]: + """Поиск вакансий с фильтрацией""" + vacancies = self.searcher.search(keywords) + self.stats.total_found = len(vacancies) + return vacancies + + def filter_suitable_vacancies( + self, vacancies: List[Vacancy], use_basic_filter: bool = True + ) -> List[Vacancy]: + """Фильтрация подходящих вакансий""" + if not use_basic_filter: + return vacancies + + suitable = self.filter.filter_suitable(vacancies) + self.stats.filtered_count = len(suitable) + return suitable + + def get_vacancy_details(self, vacancy_id: str) -> Optional[Dict[str, Any]]: + """Получение детальной информации о вакансии""" + return self.details_fetcher.get_details(vacancy_id) + + def get_search_stats(self) -> SearchStats: + """Получение статистики поиска""" + return self.stats + + def reset_stats(self) -> None: + """Сброс статистики""" + self.stats = SearchStats() + + def suggest_keywords(self, base_keyword: str = "python") -> List[str]: + """Предложения ключевых слов для поиска""" + return QueryBuilder.suggest_keywords(base_keyword) diff --git a/main.py b/main.py new file mode 100644 index 0000000..624f8b4 --- /dev/null +++ b/main.py @@ -0,0 +1,14 @@ +""" +🚀 HH.ru Автоматизация - Точка входа для прямого запуска +""" + +from hh_bot.cli import CLIInterface + + +def main(): + """Главная функция""" + CLIInterface.run_application() + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ad27684 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +selenium>=4.15.0 +webdriver-manager>=4.0.0 +requests>=2.31.0 +python-dotenv>=1.0.0 +pytest>=7.4.0 +black>=23.9.0 +flake8>=6.1.0 \ No newline at end of file diff --git a/test_basic.py b/test_basic.py new file mode 100644 index 0000000..3bb12c6 --- /dev/null +++ b/test_basic.py @@ -0,0 +1,145 @@ +""" +🧪 Базовые тесты для HH.ru автоматизации +""" + +import pytest +from hh_bot.config.settings import Settings +from hh_bot.models.vacancy import Vacancy, Employer, Experience, Snippet +from hh_bot.services.gemini_service import GeminiAIService +from hh_bot.services.hh_api_service import HHApiService + + +class TestModels: + """Тесты моделей данных""" + + def test_vacancy_creation(self): + """Тест создания вакансии""" + employer = Employer(id="123", name="Test Company") + experience = Experience(id="noExperience", name="Без опыта") + snippet = Snippet(requirement="Python", responsibility="Программирование") + + vacancy = Vacancy( + id="test_id", + name="Python Developer", + alternate_url="https://test.url", + employer=employer, + experience=experience, + snippet=snippet, + ) + + assert vacancy.id == "test_id" + assert vacancy.name == "Python Developer" + assert vacancy.employer.name == "Test Company" + + def test_vacancy_has_python(self): + """Тест проверки Python в вакансии""" + employer = Employer(id="123", name="Test Company") + experience = Experience(id="noExperience", name="Без опыта") + snippet = Snippet(requirement="Python разработчик", responsibility="Кодинг") + + vacancy = Vacancy( + id="test_id", + name="Python Developer", + alternate_url="https://test.url", + employer=employer, + experience=experience, + snippet=snippet, + ) + + assert vacancy.has_python() is True + + def test_vacancy_is_junior_level(self): + """Тест проверки junior уровня""" + employer = Employer(id="123", name="Test Company") + experience = Experience(id="noExperience", name="Без опыта") + snippet = Snippet(requirement="Junior Python Developer") + + vacancy = Vacancy( + id="test_id", + name="Junior Python Developer", + alternate_url="https://test.url", + employer=employer, + experience=experience, + snippet=snippet, + ) + + assert vacancy.is_junior_level() is True + + +class TestSettings: + """Тесты настроек""" + + def test_settings_creation(self): + """Тест создания настроек""" + settings = Settings() + + assert settings.hh_search.keywords == "python junior" + assert settings.application.max_applications == 40 + assert settings.browser.headless is False + + def test_update_keywords(self): + """Тест обновления ключевых слов""" + settings = Settings() + settings.update_search_keywords("django developer") + + assert settings.hh_search.keywords == "django developer" + + +class TestServices: + """Тесты сервисов""" + + def test_gemini_service_creation(self): + """Тест создания Gemini сервиса""" + service = GeminiAIService() + + assert service.model == "gemini-2.0-flash" + assert service.match_threshold == 0.7 + + def test_hh_api_service_creation(self): + """Тест создания HH API сервиса""" + service = HHApiService() + + assert service.base_url == "https://api.hh.ru" + assert service is not None + + def test_gemini_basic_analysis(self): + """Тест базового анализа Gemini""" + service = GeminiAIService() + + employer = Employer(id="123", name="Test Company") + experience = Experience(id="noExperience", name="Без опыта") + snippet = Snippet(requirement="Python", responsibility="Программирование") + + vacancy = Vacancy( + id="test_id", + name="Python Developer", + alternate_url="https://test.url", + employer=employer, + experience=experience, + snippet=snippet, + ) + + score, reasons = service._basic_analysis(vacancy) + assert isinstance(score, float) + assert 0.0 <= score <= 1.0 + assert isinstance(reasons, list) + assert len(reasons) > 0 + + +def test_imports(): + """Тест что все импорты работают""" + from hh_bot.config.settings import settings + from hh_bot.services.gemini_service import GeminiAIService + from hh_bot.services.hh_api_service import HHApiService + from hh_bot.services.browser_service import BrowserService + from hh_bot.core.job_application_manager import JobApplicationManager + + assert settings is not None + assert GeminiAIService() is not None + assert HHApiService() is not None + assert BrowserService() is not None + assert JobApplicationManager() is not None + + +if __name__ == "__main__": + pytest.main([__file__, "-v"])