Main commit v1

This commit is contained in:
itqop 2025-06-27 10:57:34 +03:00
parent 3a64d00bef
commit 4a8c758c58
22 changed files with 2492 additions and 0 deletions

196
.gitignore vendored Normal file
View File

@ -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
*~

35
LICENSE Normal file
View File

@ -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/
Для получения разрешения на коммерческое использование обращайтесь к авторам проекта.

270
README.md
View File

@ -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
---
⭐ **Поставьте звезду, если проект был полезен!**

1
config.example Normal file
View File

@ -0,0 +1 @@
GEMINI_API_KEY=your_gemini_api_key_here

11
hh_bot/__init__.py Normal file
View File

@ -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"]

14
hh_bot/__main__.py Normal file
View File

@ -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()

7
hh_bot/cli/__init__.py Normal file
View File

@ -0,0 +1,7 @@
"""
CLI модуль для HH.ru автоматизации
"""
from .interface import CLIInterface
__all__ = ["CLIInterface"]

115
hh_bot/cli/interface.py Normal file
View File

@ -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👋 До свидания!")

View File

@ -0,0 +1,3 @@
"""
Пакет конфигурации
"""

View File

@ -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)

234
hh_bot/config/settings.py Normal file
View File

@ -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()

3
hh_bot/core/__init__.py Normal file
View File

@ -0,0 +1,3 @@
"""
🎯 Пакет основной бизнес-логики
"""

View File

@ -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("💡 Попробуйте изменить ключевые слова поиска или настройки")

View File

@ -0,0 +1,3 @@
"""
📋 Пакет моделей данных
"""

247
hh_bot/models/vacancy.py Normal file
View File

@ -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}
"""

View File

@ -0,0 +1,3 @@
"""
🔧 Пакет сервисов
"""

View File

@ -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

View File

@ -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)

View File

@ -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)

14
main.py Normal file
View File

@ -0,0 +1,14 @@
"""
🚀 HH.ru Автоматизация - Точка входа для прямого запуска
"""
from hh_bot.cli import CLIInterface
def main():
"""Главная функция"""
CLIInterface.run_application()
if __name__ == "__main__":
main()

7
requirements.txt Normal file
View File

@ -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

145
test_basic.py Normal file
View File

@ -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"])