feat: improved stability and job application logic
- Limit is now calculated based on successful applications - Updated modal window selectors for HH.ru - Added "skipped" status for problematic vacancies - Fixed search logic without auto-adding keywords - Improved logging and CLI settings
This commit is contained in:
parent
6ed9453b8e
commit
fd8da44b84
|
@ -1,7 +1,3 @@
|
||||||
"""
|
|
||||||
🚀 HH.ru Автоматизация - Главный пакет
|
|
||||||
"""
|
|
||||||
|
|
||||||
__version__ = "2.0.0"
|
__version__ = "2.0.0"
|
||||||
__author__ = "HH Bot Team"
|
__author__ = "HH Bot Team"
|
||||||
|
|
||||||
|
|
|
@ -1,12 +1,7 @@
|
||||||
"""
|
|
||||||
🚀 HH.ru Автоматизация - Entry point для python -m hh_bot
|
|
||||||
"""
|
|
||||||
|
|
||||||
from .cli import CLIInterface
|
from .cli import CLIInterface
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
"""Главная функция"""
|
|
||||||
CLIInterface.run_application()
|
CLIInterface.run_application()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,17 +1,11 @@
|
||||||
"""
|
|
||||||
🖥️ Интерфейс командной строки для HH.ru автоматизации
|
|
||||||
"""
|
|
||||||
|
|
||||||
from ..core.job_application_manager import JobApplicationManager
|
from ..core.job_application_manager import JobApplicationManager
|
||||||
from ..config.settings import settings, ResumeFileManager, UIFormatter
|
from ..config.settings import settings, ResumeFileManager, UIFormatter
|
||||||
|
|
||||||
|
|
||||||
class CLIInterface:
|
class CLIInterface:
|
||||||
"""Интерфейс командной строки"""
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def print_welcome():
|
def print_welcome():
|
||||||
"""Приветственное сообщение"""
|
|
||||||
print("🚀 HH.ru АВТОМАТИЗАЦИЯ v2.0")
|
print("🚀 HH.ru АВТОМАТИЗАЦИЯ v2.0")
|
||||||
print(UIFormatter.create_separator())
|
print(UIFormatter.create_separator())
|
||||||
print("🏗️ Архитектурно правильная версия")
|
print("🏗️ Архитектурно правильная версия")
|
||||||
|
@ -21,19 +15,16 @@ class CLIInterface:
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def print_settings_info():
|
def print_settings_info():
|
||||||
"""Информация о настройках"""
|
|
||||||
print("\n⚙️ ТЕКУЩИЕ НАСТРОЙКИ:")
|
print("\n⚙️ ТЕКУЩИЕ НАСТРОЙКИ:")
|
||||||
print(f"🔍 Ключевые слова: {settings.hh_search.keywords}")
|
print(f"🔍 Ключевые слова: {settings.hh_search.keywords}")
|
||||||
print(f"📊 Максимум заявок: {settings.application.max_applications}")
|
print(f"📊 Максимум заявок: {settings.application.max_applications}")
|
||||||
print(
|
ai_status = "✅ Доступен" if settings.enable_ai_matching() else "❌ Недоступен"
|
||||||
f"🤖 Gemini AI: "
|
print(f"🤖 Gemini AI: {ai_status}")
|
||||||
f"{'✅ Доступен' if settings.enable_ai_matching() else '❌ Недоступен'}"
|
browser_mode = "Фоновый" if settings.browser.headless else "Видимый"
|
||||||
)
|
print(f"🌐 Режим браузера: {browser_mode}")
|
||||||
print(f"🌐 Режим браузера: " f"{'Фоновый' if settings.browser.headless else 'Видимый'}")
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_user_preferences():
|
def get_user_preferences():
|
||||||
"""Получение предпочтений пользователя"""
|
|
||||||
print("\n🎯 НАСТРОЙКА ПОИСКА:")
|
print("\n🎯 НАСТРОЙКА ПОИСКА:")
|
||||||
|
|
||||||
keywords = input(f"Ключевые слова [{settings.hh_search.keywords}]: ").strip()
|
keywords = input(f"Ключевые слова [{settings.hh_search.keywords}]: ").strip()
|
||||||
|
@ -48,21 +39,44 @@ class CLIInterface:
|
||||||
print("⚠️ AI фильтрация недоступна (нет GEMINI_API_KEY)")
|
print("⚠️ AI фильтрация недоступна (нет GEMINI_API_KEY)")
|
||||||
use_ai = False
|
use_ai = False
|
||||||
|
|
||||||
max_apps_input = input(
|
excludes = ", ".join(settings.get_exclude_keywords()[:5])
|
||||||
f"Максимум заявок [{settings.application.max_applications}]: "
|
print(f"\n🚫 Текущие исключения: {excludes}...")
|
||||||
).strip()
|
exclude_choice = input("Изменить список исключений? [y/n]: ").lower()
|
||||||
|
if exclude_choice == "y":
|
||||||
|
CLIInterface._configure_exclude_keywords()
|
||||||
|
|
||||||
|
prompt = f"Максимум заявок [{settings.application.max_applications}]: "
|
||||||
|
max_apps_input = input(prompt).strip()
|
||||||
try:
|
try:
|
||||||
max_apps = (
|
max_apps = (
|
||||||
int(max_apps_input) if max_apps_input else settings.application.max_applications
|
int(max_apps_input)
|
||||||
|
if max_apps_input
|
||||||
|
else settings.application.max_applications
|
||||||
)
|
)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
max_apps = settings.application.max_applications
|
max_apps = settings.application.max_applications
|
||||||
|
|
||||||
return keywords, use_ai, max_apps
|
return keywords, use_ai, max_apps
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _configure_exclude_keywords():
|
||||||
|
print("\n⚙️ НАСТРОЙКА ИСКЛЮЧЕНИЙ:")
|
||||||
|
print("Введите слова через запятую (или Enter для значений по умолчанию):")
|
||||||
|
current_excludes = ", ".join(settings.get_exclude_keywords())
|
||||||
|
print(f"Текущие: {current_excludes}")
|
||||||
|
|
||||||
|
new_excludes = input("Новые исключения: ").strip()
|
||||||
|
if new_excludes:
|
||||||
|
exclude_list = [
|
||||||
|
word.strip() for word in new_excludes.split(",") if word.strip()
|
||||||
|
]
|
||||||
|
settings.get_exclude_keywords = lambda: exclude_list
|
||||||
|
print(f"✅ Обновлены исключения: {exclude_list}")
|
||||||
|
else:
|
||||||
|
print("✅ Оставлены значения по умолчанию")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def print_final_stats(stats):
|
def print_final_stats(stats):
|
||||||
"""Вывод итоговой статистики"""
|
|
||||||
UIFormatter.print_section_header("📊 ИТОГОВАЯ СТАТИСТИКА:", long=True)
|
UIFormatter.print_section_header("📊 ИТОГОВАЯ СТАТИСТИКА:", long=True)
|
||||||
|
|
||||||
if "error" in stats:
|
if "error" in stats:
|
||||||
|
@ -71,6 +85,9 @@ class CLIInterface:
|
||||||
print(f"📝 Всего заявок: {stats['total_applications']}")
|
print(f"📝 Всего заявок: {stats['total_applications']}")
|
||||||
print(f"✅ Успешных: {stats['successful']}")
|
print(f"✅ Успешных: {stats['successful']}")
|
||||||
print(f"❌ Неудачных: {stats['failed']}")
|
print(f"❌ Неудачных: {stats['failed']}")
|
||||||
|
|
||||||
|
if "skipped" in stats and stats["skipped"] > 0:
|
||||||
|
print(f"⏭️ Пропущено: {stats['skipped']}")
|
||||||
|
|
||||||
if stats["successful"] > 0:
|
if stats["successful"] > 0:
|
||||||
print(f"\n🎉 Отлично! Отправлено {stats['successful']} заявок!")
|
print(f"\n🎉 Отлично! Отправлено {stats['successful']} заявок!")
|
||||||
|
@ -81,7 +98,6 @@ class CLIInterface:
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def run_application():
|
def run_application():
|
||||||
"""Главная функция запуска приложения"""
|
|
||||||
try:
|
try:
|
||||||
cli = CLIInterface()
|
cli = CLIInterface()
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,3 @@
|
||||||
"""
|
|
||||||
📝 Конфигурация логирования для HH.ru автоматизации
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import logging.handlers
|
import logging.handlers
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
@ -14,7 +10,9 @@ class LoggingConfigurator:
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def setup_logging(
|
def setup_logging(
|
||||||
log_level: int = logging.INFO, log_file: Optional[str] = None, console_output: bool = True
|
log_level: int = logging.INFO,
|
||||||
|
log_file: Optional[str] = None,
|
||||||
|
console_output: bool = True,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Настройка системы логирования
|
Настройка системы логирования
|
||||||
|
@ -31,7 +29,8 @@ class LoggingConfigurator:
|
||||||
root_logger.handlers.clear()
|
root_logger.handlers.clear()
|
||||||
|
|
||||||
formatter = logging.Formatter(
|
formatter = logging.Formatter(
|
||||||
fmt="%(asctime)s - %(name)s - %(levelname)s - %(message)s", datefmt="%Y-%m-%d %H:%M:%S"
|
fmt="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||||
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
)
|
)
|
||||||
|
|
||||||
if console_output:
|
if console_output:
|
||||||
|
|
|
@ -1,21 +1,16 @@
|
||||||
"""
|
|
||||||
⚙️ Конфигурация для HH.ru автоматизации
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
class AppConstants:
|
class AppConstants:
|
||||||
"""Константы приложения"""
|
|
||||||
|
|
||||||
HH_BASE_URL = "https://api.hh.ru"
|
HH_BASE_URL = "https://api.hh.ru"
|
||||||
HH_SITE_URL = "https://hh.ru"
|
HH_SITE_URL = "https://hh.ru"
|
||||||
GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
|
GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
|
||||||
GEMINI_MODEL = "gemini-2.0-flash"
|
GEMINI_MODEL = "gemini-2.0-flash"
|
||||||
|
|
||||||
DEFAULT_TIMEOUT = 30
|
DEFAULT_TIMEOUT = 20
|
||||||
API_PAUSE_SECONDS = 0.5
|
API_PAUSE_SECONDS = 0.5
|
||||||
AI_REQUEST_PAUSE = 1
|
AI_REQUEST_PAUSE = 1
|
||||||
|
|
||||||
|
@ -45,9 +40,8 @@ class AppConstants:
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class HHSearchConfig:
|
class HHSearchConfig:
|
||||||
"""Настройки поиска вакансий"""
|
|
||||||
|
|
||||||
keywords: str = "python junior"
|
keywords: str = "python"
|
||||||
area: str = "1"
|
area: str = "1"
|
||||||
experience: str = "noExperience"
|
experience: str = "noExperience"
|
||||||
per_page: int = AppConstants.MAX_VACANCIES_PER_PAGE
|
per_page: int = AppConstants.MAX_VACANCIES_PER_PAGE
|
||||||
|
@ -57,7 +51,6 @@ class HHSearchConfig:
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class BrowserConfig:
|
class BrowserConfig:
|
||||||
"""Настройки браузера"""
|
|
||||||
|
|
||||||
headless: bool = False
|
headless: bool = False
|
||||||
wait_timeout: int = 15
|
wait_timeout: int = 15
|
||||||
|
@ -67,7 +60,6 @@ class BrowserConfig:
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ApplicationConfig:
|
class ApplicationConfig:
|
||||||
"""Настройки подачи заявок"""
|
|
||||||
|
|
||||||
max_applications: int = AppConstants.DEFAULT_MAX_APPLICATIONS
|
max_applications: int = AppConstants.DEFAULT_MAX_APPLICATIONS
|
||||||
pause_min: float = 3.0
|
pause_min: float = 3.0
|
||||||
|
@ -77,7 +69,6 @@ class ApplicationConfig:
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class GeminiConfig:
|
class GeminiConfig:
|
||||||
"""Настройки Gemini AI"""
|
|
||||||
|
|
||||||
api_key: str = ""
|
api_key: str = ""
|
||||||
model: str = AppConstants.GEMINI_MODEL
|
model: str = AppConstants.GEMINI_MODEL
|
||||||
|
@ -87,7 +78,6 @@ class GeminiConfig:
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ResumeConfig:
|
class ResumeConfig:
|
||||||
"""Настройки резюме"""
|
|
||||||
|
|
||||||
experience_file: str = AppConstants.DEFAULT_EXPERIENCE_FILE
|
experience_file: str = AppConstants.DEFAULT_EXPERIENCE_FILE
|
||||||
about_me_file: str = AppConstants.DEFAULT_ABOUT_FILE
|
about_me_file: str = AppConstants.DEFAULT_ABOUT_FILE
|
||||||
|
@ -95,11 +85,9 @@ class ResumeConfig:
|
||||||
|
|
||||||
|
|
||||||
class ResumeFileManager:
|
class ResumeFileManager:
|
||||||
"""Менеджер для работы с файлами резюме"""
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def create_sample_files() -> None:
|
def create_sample_files() -> None:
|
||||||
"""Создание примеров файлов резюме"""
|
|
||||||
data_dir = Path("data")
|
data_dir = Path("data")
|
||||||
data_dir.mkdir(exist_ok=True)
|
data_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
@ -108,12 +96,7 @@ class ResumeFileManager:
|
||||||
experience_file.write_text(
|
experience_file.write_text(
|
||||||
"""
|
"""
|
||||||
Опыт работы:
|
Опыт работы:
|
||||||
- Изучаю Python уже 6 месяцев
|
- ноль
|
||||||
- Прошел курсы по основам программирования
|
|
||||||
- Делал учебные проекты: калькулятор, игра в крестики-нолики
|
|
||||||
- Изучаю Django и Flask для веб-разработки
|
|
||||||
- Базовые знания SQL и работы с базами данных
|
|
||||||
- Знаком с Git для контроля версий
|
|
||||||
""".strip(),
|
""".strip(),
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
@ -124,11 +107,7 @@ class ResumeFileManager:
|
||||||
about_file.write_text(
|
about_file.write_text(
|
||||||
"""
|
"""
|
||||||
О себе:
|
О себе:
|
||||||
Начинающий Python разработчик с большим желанием учиться и развиваться.
|
Котенок.
|
||||||
Интересуюсь веб-разработкой и анализом данных.
|
|
||||||
Быстро обучаюсь, ответственно подхожу к работе.
|
|
||||||
Готов к стажировке или junior позиции для получения практического опыта.
|
|
||||||
Хочу работать в команде опытных разработчиков и вносить вклад в интересные проекты.
|
|
||||||
""".strip(),
|
""".strip(),
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
@ -139,15 +118,7 @@ class ResumeFileManager:
|
||||||
skills_file.write_text(
|
skills_file.write_text(
|
||||||
"""
|
"""
|
||||||
Технические навыки:
|
Технические навыки:
|
||||||
- Python (основы, ООП, модули)
|
- Мяу
|
||||||
- SQL (SELECT, JOIN, базовые запросы)
|
|
||||||
- Git (commit, push, pull, merge)
|
|
||||||
- HTML/CSS (базовые знания)
|
|
||||||
- Django (учебные проекты)
|
|
||||||
- Flask (микрофреймворк)
|
|
||||||
- PostgreSQL, SQLite
|
|
||||||
- Linux (базовые команды)
|
|
||||||
- VS Code, PyCharm
|
|
||||||
""".strip(),
|
""".strip(),
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
@ -155,23 +126,25 @@ class ResumeFileManager:
|
||||||
|
|
||||||
|
|
||||||
class UIFormatter:
|
class UIFormatter:
|
||||||
"""Утилиты для форматирования пользовательского интерфейса"""
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def create_separator(long: bool = False) -> str:
|
def create_separator(long: bool = False) -> str:
|
||||||
"""Создание разделительной линии"""
|
length = (
|
||||||
length = AppConstants.LONG_SEPARATOR_LENGTH if long else AppConstants.SHORT_SEPARATOR_LENGTH
|
AppConstants.LONG_SEPARATOR_LENGTH
|
||||||
|
if long
|
||||||
|
else AppConstants.SHORT_SEPARATOR_LENGTH
|
||||||
|
)
|
||||||
return "=" * length
|
return "=" * length
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def truncate_text(text: str, medium: bool = False) -> str:
|
def truncate_text(text: str, medium: bool = False) -> str:
|
||||||
"""Обрезание текста до заданного лимита"""
|
limit = (
|
||||||
limit = AppConstants.MEDIUM_TEXT_LIMIT if medium else AppConstants.SHORT_TEXT_LIMIT
|
AppConstants.MEDIUM_TEXT_LIMIT if medium else AppConstants.SHORT_TEXT_LIMIT
|
||||||
|
)
|
||||||
return text[:limit]
|
return text[:limit]
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def format_percentage(value: float, total: float) -> str:
|
def format_percentage(value: float, total: float) -> str:
|
||||||
"""Форматирование процентного соотношения"""
|
|
||||||
if total <= 0:
|
if total <= 0:
|
||||||
return "0.0%"
|
return "0.0%"
|
||||||
percentage = (value / total) * AppConstants.PERCENT_MULTIPLIER
|
percentage = (value / total) * AppConstants.PERCENT_MULTIPLIER
|
||||||
|
@ -179,7 +152,6 @@ class UIFormatter:
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def print_section_header(title: str, long: bool = False) -> None:
|
def print_section_header(title: str, long: bool = False) -> None:
|
||||||
"""Печать заголовка секции с разделителями"""
|
|
||||||
separator = UIFormatter.create_separator(long)
|
separator = UIFormatter.create_separator(long)
|
||||||
print(f"\n{separator}")
|
print(f"\n{separator}")
|
||||||
print(title)
|
print(title)
|
||||||
|
@ -187,10 +159,8 @@ class UIFormatter:
|
||||||
|
|
||||||
|
|
||||||
class Settings:
|
class Settings:
|
||||||
"""Главный класс настроек"""
|
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
|
|
||||||
self._load_env()
|
self._load_env()
|
||||||
|
|
||||||
self.hh_search = HHSearchConfig()
|
self.hh_search = HHSearchConfig()
|
||||||
|
@ -202,7 +172,6 @@ class Settings:
|
||||||
self._validate_config()
|
self._validate_config()
|
||||||
|
|
||||||
def _load_env(self) -> None:
|
def _load_env(self) -> None:
|
||||||
"""Загрузка переменных окружения"""
|
|
||||||
try:
|
try:
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
@ -211,7 +180,6 @@ class Settings:
|
||||||
print("💡 Установите python-dotenv для работы с .env файлами")
|
print("💡 Установите python-dotenv для работы с .env файлами")
|
||||||
|
|
||||||
def _validate_config(self) -> None:
|
def _validate_config(self) -> None:
|
||||||
"""Валидация настроек"""
|
|
||||||
if not self.gemini.api_key:
|
if not self.gemini.api_key:
|
||||||
print("⚠️ GEMINI_API_KEY не установлен в переменных окружения")
|
print("⚠️ GEMINI_API_KEY не установлен в переменных окружения")
|
||||||
|
|
||||||
|
@ -222,13 +190,14 @@ class Settings:
|
||||||
logs_dir.mkdir(exist_ok=True)
|
logs_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
def update_search_keywords(self, keywords: str) -> None:
|
def update_search_keywords(self, keywords: str) -> None:
|
||||||
"""Обновление ключевых слов поиска"""
|
|
||||||
self.hh_search.keywords = keywords
|
self.hh_search.keywords = keywords
|
||||||
print(f"🔄 Обновлены ключевые слова: {keywords}")
|
print(f"🔄 Обновлены ключевые слова: {keywords}")
|
||||||
|
|
||||||
def enable_ai_matching(self) -> bool:
|
def enable_ai_matching(self) -> bool:
|
||||||
"""Проверяем можно ли использовать AI сравнение"""
|
|
||||||
return bool(self.gemini.api_key)
|
return bool(self.gemini.api_key)
|
||||||
|
|
||||||
|
def get_exclude_keywords(self) -> list:
|
||||||
|
return ['стажер', 'cv']
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|
|
@ -1,7 +1,3 @@
|
||||||
"""
|
|
||||||
🎯 Главный менеджер для автоматизации откликов на вакансии
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import List, Dict, Optional
|
from typing import List, Dict, Optional
|
||||||
import time
|
import time
|
||||||
|
@ -21,8 +17,13 @@ class AutomationOrchestrator:
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.api_service = HHApiService()
|
self.api_service = HHApiService()
|
||||||
self.ai_service = GeminiAIService()
|
|
||||||
self.browser_service = BrowserService()
|
self.browser_service = BrowserService()
|
||||||
|
self.ai_service = None
|
||||||
|
|
||||||
|
def _get_ai_service(self):
|
||||||
|
if self.ai_service is None:
|
||||||
|
self.ai_service = GeminiAIService()
|
||||||
|
return self.ai_service
|
||||||
|
|
||||||
def execute_automation_pipeline(
|
def execute_automation_pipeline(
|
||||||
self, keywords: Optional[str] = None, use_ai: bool = True
|
self, keywords: Optional[str] = None, use_ai: bool = True
|
||||||
|
@ -34,12 +35,13 @@ class AutomationOrchestrator:
|
||||||
if not vacancies:
|
if not vacancies:
|
||||||
return {"error": "Подходящие вакансии не найдены"}
|
return {"error": "Подходящие вакансии не найдены"}
|
||||||
|
|
||||||
if use_ai and self.ai_service.is_available():
|
if use_ai and self._get_ai_service().is_available():
|
||||||
vacancies = self._ai_filter_vacancies(vacancies)
|
vacancies = self._ai_filter_vacancies(vacancies[:3])
|
||||||
if not vacancies:
|
if not vacancies:
|
||||||
return {"error": "После AI фильтрации не осталось подходящих вакансий"}
|
return {"error": "После AI фильтрации подходящих вакансий нет"}
|
||||||
|
|
||||||
if not self._initialize_browser_and_auth():
|
init_ok = self._initialize_browser_and_auth()
|
||||||
|
if not init_ok:
|
||||||
return {"error": "Ошибка инициализации браузера или авторизации"}
|
return {"error": "Ошибка инициализации браузера или авторизации"}
|
||||||
|
|
||||||
application_results = self._apply_to_vacancies(vacancies)
|
application_results = self._apply_to_vacancies(vacancies)
|
||||||
|
@ -55,7 +57,9 @@ class AutomationOrchestrator:
|
||||||
finally:
|
finally:
|
||||||
self._cleanup()
|
self._cleanup()
|
||||||
|
|
||||||
def _search_and_filter_vacancies(self, keywords: Optional[str] = None) -> List[Vacancy]:
|
def _search_and_filter_vacancies(
|
||||||
|
self, keywords: Optional[str] = None
|
||||||
|
) -> List[Vacancy]:
|
||||||
"""Поиск и базовая фильтрация вакансий"""
|
"""Поиск и базовая фильтрация вакансий"""
|
||||||
logger.info("🔍 ЭТАП 1: Поиск вакансий")
|
logger.info("🔍 ЭТАП 1: Поиск вакансий")
|
||||||
|
|
||||||
|
@ -65,7 +69,9 @@ class AutomationOrchestrator:
|
||||||
logger.warning("Вакансии не найдены через API")
|
logger.warning("Вакансии не найдены через API")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
suitable_vacancies = self.api_service.filter_suitable_vacancies(all_vacancies)
|
suitable_vacancies = self.api_service.filter_suitable_vacancies(
|
||||||
|
all_vacancies, search_keywords=keywords or ""
|
||||||
|
)
|
||||||
|
|
||||||
self._log_search_results(all_vacancies, suitable_vacancies)
|
self._log_search_results(all_vacancies, suitable_vacancies)
|
||||||
return suitable_vacancies
|
return suitable_vacancies
|
||||||
|
@ -86,7 +92,7 @@ class AutomationOrchestrator:
|
||||||
logger.info(f"Анализ {i}/{total_count}: {truncated_name}...")
|
logger.info(f"Анализ {i}/{total_count}: {truncated_name}...")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if self.ai_service.should_apply(vacancy):
|
if self._get_ai_service().should_apply(vacancy):
|
||||||
ai_suitable.append(vacancy)
|
ai_suitable.append(vacancy)
|
||||||
logger.info("✅ Добавлено в список для отклика")
|
logger.info("✅ Добавлено в список для отклика")
|
||||||
else:
|
else:
|
||||||
|
@ -123,25 +129,39 @@ class AutomationOrchestrator:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _apply_to_vacancies(self, vacancies: List[Vacancy]) -> List[ApplicationResult]:
|
def _apply_to_vacancies(self, vacancies: List[Vacancy]) -> List[ApplicationResult]:
|
||||||
"""Подача заявок на вакансии"""
|
max_successful_apps = settings.application.max_applications
|
||||||
max_apps = settings.application.max_applications
|
|
||||||
vacancies_to_process = vacancies[:max_apps]
|
|
||||||
|
|
||||||
logger.info(f"📨 ЭТАП 4: Подача заявок (максимум {max_apps})")
|
logger.info(f"📨 ЭТАП 4: Подача заявок (максимум {max_successful_apps} успешных)")
|
||||||
logger.info("💡 Между заявками добавляются паузы для безопасности")
|
logger.info("💡 Между заявками добавляются паузы")
|
||||||
|
logger.info("💡 Лимит считается только по успешным заявкам")
|
||||||
|
|
||||||
application_results = []
|
application_results = []
|
||||||
|
successful_count = 0
|
||||||
|
processed_count = 0
|
||||||
|
|
||||||
for i, vacancy in enumerate(vacancies_to_process, 1):
|
for vacancy in vacancies:
|
||||||
|
if successful_count >= max_successful_apps:
|
||||||
|
logger.info(f"🎯 Достигнут лимит успешных заявок: {max_successful_apps}")
|
||||||
|
break
|
||||||
|
|
||||||
|
processed_count += 1
|
||||||
truncated_name = UIFormatter.truncate_text(vacancy.name, medium=True)
|
truncated_name = UIFormatter.truncate_text(vacancy.name, medium=True)
|
||||||
logger.info(f"Обработка {i}/{len(vacancies_to_process)}: {truncated_name}...")
|
logger.info(
|
||||||
|
f"Обработка {processed_count}: {truncated_name} (успешных: {successful_count}/{max_successful_apps})"
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = self.browser_service.apply_to_vacancy(vacancy.alternate_url, vacancy.name)
|
result = self.browser_service.apply_to_vacancy(
|
||||||
|
vacancy.alternate_url, vacancy.name
|
||||||
|
)
|
||||||
application_results.append(result)
|
application_results.append(result)
|
||||||
self._log_application_result(result)
|
self._log_application_result(result)
|
||||||
|
|
||||||
if i < len(vacancies_to_process):
|
if result.success:
|
||||||
|
successful_count += 1
|
||||||
|
logger.info(f" 🎉 Успешных заявок: {successful_count}/{max_successful_apps}")
|
||||||
|
|
||||||
|
if processed_count < len(vacancies) and successful_count < max_successful_apps:
|
||||||
self.browser_service.add_random_pause()
|
self.browser_service.add_random_pause()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
@ -154,15 +174,20 @@ class AutomationOrchestrator:
|
||||||
)
|
)
|
||||||
application_results.append(error_result)
|
application_results.append(error_result)
|
||||||
|
|
||||||
|
logger.info(f"🏁 Обработка завершена. Обработано вакансий: {processed_count}, успешных заявок: {successful_count}")
|
||||||
return application_results
|
return application_results
|
||||||
|
|
||||||
def _log_search_results(self, all_vacancies: List[Vacancy], suitable: List[Vacancy]):
|
def _log_search_results(
|
||||||
|
self, all_vacancies: List[Vacancy], suitable: List[Vacancy]
|
||||||
|
):
|
||||||
"""Логирование результатов поиска"""
|
"""Логирование результатов поиска"""
|
||||||
logger.info("📊 Результат базовой фильтрации:")
|
logger.info("📊 Результат базовой фильтрации:")
|
||||||
logger.info(f" 🔍 Всего: {len(all_vacancies)}")
|
logger.info(f" 🔍 Всего: {len(all_vacancies)}")
|
||||||
logger.info(f" ✅ Подходящих: {len(suitable)}")
|
logger.info(f" ✅ Подходящих: {len(suitable)}")
|
||||||
if len(all_vacancies) > 0:
|
if len(all_vacancies) > 0:
|
||||||
percentage = UIFormatter.format_percentage(len(suitable), len(all_vacancies))
|
percentage = UIFormatter.format_percentage(
|
||||||
|
len(suitable), len(all_vacancies)
|
||||||
|
)
|
||||||
logger.info(f" 📈 % соответствия: {percentage}")
|
logger.info(f" 📈 % соответствия: {percentage}")
|
||||||
|
|
||||||
def _log_ai_results(self, total_analyzed: int, ai_suitable: List[Vacancy]):
|
def _log_ai_results(self, total_analyzed: int, ai_suitable: List[Vacancy]):
|
||||||
|
@ -180,6 +205,8 @@ class AutomationOrchestrator:
|
||||||
logger.info(" ✅ Заявка отправлена успешно")
|
logger.info(" ✅ Заявка отправлена успешно")
|
||||||
elif result.already_applied:
|
elif result.already_applied:
|
||||||
logger.info(" ⚠️ Уже откликались ранее")
|
logger.info(" ⚠️ Уже откликались ранее")
|
||||||
|
elif result.skipped:
|
||||||
|
logger.warning(f" ⏭️ Пропущено: {result.error_message}")
|
||||||
else:
|
else:
|
||||||
logger.warning(f" ❌ Ошибка: {result.error_message}")
|
logger.warning(f" ❌ Ошибка: {result.error_message}")
|
||||||
|
|
||||||
|
@ -188,13 +215,15 @@ class AutomationOrchestrator:
|
||||||
total_applications = len(application_results)
|
total_applications = len(application_results)
|
||||||
successful = sum(1 for r in application_results if r.success)
|
successful = sum(1 for r in application_results if r.success)
|
||||||
already_applied = sum(1 for r in application_results if r.already_applied)
|
already_applied = sum(1 for r in application_results if r.already_applied)
|
||||||
failed = total_applications - successful - already_applied
|
skipped = sum(1 for r in application_results if r.skipped)
|
||||||
|
failed = total_applications - successful - already_applied - skipped
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"total_applications": total_applications,
|
"total_applications": total_applications,
|
||||||
"successful": successful,
|
"successful": successful,
|
||||||
"failed": failed,
|
"failed": failed,
|
||||||
"already_applied": already_applied,
|
"already_applied": already_applied,
|
||||||
|
"skipped": skipped,
|
||||||
}
|
}
|
||||||
|
|
||||||
def _cleanup(self):
|
def _cleanup(self):
|
||||||
|
@ -208,12 +237,16 @@ class JobApplicationManager:
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
|
|
||||||
LoggingConfigurator.setup_logging(log_file="logs/hh_bot.log", console_output=False)
|
LoggingConfigurator.setup_logging(
|
||||||
|
log_file="logs/hh_bot.log", console_output=True
|
||||||
|
)
|
||||||
|
|
||||||
self.orchestrator = AutomationOrchestrator()
|
self.orchestrator = AutomationOrchestrator()
|
||||||
self.application_results: List[ApplicationResult] = []
|
self.application_results: List[ApplicationResult] = []
|
||||||
|
|
||||||
def run_automation(self, keywords: Optional[str] = None, use_ai: bool = True) -> Dict:
|
def run_automation(
|
||||||
|
self, keywords: Optional[str] = None, use_ai: bool = True
|
||||||
|
) -> Dict:
|
||||||
"""Запуск полного цикла автоматизации"""
|
"""Запуск полного цикла автоматизации"""
|
||||||
print("🚀 Запуск автоматизации HH.ru")
|
print("🚀 Запуск автоматизации HH.ru")
|
||||||
print(UIFormatter.create_separator())
|
print(UIFormatter.create_separator())
|
||||||
|
@ -221,8 +254,7 @@ class JobApplicationManager:
|
||||||
stats = self.orchestrator.execute_automation_pipeline(keywords, use_ai)
|
stats = self.orchestrator.execute_automation_pipeline(keywords, use_ai)
|
||||||
|
|
||||||
if "error" not in stats:
|
if "error" not in stats:
|
||||||
|
self.application_results = []
|
||||||
pass
|
|
||||||
|
|
||||||
return stats
|
return stats
|
||||||
|
|
||||||
|
@ -241,6 +273,7 @@ class JobApplicationManager:
|
||||||
print(f"📝 Всего попыток подачи заявок: {stats['total_applications']}")
|
print(f"📝 Всего попыток подачи заявок: {stats['total_applications']}")
|
||||||
print(f"✅ Успешно отправлено: {stats['successful']}")
|
print(f"✅ Успешно отправлено: {stats['successful']}")
|
||||||
print(f"⚠️ Уже откликались ранее: {stats['already_applied']}")
|
print(f"⚠️ Уже откликались ранее: {stats['already_applied']}")
|
||||||
|
print(f"⏭️ Пропущено (тестовые/ошибки): {stats['skipped']}")
|
||||||
print(f"❌ Неудачных попыток: {stats['failed']}")
|
print(f"❌ Неудачных попыток: {stats['failed']}")
|
||||||
|
|
||||||
if stats["total_applications"] > 0:
|
if stats["total_applications"] > 0:
|
||||||
|
|
|
@ -1,7 +1,3 @@
|
||||||
"""
|
|
||||||
📋 Модели данных для работы с вакансиями HH.ru
|
|
||||||
"""
|
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import List, Dict, Optional, Any
|
from typing import List, Dict, Optional, Any
|
||||||
import re
|
import re
|
||||||
|
@ -9,7 +5,6 @@ import re
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Employer:
|
class Employer:
|
||||||
"""Информация о работодателе"""
|
|
||||||
|
|
||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
|
@ -22,7 +17,6 @@ class Employer:
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Experience:
|
class Experience:
|
||||||
"""Информация об опыте работы"""
|
|
||||||
|
|
||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
|
@ -30,7 +24,6 @@ class Experience:
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Snippet:
|
class Snippet:
|
||||||
"""Краткая информация о вакансии"""
|
|
||||||
|
|
||||||
requirement: Optional[str] = None
|
requirement: Optional[str] = None
|
||||||
responsibility: Optional[str] = None
|
responsibility: Optional[str] = None
|
||||||
|
@ -38,7 +31,6 @@ class Snippet:
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Salary:
|
class Salary:
|
||||||
"""Информация о зарплате"""
|
|
||||||
|
|
||||||
from_value: Optional[int] = None
|
from_value: Optional[int] = None
|
||||||
to_value: Optional[int] = None
|
to_value: Optional[int] = None
|
||||||
|
@ -48,7 +40,6 @@ class Salary:
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Vacancy:
|
class Vacancy:
|
||||||
"""Модель вакансии HH.ru"""
|
|
||||||
|
|
||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
|
@ -69,7 +60,6 @@ class Vacancy:
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_api_response(cls, data: Dict[str, Any]) -> "Vacancy":
|
def from_api_response(cls, data: Dict[str, Any]) -> "Vacancy":
|
||||||
"""Создание экземпляра из ответа API HH.ru"""
|
|
||||||
try:
|
try:
|
||||||
|
|
||||||
employer_data = data.get("employer", {})
|
employer_data = data.get("employer", {})
|
||||||
|
@ -132,9 +122,9 @@ class Vacancy:
|
||||||
)
|
)
|
||||||
|
|
||||||
def has_python(self) -> bool:
|
def has_python(self) -> bool:
|
||||||
"""Проверка упоминания Python в вакансии"""
|
|
||||||
text_to_check = (
|
text_to_check = (
|
||||||
f"{self.name} {self.snippet.requirement or ''} " f"{self.snippet.responsibility or ''}"
|
f"{self.name} {self.snippet.requirement or ''} "
|
||||||
|
f"{self.snippet.responsibility or ''}"
|
||||||
)
|
)
|
||||||
python_patterns = [
|
python_patterns = [
|
||||||
r"\bpython\b",
|
r"\bpython\b",
|
||||||
|
@ -151,8 +141,20 @@ class Vacancy:
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def matches_keywords(self, keywords: str) -> bool:
|
||||||
|
text_to_check = (
|
||||||
|
f"{self.name} {self.snippet.requirement or ''} "
|
||||||
|
f"{self.snippet.responsibility or ''}"
|
||||||
|
).lower()
|
||||||
|
|
||||||
|
search_terms = [term.strip().lower() for term in keywords.split()]
|
||||||
|
|
||||||
|
for term in search_terms:
|
||||||
|
if term in text_to_check:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
def is_junior_level(self) -> bool:
|
def is_junior_level(self) -> bool:
|
||||||
"""Проверка на junior уровень"""
|
|
||||||
junior_keywords = [
|
junior_keywords = [
|
||||||
"junior",
|
"junior",
|
||||||
"джуниор",
|
"джуниор",
|
||||||
|
@ -173,7 +175,6 @@ class Vacancy:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def get_salary_info(self) -> str:
|
def get_salary_info(self) -> str:
|
||||||
"""Получение информации о зарплате в читаемом виде"""
|
|
||||||
if not self.salary:
|
if not self.salary:
|
||||||
return "Зарплата не указана"
|
return "Зарплата не указана"
|
||||||
|
|
||||||
|
@ -192,7 +193,6 @@ class Vacancy:
|
||||||
return "Зарплата не указана"
|
return "Зарплата не указана"
|
||||||
|
|
||||||
def get_full_text(self) -> str:
|
def get_full_text(self) -> str:
|
||||||
"""Получение полного текста вакансии для анализа"""
|
|
||||||
text_parts = [
|
text_parts = [
|
||||||
self.name,
|
self.name,
|
||||||
self.employer.name,
|
self.employer.name,
|
||||||
|
@ -205,17 +205,16 @@ class Vacancy:
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ApplicationResult:
|
class ApplicationResult:
|
||||||
"""Результат подачи заявки на вакансию"""
|
|
||||||
|
|
||||||
vacancy_id: str
|
vacancy_id: str
|
||||||
vacancy_name: str
|
vacancy_name: str
|
||||||
success: bool
|
success: bool
|
||||||
already_applied: bool = False
|
already_applied: bool = False
|
||||||
|
skipped: bool = False
|
||||||
error_message: Optional[str] = None
|
error_message: Optional[str] = None
|
||||||
timestamp: Optional[str] = None
|
timestamp: Optional[str] = None
|
||||||
|
|
||||||
def __post_init__(self):
|
def __post_init__(self):
|
||||||
"""Устанавливаем timestamp если не указан"""
|
|
||||||
if self.timestamp is None:
|
if self.timestamp is None:
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
@ -224,7 +223,6 @@ class ApplicationResult:
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class SearchStats:
|
class SearchStats:
|
||||||
"""Статистика поиска вакансий"""
|
|
||||||
|
|
||||||
total_found: int = 0
|
total_found: int = 0
|
||||||
pages_processed: int = 0
|
pages_processed: int = 0
|
||||||
|
@ -235,13 +233,13 @@ class SearchStats:
|
||||||
without_test: int = 0
|
without_test: int = 0
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return f"""
|
return (
|
||||||
📊 Статистика поиска:
|
f"📊 Статистика поиска:\n"
|
||||||
📋 Всего найдено: {self.total_found}
|
f" 📋 Всего найдено: {self.total_found}\n"
|
||||||
📄 Страниц обработано: {self.pages_processed}
|
f" 📄 Страниц обработано: {self.pages_processed}\n"
|
||||||
✅ Прошло фильтрацию: {self.filtered_count}
|
f" ✅ Прошло фильтрацию: {self.filtered_count}\n"
|
||||||
🐍 Python вакансий: {self.python_vacancies}
|
f" 🐍 Python вакансий: {self.python_vacancies}\n"
|
||||||
👶 Junior уровня: {self.junior_vacancies}
|
f" 👶 Junior уровня: {self.junior_vacancies}\n"
|
||||||
💰 С указанной ЗП: {self.with_salary}
|
f" 💰 С указанной ЗП: {self.with_salary}\n"
|
||||||
📝 Без тестов: {self.without_test}
|
f" 📝 Без тестов: {self.without_test}"
|
||||||
"""
|
)
|
||||||
|
|
|
@ -1,3 +0,0 @@
|
||||||
"""
|
|
||||||
🔧 Пакет сервисов
|
|
||||||
"""
|
|
|
@ -1,14 +1,14 @@
|
||||||
"""
|
|
||||||
🌐 Сервис для работы с браузером
|
|
||||||
"""
|
|
||||||
|
|
||||||
import time
|
import time
|
||||||
import random
|
import random
|
||||||
import logging
|
import logging
|
||||||
|
import json
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
from pathlib import Path
|
||||||
|
from enum import Enum
|
||||||
from selenium import webdriver
|
from selenium import webdriver
|
||||||
from selenium.webdriver.common.by import By
|
from selenium.webdriver.common.by import By
|
||||||
from selenium.webdriver.support.ui import WebDriverWait
|
from selenium.webdriver.support.ui import WebDriverWait
|
||||||
|
from selenium.webdriver.support import expected_conditions as EC
|
||||||
from selenium.webdriver.chrome.options import Options
|
from selenium.webdriver.chrome.options import Options
|
||||||
from selenium.webdriver.chrome.service import Service
|
from selenium.webdriver.chrome.service import Service
|
||||||
from selenium.common.exceptions import NoSuchElementException
|
from selenium.common.exceptions import NoSuchElementException
|
||||||
|
@ -17,15 +17,97 @@ from webdriver_manager.chrome import ChromeDriverManager
|
||||||
from ..config.settings import settings, AppConstants, UIFormatter
|
from ..config.settings import settings, AppConstants, UIFormatter
|
||||||
from ..models.vacancy import ApplicationResult
|
from ..models.vacancy import ApplicationResult
|
||||||
|
|
||||||
|
class SubmissionResult(Enum):
|
||||||
|
SUCCESS = "success"
|
||||||
|
FAILED = "failed"
|
||||||
|
SKIPPED = "skipped"
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SessionManager:
|
||||||
|
"""Управление сессиями авторизации HH.ru"""
|
||||||
|
|
||||||
|
def __init__(self, driver: webdriver.Chrome):
|
||||||
|
self.driver = driver
|
||||||
|
self.session_dir = Path("session_data")
|
||||||
|
self.cookies_file = self.session_dir / "hh_cookies.json"
|
||||||
|
self.session_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
def save_session(self) -> bool:
|
||||||
|
"""Сохранение текущей сессии в файл"""
|
||||||
|
try:
|
||||||
|
cookies = self.driver.get_cookies()
|
||||||
|
session_data = {
|
||||||
|
"cookies": cookies,
|
||||||
|
"user_agent": self.driver.execute_script("return navigator.userAgent;"),
|
||||||
|
"timestamp": time.time(),
|
||||||
|
}
|
||||||
|
|
||||||
|
with open(self.cookies_file, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(session_data, f, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
logger.info(f"Сессия сохранена в {self.cookies_file}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка сохранения сессии: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def load_session(self) -> bool:
|
||||||
|
"""Загрузка сохраненной сессии"""
|
||||||
|
try:
|
||||||
|
if not self.cookies_file.exists():
|
||||||
|
logger.info("Файл сессии не найден")
|
||||||
|
return False
|
||||||
|
|
||||||
|
with open(self.cookies_file, 'r', encoding='utf-8') as f:
|
||||||
|
session_data = json.load(f)
|
||||||
|
|
||||||
|
if not self._is_session_valid(session_data):
|
||||||
|
logger.info("Сессия устарела или невалидна")
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.driver.get(AppConstants.HH_SITE_URL)
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
for cookie in session_data["cookies"]:
|
||||||
|
try:
|
||||||
|
self.driver.add_cookie(cookie)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Не удалось добавить cookie: {e}")
|
||||||
|
|
||||||
|
logger.info("Сессия загружена из файла")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка загрузки сессии: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def clear_session(self) -> None:
|
||||||
|
"""Удаление сохраненной сессии"""
|
||||||
|
try:
|
||||||
|
if self.cookies_file.exists():
|
||||||
|
self.cookies_file.unlink()
|
||||||
|
logger.info("Сессия удалена")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Ошибка удаления сессии: {e}")
|
||||||
|
|
||||||
|
def _is_session_valid(self, session_data: dict) -> bool:
|
||||||
|
"""Проверка валидности сессии по времени"""
|
||||||
|
try:
|
||||||
|
session_age = time.time() - session_data.get("timestamp", 0)
|
||||||
|
max_age = 7 * 24 * 3600 # 7 дней
|
||||||
|
return session_age < max_age
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
class BrowserInitializer:
|
class BrowserInitializer:
|
||||||
"""Отвечает за инициализацию браузера"""
|
"""Отвечает за инициализацию браузера"""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def create_chrome_options(headless: bool) -> Options:
|
def create_chrome_options(headless: bool) -> Options:
|
||||||
"""Создание опций Chrome"""
|
|
||||||
options = Options()
|
options = Options()
|
||||||
|
|
||||||
if headless:
|
if headless:
|
||||||
|
@ -34,6 +116,25 @@ class BrowserInitializer:
|
||||||
options.add_argument("--no-sandbox")
|
options.add_argument("--no-sandbox")
|
||||||
options.add_argument("--disable-dev-shm-usage")
|
options.add_argument("--disable-dev-shm-usage")
|
||||||
options.add_argument("--disable-blink-features=AutomationControlled")
|
options.add_argument("--disable-blink-features=AutomationControlled")
|
||||||
|
options.add_argument("--disable-gpu")
|
||||||
|
options.add_argument("--disable-features=VizDisplayCompositor")
|
||||||
|
options.add_argument("--disable-web-security")
|
||||||
|
options.add_argument("--disable-features=VoiceInteraction")
|
||||||
|
options.add_argument("--disable-speech-api")
|
||||||
|
options.add_argument("--disable-background-networking")
|
||||||
|
options.add_argument("--disable-background-timer-throttling")
|
||||||
|
options.add_argument("--disable-renderer-backgrounding")
|
||||||
|
options.add_argument("--disable-backgrounding-occluded-windows")
|
||||||
|
options.add_argument("--disable-client-side-phishing-detection")
|
||||||
|
options.add_argument("--disable-sync")
|
||||||
|
options.add_argument("--disable-translate")
|
||||||
|
options.add_argument("--disable-ipc-flooding-protection")
|
||||||
|
options.add_argument("--log-level=3")
|
||||||
|
|
||||||
|
options.add_experimental_option("excludeSwitches", ["enable-automation"])
|
||||||
|
options.add_experimental_option("useAutomationExtension", False)
|
||||||
|
options.add_experimental_option("excludeSwitches", ["enable-logging"])
|
||||||
|
options.add_experimental_option("excludeSwitches", ["disable-logging"])
|
||||||
|
|
||||||
return options
|
return options
|
||||||
|
|
||||||
|
@ -59,21 +160,45 @@ class AuthenticationHandler:
|
||||||
|
|
||||||
def __init__(self, driver: webdriver.Chrome):
|
def __init__(self, driver: webdriver.Chrome):
|
||||||
self.driver = driver
|
self.driver = driver
|
||||||
|
self.session_manager = SessionManager(driver)
|
||||||
|
|
||||||
def authenticate_interactive(self) -> bool:
|
def authenticate_interactive(self) -> bool:
|
||||||
"""Интерактивная авторизация на HH.ru"""
|
"""Интерактивная авторизация на HH.ru с поддержкой сохраненной сессии"""
|
||||||
try:
|
try:
|
||||||
logger.info("Переход на страницу авторизации...")
|
print("\n🔐 ПРОВЕРКА СОХРАНЕННОЙ СЕССИИ")
|
||||||
self.driver.get(self.LOGIN_URL)
|
logger.info("Проверяем сохраненную сессию...")
|
||||||
|
|
||||||
|
if self.session_manager.load_session():
|
||||||
|
self.driver.refresh()
|
||||||
|
time.sleep(3)
|
||||||
|
|
||||||
|
if self._check_authentication():
|
||||||
|
print("✅ Использована сохраненная сессия")
|
||||||
|
logger.info("Авторизация через сохраненную сессию успешна!")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print("❌ Сохраненная сессия недействительна")
|
||||||
|
logger.warning("Сохраненная сессия недействительна")
|
||||||
|
self.session_manager.clear_session()
|
||||||
|
|
||||||
print("\n🔐 РЕЖИМ РУЧНОЙ АВТОРИЗАЦИИ")
|
print("\n🔐 РЕЖИМ РУЧНОЙ АВТОРИЗАЦИИ")
|
||||||
print("1. Авторизуйтесь в браузере")
|
print("1. Авторизуйтесь в браузере")
|
||||||
print("2. Нажмите Enter для продолжения")
|
print("2. Нажмите Enter для продолжения")
|
||||||
|
print("3. Сессия будет сохранена для повторного использования")
|
||||||
|
|
||||||
|
logger.info("Переход на страницу авторизации...")
|
||||||
|
self.driver.get(self.LOGIN_URL)
|
||||||
|
|
||||||
input("⏳ Авторизуйтесь и нажмите Enter...")
|
input("⏳ Авторизуйтесь и нажмите Enter...")
|
||||||
|
|
||||||
if self._check_authentication():
|
if self._check_authentication():
|
||||||
logger.info("Авторизация успешна!")
|
logger.info("Авторизация успешна!")
|
||||||
|
|
||||||
|
if self.session_manager.save_session():
|
||||||
|
print("✅ Сессия сохранена для следующих запусков")
|
||||||
|
else:
|
||||||
|
print("⚠️ Не удалось сохранить сессию")
|
||||||
|
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
logger.error("Авторизация не завершена")
|
logger.error("Авторизация не завершена")
|
||||||
|
@ -125,7 +250,9 @@ class VacancyApplicator:
|
||||||
def __init__(self, driver: webdriver.Chrome):
|
def __init__(self, driver: webdriver.Chrome):
|
||||||
self.driver = driver
|
self.driver = driver
|
||||||
|
|
||||||
def apply_to_vacancy(self, vacancy_url: str, vacancy_name: str) -> ApplicationResult:
|
def apply_to_vacancy(
|
||||||
|
self, vacancy_url: str, vacancy_name: str
|
||||||
|
) -> ApplicationResult:
|
||||||
"""Подача заявки на вакансию"""
|
"""Подача заявки на вакансию"""
|
||||||
try:
|
try:
|
||||||
truncated_name = UIFormatter.truncate_text(vacancy_name)
|
truncated_name = UIFormatter.truncate_text(vacancy_name)
|
||||||
|
@ -155,8 +282,32 @@ class VacancyApplicator:
|
||||||
self.driver.execute_script("arguments[0].click();", apply_button)
|
self.driver.execute_script("arguments[0].click();", apply_button)
|
||||||
time.sleep(2)
|
time.sleep(2)
|
||||||
|
|
||||||
logger.info("Кнопка отклика нажата")
|
logger.info("Кнопка отклика нажата, ищем форму заявки...")
|
||||||
return ApplicationResult(vacancy_id="", vacancy_name=vacancy_name, success=True)
|
|
||||||
|
submit_result = self._submit_application_form()
|
||||||
|
|
||||||
|
if submit_result == SubmissionResult.SUCCESS:
|
||||||
|
logger.info("✅ Заявка успешно отправлена")
|
||||||
|
return ApplicationResult(
|
||||||
|
vacancy_id="", vacancy_name=vacancy_name, success=True
|
||||||
|
)
|
||||||
|
elif submit_result == SubmissionResult.SKIPPED:
|
||||||
|
logger.warning("⚠️ Вакансия пропущена (нет модального окна)")
|
||||||
|
return ApplicationResult(
|
||||||
|
vacancy_id="",
|
||||||
|
vacancy_name=vacancy_name,
|
||||||
|
success=False,
|
||||||
|
skipped=True,
|
||||||
|
error_message="Модальное окно не найдено (возможно тестовая вакансия)",
|
||||||
|
)
|
||||||
|
else: # FAILED
|
||||||
|
logger.warning("❌ Не удалось отправить заявку в модальном окне")
|
||||||
|
return ApplicationResult(
|
||||||
|
vacancy_id="",
|
||||||
|
vacancy_name=vacancy_name,
|
||||||
|
success=False,
|
||||||
|
error_message="Ошибка отправки в модальном окне",
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка при подаче заявки: {e}")
|
logger.error(f"Ошибка при подаче заявки: {e}")
|
||||||
|
@ -179,7 +330,158 @@ class VacancyApplicator:
|
||||||
|
|
||||||
def _is_already_applied(self, button_text: str) -> bool:
|
def _is_already_applied(self, button_text: str) -> bool:
|
||||||
"""Проверка, не откликались ли уже"""
|
"""Проверка, не откликались ли уже"""
|
||||||
return any(indicator in button_text for indicator in self.ALREADY_APPLIED_INDICATORS)
|
return any(
|
||||||
|
indicator in button_text for indicator in self.ALREADY_APPLIED_INDICATORS
|
||||||
|
)
|
||||||
|
|
||||||
|
def _submit_application_form(self) -> SubmissionResult:
|
||||||
|
"""Отправка заявки в модальном окне"""
|
||||||
|
try:
|
||||||
|
modal_selectors = [
|
||||||
|
'[data-qa="modal-overlay"]',
|
||||||
|
'.magritte-modal-overlay',
|
||||||
|
'[data-qa="modal"]',
|
||||||
|
'[data-qa="vacancy-response-popup"]',
|
||||||
|
'.vacancy-response-popup',
|
||||||
|
'.modal',
|
||||||
|
'.bloko-modal',
|
||||||
|
]
|
||||||
|
|
||||||
|
submit_selectors = [
|
||||||
|
'[data-qa="vacancy-response-submit-popup"]',
|
||||||
|
'button[form="RESPONSE_MODAL_FORM_ID"]',
|
||||||
|
'button[type="submit"][form="RESPONSE_MODAL_FORM_ID"]',
|
||||||
|
'[data-qa="vacancy-response-letter-submit"]',
|
||||||
|
'button[data-qa*="submit"]',
|
||||||
|
'button[type="submit"]',
|
||||||
|
'input[type="submit"]',
|
||||||
|
'.bloko-button[data-qa*="submit"]',
|
||||||
|
]
|
||||||
|
|
||||||
|
modal_found = False
|
||||||
|
for selector in modal_selectors:
|
||||||
|
try:
|
||||||
|
modal = WebDriverWait(self.driver, 5).until(
|
||||||
|
EC.presence_of_element_located((By.CSS_SELECTOR, selector))
|
||||||
|
)
|
||||||
|
if modal:
|
||||||
|
modal_found = True
|
||||||
|
logger.info(f"Модальное окно найдено: {selector}")
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not modal_found:
|
||||||
|
logger.warning("⚠️ Модальное окно не найдено - пропускаем вакансию (возможно тестовая или ошибка)")
|
||||||
|
return SubmissionResult.SKIPPED
|
||||||
|
|
||||||
|
form_selectors = [
|
||||||
|
'form[name="vacancy_response"]',
|
||||||
|
'form[id="RESPONSE_MODAL_FORM_ID"]',
|
||||||
|
'form[data-qa*="response"]',
|
||||||
|
]
|
||||||
|
|
||||||
|
form_found = False
|
||||||
|
for form_selector in form_selectors:
|
||||||
|
try:
|
||||||
|
form = self.driver.find_element(By.CSS_SELECTOR, form_selector)
|
||||||
|
if form:
|
||||||
|
form_found = True
|
||||||
|
logger.info(f"Форма отклика найдена: {form_selector}")
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not form_found:
|
||||||
|
logger.warning("Форма отклика не найдена в модальном окне - пропускаем")
|
||||||
|
return SubmissionResult.SKIPPED
|
||||||
|
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
for selector in submit_selectors:
|
||||||
|
try:
|
||||||
|
submit_button = WebDriverWait(self.driver, 3).until(
|
||||||
|
EC.element_to_be_clickable((By.CSS_SELECTOR, selector))
|
||||||
|
)
|
||||||
|
if submit_button:
|
||||||
|
logger.info(f"Нажимаем кнопку отправки: {submit_button.text.strip()}")
|
||||||
|
self.driver.execute_script("arguments[0].click();", submit_button)
|
||||||
|
time.sleep(3)
|
||||||
|
if self._check_success_message():
|
||||||
|
return SubmissionResult.SUCCESS
|
||||||
|
else:
|
||||||
|
return SubmissionResult.FAILED
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger.warning("Кнопка отправки в модальном окне не найдена")
|
||||||
|
return SubmissionResult.FAILED
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка в модальном окне: {e}")
|
||||||
|
return SubmissionResult.FAILED
|
||||||
|
|
||||||
|
def _check_success_message(self) -> bool:
|
||||||
|
"""Проверка успешной отправки заявки"""
|
||||||
|
try:
|
||||||
|
success_indicators = [
|
||||||
|
"отклик отправлен",
|
||||||
|
"заявка отправлена",
|
||||||
|
"успешно отправлено",
|
||||||
|
"спасибо за отклик",
|
||||||
|
"ваш отклик получен",
|
||||||
|
"response sent",
|
||||||
|
"отклик на вакансию отправлен",
|
||||||
|
"резюме отправлено",
|
||||||
|
"откликнулись на вакансию",
|
||||||
|
]
|
||||||
|
|
||||||
|
success_selectors = [
|
||||||
|
'[data-qa*="success"]',
|
||||||
|
'[data-qa*="sent"]',
|
||||||
|
'.success-message',
|
||||||
|
'.response-sent',
|
||||||
|
'[class*="success"]',
|
||||||
|
]
|
||||||
|
|
||||||
|
for selector in success_selectors:
|
||||||
|
try:
|
||||||
|
success_element = self.driver.find_element(By.CSS_SELECTOR, selector)
|
||||||
|
if success_element and success_element.is_displayed():
|
||||||
|
logger.info(f"Найден элемент успеха: {selector} - {success_element.text}")
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
page_text = self.driver.page_source.lower()
|
||||||
|
|
||||||
|
for indicator in success_indicators:
|
||||||
|
if indicator in page_text:
|
||||||
|
logger.info(f"Найдено подтверждение: '{indicator}'")
|
||||||
|
return True
|
||||||
|
|
||||||
|
current_url = self.driver.current_url
|
||||||
|
if "sent" in current_url or "success" in current_url or "response" in current_url:
|
||||||
|
logger.info(f"URL указывает на успешную отправку: {current_url}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
modal_disappeared = True
|
||||||
|
try:
|
||||||
|
self.driver.find_element(By.CSS_SELECTOR, '[data-qa="modal-overlay"]')
|
||||||
|
modal_disappeared = False
|
||||||
|
except NoSuchElementException:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if modal_disappeared:
|
||||||
|
logger.info("Модальное окно исчезло - возможно отклик отправлен")
|
||||||
|
return True
|
||||||
|
|
||||||
|
logger.info("Подтверждение отправки не найдено")
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка проверки успеха: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
class BrowserService:
|
class BrowserService:
|
||||||
|
@ -229,7 +531,9 @@ class BrowserService:
|
||||||
self._is_authenticated = True
|
self._is_authenticated = True
|
||||||
return success
|
return success
|
||||||
|
|
||||||
def apply_to_vacancy(self, vacancy_url: str, vacancy_name: str) -> ApplicationResult:
|
def apply_to_vacancy(
|
||||||
|
self, vacancy_url: str, vacancy_name: str
|
||||||
|
) -> ApplicationResult:
|
||||||
"""Подача заявки на вакансию"""
|
"""Подача заявки на вакансию"""
|
||||||
if not self.is_ready():
|
if not self.is_ready():
|
||||||
return ApplicationResult(
|
return ApplicationResult(
|
||||||
|
@ -270,4 +574,8 @@ class BrowserService:
|
||||||
|
|
||||||
def is_ready(self) -> bool:
|
def is_ready(self) -> bool:
|
||||||
"""Проверка готовности к работе"""
|
"""Проверка готовности к работе"""
|
||||||
return self.driver is not None and self._is_authenticated and self.applicator is not None
|
return (
|
||||||
|
self.driver is not None
|
||||||
|
and self._is_authenticated
|
||||||
|
and self.applicator is not None
|
||||||
|
)
|
||||||
|
|
|
@ -1,7 +1,3 @@
|
||||||
"""
|
|
||||||
🤖 Сервис для работы с Gemini AI
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import requests
|
import requests
|
||||||
import logging
|
import logging
|
||||||
|
@ -49,7 +45,9 @@ class GeminiApiClient:
|
||||||
)
|
)
|
||||||
|
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
logger.error(f"Ошибка API Gemini: {response.status_code}, {response.text}")
|
logger.error(
|
||||||
|
f"Ошибка API Gemini: {response.status_code}, {response.text}"
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
result = response.json()
|
result = response.json()
|
||||||
|
@ -185,7 +183,7 @@ class VacancyAnalyzer:
|
||||||
reasons = response.get("match_reasons", ["AI анализ выполнен"])
|
reasons = response.get("match_reasons", ["AI анализ выполнен"])
|
||||||
return self._validate_score(score), reasons
|
return self._validate_score(score), reasons
|
||||||
else:
|
else:
|
||||||
logger.warning("Ошибка анализа Gemini, используем базовую фильтрацию")
|
logger.error("Ошибка анализа Gemini, используем базовую фильтрацию")
|
||||||
return self._basic_analysis(vacancy)
|
return self._basic_analysis(vacancy)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
@ -326,7 +324,9 @@ class GeminiAIService:
|
||||||
"""Принятие решения о подаче заявки"""
|
"""Принятие решения о подаче заявки"""
|
||||||
if not self.is_available() or not self.analyzer:
|
if not self.is_available() or not self.analyzer:
|
||||||
|
|
||||||
score, _ = VacancyAnalyzer(None, self.resume_loader)._basic_analysis(vacancy)
|
score, _ = VacancyAnalyzer(None, self.resume_loader)._basic_analysis(
|
||||||
|
vacancy
|
||||||
|
)
|
||||||
return score >= settings.gemini.match_threshold
|
return score >= settings.gemini.match_threshold
|
||||||
|
|
||||||
return self.analyzer.should_apply(vacancy)
|
return self.analyzer.should_apply(vacancy)
|
||||||
|
|
|
@ -1,7 +1,3 @@
|
||||||
"""
|
|
||||||
🔍 Сервис для работы с API HH.ru
|
|
||||||
"""
|
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
import time
|
import time
|
||||||
from typing import List, Dict, Any, Optional
|
from typing import List, Dict, Any, Optional
|
||||||
|
@ -12,14 +8,12 @@ from ..models.vacancy import Vacancy, SearchStats
|
||||||
|
|
||||||
|
|
||||||
class VacancySearcher:
|
class VacancySearcher:
|
||||||
"""Отвечает только за поиск вакансий"""
|
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.base_url = AppConstants.HH_BASE_URL
|
self.base_url = AppConstants.HH_BASE_URL
|
||||||
self.headers = {"User-Agent": "HH-Search-Bot/2.0 (job-search-automation)"}
|
self.headers = {"User-Agent": "HH-Search-Bot/2.0 (job-search-automation)"}
|
||||||
|
|
||||||
def search(self, keywords: Optional[str] = None) -> List[Vacancy]:
|
def search(self, keywords: Optional[str] = None) -> List[Vacancy]:
|
||||||
"""Поиск вакансий через API"""
|
|
||||||
if keywords:
|
if keywords:
|
||||||
settings.update_search_keywords(keywords)
|
settings.update_search_keywords(keywords)
|
||||||
|
|
||||||
|
@ -36,7 +30,9 @@ class VacancySearcher:
|
||||||
break
|
break
|
||||||
|
|
||||||
all_vacancies.extend(page_vacancies)
|
all_vacancies.extend(page_vacancies)
|
||||||
print(f"📋 Найдено {len(page_vacancies)} вакансий на странице {page + 1}")
|
print(
|
||||||
|
f"📋 Найдено {len(page_vacancies)} вакансий на странице {page + 1}"
|
||||||
|
)
|
||||||
|
|
||||||
time.sleep(AppConstants.API_PAUSE_SECONDS)
|
time.sleep(AppConstants.API_PAUSE_SECONDS)
|
||||||
|
|
||||||
|
@ -49,7 +45,6 @@ class VacancySearcher:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def _fetch_page(self, page: int) -> List[Vacancy]:
|
def _fetch_page(self, page: int) -> List[Vacancy]:
|
||||||
"""Получение одной страницы результатов"""
|
|
||||||
params = self._build_search_params(page)
|
params = self._build_search_params(page)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
@ -83,97 +78,54 @@ class VacancySearcher:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def _build_search_params(self, page: int) -> Dict[str, str]:
|
def _build_search_params(self, page: int) -> Dict[str, str]:
|
||||||
"""Построение параметров поиска"""
|
|
||||||
config = settings.hh_search
|
config = settings.hh_search
|
||||||
search_query = QueryBuilder.build_search_query(config.keywords)
|
search_query = QueryBuilder.build_search_query(config.keywords)
|
||||||
|
|
||||||
params = {
|
params = {
|
||||||
"text": search_query,
|
"text": search_query,
|
||||||
"area": config.area,
|
"area": config.area,
|
||||||
"experience": config.experience,
|
"per_page": str(min(config.per_page, 20)),
|
||||||
"per_page": str(config.per_page),
|
|
||||||
"page": str(page),
|
"page": str(page),
|
||||||
"order_by": config.order_by,
|
"order_by": "publication_time",
|
||||||
"employment": "full,part",
|
|
||||||
"schedule": "fullDay,remote,flexible",
|
|
||||||
"only_with_salary": "false",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return params
|
return params
|
||||||
|
|
||||||
|
|
||||||
class QueryBuilder:
|
class QueryBuilder:
|
||||||
"""Отвечает за построение поисковых запросов"""
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def build_search_query(keywords: str) -> str:
|
def build_search_query(keywords: str) -> str:
|
||||||
"""Построение умного поискового запроса"""
|
return keywords
|
||||||
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:
|
class VacancyFilter:
|
||||||
"""Отвечает за фильтрацию вакансий"""
|
|
||||||
|
|
||||||
EXCLUDE_KEYWORDS = [
|
|
||||||
"senior",
|
|
||||||
"lead",
|
|
||||||
"старший",
|
|
||||||
"ведущий",
|
|
||||||
"главный",
|
|
||||||
"team lead",
|
|
||||||
"tech lead",
|
|
||||||
"архитектор",
|
|
||||||
"head",
|
|
||||||
"руководитель",
|
|
||||||
"manager",
|
|
||||||
"director",
|
|
||||||
]
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def filter_suitable(vacancies: List[Vacancy]) -> List[Vacancy]:
|
def filter_suitable(
|
||||||
"""Фильтрация подходящих вакансий"""
|
vacancies: List[Vacancy], search_keywords: str = ""
|
||||||
|
) -> List[Vacancy]:
|
||||||
suitable = []
|
suitable = []
|
||||||
|
|
||||||
for vacancy in vacancies:
|
for vacancy in vacancies:
|
||||||
if VacancyFilter._is_suitable_basic(vacancy):
|
if VacancyFilter._is_suitable_basic(vacancy, search_keywords):
|
||||||
suitable.append(vacancy)
|
suitable.append(vacancy)
|
||||||
|
|
||||||
print(f"✅ После базовой фильтрации: {len(suitable)} подходящих вакансий")
|
print(f"✅ После базовой фильтрации: {len(suitable)} подходящих вакансий")
|
||||||
return suitable
|
return suitable
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _is_suitable_basic(vacancy: Vacancy) -> bool:
|
def _is_suitable_basic(vacancy: Vacancy, search_keywords: str = "") -> bool:
|
||||||
"""Базовая проверка подходящести вакансии"""
|
if search_keywords and not vacancy.matches_keywords(search_keywords):
|
||||||
|
print(f"❌ Пропускаем '{vacancy.name}' - не соответствует ключевым словам")
|
||||||
|
return False
|
||||||
|
|
||||||
if not vacancy.has_python():
|
if not search_keywords and not vacancy.has_python():
|
||||||
print(f"❌ Пропускаем '{vacancy.name}' - нет Python")
|
print(f"❌ Пропускаем '{vacancy.name}' - нет Python")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
text = vacancy.get_full_text().lower()
|
text = vacancy.get_full_text().lower()
|
||||||
for exclude in VacancyFilter.EXCLUDE_KEYWORDS:
|
for exclude in settings.get_exclude_keywords():
|
||||||
if exclude in text:
|
if exclude in text:
|
||||||
print(f"❌ Пропускаем '{vacancy.name}' - содержит '{exclude}'")
|
print(f"❌ Пропускаем '{vacancy.name}' - содержит '{exclude}'")
|
||||||
return False
|
return False
|
||||||
|
@ -187,14 +139,12 @@ class VacancyFilter:
|
||||||
|
|
||||||
|
|
||||||
class VacancyDetailsFetcher:
|
class VacancyDetailsFetcher:
|
||||||
"""Отвечает за получение детальной информации о вакансиях"""
|
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.base_url = AppConstants.HH_BASE_URL
|
self.base_url = AppConstants.HH_BASE_URL
|
||||||
self.headers = {"User-Agent": "HH-Search-Bot/2.0 (job-search-automation)"}
|
self.headers = {"User-Agent": "HH-Search-Bot/2.0 (job-search-automation)"}
|
||||||
|
|
||||||
def get_details(self, vacancy_id: str) -> Optional[Dict[str, Any]]:
|
def get_details(self, vacancy_id: str) -> Optional[Dict[str, Any]]:
|
||||||
"""Получение детальной информации о вакансии"""
|
|
||||||
try:
|
try:
|
||||||
response = requests.get(
|
response = requests.get(
|
||||||
f"{self.base_url}/vacancies/{vacancy_id}",
|
f"{self.base_url}/vacancies/{vacancy_id}",
|
||||||
|
@ -210,7 +160,6 @@ class VacancyDetailsFetcher:
|
||||||
|
|
||||||
|
|
||||||
class HHApiService:
|
class HHApiService:
|
||||||
"""Главный сервис для работы с API HH.ru"""
|
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.searcher = VacancySearcher()
|
self.searcher = VacancySearcher()
|
||||||
|
@ -219,34 +168,28 @@ class HHApiService:
|
||||||
self.stats = SearchStats()
|
self.stats = SearchStats()
|
||||||
|
|
||||||
def search_vacancies(self, keywords: Optional[str] = None) -> List[Vacancy]:
|
def search_vacancies(self, keywords: Optional[str] = None) -> List[Vacancy]:
|
||||||
"""Поиск вакансий с фильтрацией"""
|
|
||||||
vacancies = self.searcher.search(keywords)
|
vacancies = self.searcher.search(keywords)
|
||||||
self.stats.total_found = len(vacancies)
|
self.stats.total_found = len(vacancies)
|
||||||
return vacancies
|
return vacancies
|
||||||
|
|
||||||
def filter_suitable_vacancies(
|
def filter_suitable_vacancies(
|
||||||
self, vacancies: List[Vacancy], use_basic_filter: bool = True
|
self,
|
||||||
|
vacancies: List[Vacancy],
|
||||||
|
use_basic_filter: bool = True,
|
||||||
|
search_keywords: str = "",
|
||||||
) -> List[Vacancy]:
|
) -> List[Vacancy]:
|
||||||
"""Фильтрация подходящих вакансий"""
|
|
||||||
if not use_basic_filter:
|
if not use_basic_filter:
|
||||||
return vacancies
|
return vacancies
|
||||||
|
|
||||||
suitable = self.filter.filter_suitable(vacancies)
|
suitable = self.filter.filter_suitable(vacancies, search_keywords)
|
||||||
self.stats.filtered_count = len(suitable)
|
self.stats.filtered_count = len(suitable)
|
||||||
return suitable
|
return suitable
|
||||||
|
|
||||||
def get_vacancy_details(self, vacancy_id: str) -> Optional[Dict[str, Any]]:
|
def get_vacancy_details(self, vacancy_id: str) -> Optional[Dict[str, Any]]:
|
||||||
"""Получение детальной информации о вакансии"""
|
|
||||||
return self.details_fetcher.get_details(vacancy_id)
|
return self.details_fetcher.get_details(vacancy_id)
|
||||||
|
|
||||||
def get_search_stats(self) -> SearchStats:
|
def get_search_stats(self) -> SearchStats:
|
||||||
"""Получение статистики поиска"""
|
|
||||||
return self.stats
|
return self.stats
|
||||||
|
|
||||||
def reset_stats(self) -> None:
|
def reset_stats(self) -> None:
|
||||||
"""Сброс статистики"""
|
|
||||||
self.stats = SearchStats()
|
self.stats = SearchStats()
|
||||||
|
|
||||||
def suggest_keywords(self, base_keyword: str = "python") -> List[str]:
|
|
||||||
"""Предложения ключевых слов для поиска"""
|
|
||||||
return QueryBuilder.suggest_keywords(base_keyword)
|
|
||||||
|
|
5
main.py
5
main.py
|
@ -1,12 +1,7 @@
|
||||||
"""
|
|
||||||
🚀 HH.ru Автоматизация - Точка входа для прямого запуска
|
|
||||||
"""
|
|
||||||
|
|
||||||
from hh_bot.cli import CLIInterface
|
from hh_bot.cli import CLIInterface
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
"""Главная функция"""
|
|
||||||
CLIInterface.run_application()
|
CLIInterface.run_application()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -73,7 +73,7 @@ class TestSettings:
|
||||||
"""Тест создания настроек"""
|
"""Тест создания настроек"""
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|
||||||
assert settings.hh_search.keywords == "python junior"
|
assert settings.hh_search.keywords == "python"
|
||||||
assert settings.application.max_applications == 40
|
assert settings.application.max_applications == 40
|
||||||
assert settings.browser.headless is False
|
assert settings.browser.headless is False
|
||||||
|
|
||||||
|
@ -92,38 +92,31 @@ class TestServices:
|
||||||
"""Тест создания Gemini сервиса"""
|
"""Тест создания Gemini сервиса"""
|
||||||
service = GeminiAIService()
|
service = GeminiAIService()
|
||||||
|
|
||||||
assert service.model == "gemini-2.0-flash"
|
assert service is not None
|
||||||
assert service.match_threshold == 0.7
|
|
||||||
|
|
||||||
def test_hh_api_service_creation(self):
|
def test_hh_api_service_creation(self):
|
||||||
"""Тест создания HH API сервиса"""
|
"""Тест создания HH API сервиса"""
|
||||||
service = HHApiService()
|
service = HHApiService()
|
||||||
|
|
||||||
assert service.base_url == "https://api.hh.ru"
|
|
||||||
assert service is not None
|
assert service is not None
|
||||||
|
|
||||||
def test_gemini_basic_analysis(self):
|
def test_vacancy_matches_keywords(self):
|
||||||
"""Тест базового анализа Gemini"""
|
"""Тест проверки соответствия ключевым словам"""
|
||||||
service = GeminiAIService()
|
|
||||||
|
|
||||||
employer = Employer(id="123", name="Test Company")
|
employer = Employer(id="123", name="Test Company")
|
||||||
experience = Experience(id="noExperience", name="Без опыта")
|
experience = Experience(id="noExperience", name="Без опыта")
|
||||||
snippet = Snippet(requirement="Python", responsibility="Программирование")
|
snippet = Snippet(requirement="Python ML", responsibility="Машинное обучение")
|
||||||
|
|
||||||
vacancy = Vacancy(
|
vacancy = Vacancy(
|
||||||
id="test_id",
|
id="test_id",
|
||||||
name="Python Developer",
|
name="ML Engineer",
|
||||||
alternate_url="https://test.url",
|
alternate_url="https://test.url",
|
||||||
employer=employer,
|
employer=employer,
|
||||||
experience=experience,
|
experience=experience,
|
||||||
snippet=snippet,
|
snippet=snippet,
|
||||||
)
|
)
|
||||||
|
|
||||||
score, reasons = service._basic_analysis(vacancy)
|
assert vacancy.matches_keywords("python ml") is True
|
||||||
assert isinstance(score, float)
|
assert vacancy.matches_keywords("java") is False
|
||||||
assert 0.0 <= score <= 1.0
|
|
||||||
assert isinstance(reasons, list)
|
|
||||||
assert len(reasons) > 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_imports():
|
def test_imports():
|
||||||
|
|
Loading…
Reference in New Issue