Main commit v1
This commit is contained in:
parent
3a64d00bef
commit
4a8c758c58
|
@ -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
|
||||||
|
*~
|
|
@ -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
270
README.md
|
@ -0,0 +1,270 @@
|
||||||
|
# 🤖 HH.ru Bot - Автоматизация поиска работы
|
||||||
|
|
||||||
|
> Современный бот для автоматического поиска и отклика на вакансии с поддержкой ИИ-анализа
|
||||||
|
|
||||||
|
[](https://www.python.org/downloads/)
|
||||||
|
[](https://creativecommons.org/licenses/by-nc-sa/4.0/)
|
||||||
|
[](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
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
⭐ **Поставьте звезду, если проект был полезен!**
|
|
@ -0,0 +1 @@
|
||||||
|
GEMINI_API_KEY=your_gemini_api_key_here
|
|
@ -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"]
|
|
@ -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()
|
|
@ -0,0 +1,7 @@
|
||||||
|
"""
|
||||||
|
CLI модуль для HH.ru автоматизации
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .interface import CLIInterface
|
||||||
|
|
||||||
|
__all__ = ["CLIInterface"]
|
|
@ -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👋 До свидания!")
|
|
@ -0,0 +1,3 @@
|
||||||
|
"""
|
||||||
|
⚙️ Пакет конфигурации
|
||||||
|
"""
|
|
@ -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)
|
|
@ -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()
|
|
@ -0,0 +1,3 @@
|
||||||
|
"""
|
||||||
|
🎯 Пакет основной бизнес-логики
|
||||||
|
"""
|
|
@ -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("💡 Попробуйте изменить ключевые слова поиска или настройки")
|
|
@ -0,0 +1,3 @@
|
||||||
|
"""
|
||||||
|
📋 Пакет моделей данных
|
||||||
|
"""
|
|
@ -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}
|
||||||
|
"""
|
|
@ -0,0 +1,3 @@
|
||||||
|
"""
|
||||||
|
🔧 Пакет сервисов
|
||||||
|
"""
|
|
@ -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
|
|
@ -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)
|
|
@ -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)
|
|
@ -0,0 +1,14 @@
|
||||||
|
"""
|
||||||
|
🚀 HH.ru Автоматизация - Точка входа для прямого запуска
|
||||||
|
"""
|
||||||
|
|
||||||
|
from hh_bot.cli import CLIInterface
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Главная функция"""
|
||||||
|
CLIInterface.run_application()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
|
@ -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
|
|
@ -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"])
|
Loading…
Reference in New Issue