feat: add AI-powered cover letter generation and improve modal handling
- Add automatic cover letter generation using Gemini AI - Implement smart detection of "Add cover letter" button in modals - Generate personalized cover letters based on real resume data and vacancy text - Improve modal window detection with updated selectors for HH.ru - Fix code formatting to comply with black and flake8 standards - Add .flake8 configuration with modern standards (88 char limit) - Handle cover letter field detection and auto-filling - Graceful fallback to default letter if AI generation fails Features: - Smart cover letter button detection - Personalized content generation via Gemini AI - Robust error handling for cover letter functionality - Maintains existing functionality without cover letters
This commit is contained in:
		
							parent
							
								
									fd8da44b84
								
							
						
					
					
						commit
						5973d0f70e
					
				| 
						 | 
					@ -0,0 +1,11 @@
 | 
				
			||||||
 | 
					[flake8]
 | 
				
			||||||
 | 
					max-line-length = 88
 | 
				
			||||||
 | 
					extend-ignore = E203, W503
 | 
				
			||||||
 | 
					exclude = 
 | 
				
			||||||
 | 
					    .git,
 | 
				
			||||||
 | 
					    __pycache__,
 | 
				
			||||||
 | 
					    .pytest_cache,
 | 
				
			||||||
 | 
					    venv,
 | 
				
			||||||
 | 
					    env,
 | 
				
			||||||
 | 
					    .venv,
 | 
				
			||||||
 | 
					    .env 
 | 
				
			||||||
| 
						 | 
					@ -197,7 +197,7 @@ class Settings:
 | 
				
			||||||
        return bool(self.gemini.api_key)
 | 
					        return bool(self.gemini.api_key)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def get_exclude_keywords(self) -> list:
 | 
					    def get_exclude_keywords(self) -> list:
 | 
				
			||||||
        return ['стажер', 'cv']
 | 
					        return ["стажер", "cv"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
settings = Settings()
 | 
					settings = Settings()
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -131,7 +131,9 @@ class AutomationOrchestrator:
 | 
				
			||||||
    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_successful_apps = settings.application.max_applications
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        logger.info(f"📨 ЭТАП 4: Подача заявок (максимум {max_successful_apps} успешных)")
 | 
					        logger.info(
 | 
				
			||||||
 | 
					            f"📨 ЭТАП 4: Подача заявок (максимум {max_successful_apps} успешных)"
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
        logger.info("💡 Между заявками добавляются паузы")
 | 
					        logger.info("💡 Между заявками добавляются паузы")
 | 
				
			||||||
        logger.info("💡 Лимит считается только по успешным заявкам")
 | 
					        logger.info("💡 Лимит считается только по успешным заявкам")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -141,27 +143,34 @@ class AutomationOrchestrator:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        for vacancy in vacancies:
 | 
					        for vacancy in vacancies:
 | 
				
			||||||
            if successful_count >= max_successful_apps:
 | 
					            if successful_count >= max_successful_apps:
 | 
				
			||||||
                logger.info(f"🎯 Достигнут лимит успешных заявок: {max_successful_apps}")
 | 
					                logger.info(
 | 
				
			||||||
 | 
					                    f"🎯 Достигнут лимит успешных заявок: {max_successful_apps}"
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
                break
 | 
					                break
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            processed_count += 1
 | 
					            processed_count += 1
 | 
				
			||||||
            truncated_name = UIFormatter.truncate_text(vacancy.name, medium=True)
 | 
					            truncated_name = UIFormatter.truncate_text(vacancy.name, medium=True)
 | 
				
			||||||
            logger.info(
 | 
					            logger.info(
 | 
				
			||||||
                f"Обработка {processed_count}: {truncated_name} (успешных: {successful_count}/{max_successful_apps})"
 | 
					                f"Обработка {processed_count}: {truncated_name} "
 | 
				
			||||||
 | 
					                f"(успешных: {successful_count}/{max_successful_apps})"
 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            try:
 | 
					            try:
 | 
				
			||||||
                result = self.browser_service.apply_to_vacancy(
 | 
					                result = self.browser_service.apply_to_vacancy(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 result.success:
 | 
					                if result.success:
 | 
				
			||||||
                    successful_count += 1
 | 
					                    successful_count += 1
 | 
				
			||||||
                    logger.info(f"   🎉 Успешных заявок: {successful_count}/{max_successful_apps}")
 | 
					                    logger.info(
 | 
				
			||||||
 | 
					                        f"   🎉 Успешных заявок: "
 | 
				
			||||||
 | 
					                        f"{successful_count}/{max_successful_apps}"
 | 
				
			||||||
 | 
					                    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                if processed_count < len(vacancies) and 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:
 | 
				
			||||||
| 
						 | 
					@ -174,7 +183,10 @@ class AutomationOrchestrator:
 | 
				
			||||||
                )
 | 
					                )
 | 
				
			||||||
                application_results.append(error_result)
 | 
					                application_results.append(error_result)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        logger.info(f"🏁 Обработка завершена. Обработано вакансий: {processed_count}, успешных заявок: {successful_count}")
 | 
					        logger.info(
 | 
				
			||||||
 | 
					            f"🏁 Обработка завершена. Обработано вакансий: {processed_count}, "
 | 
				
			||||||
 | 
					            f"успешных заявок: {successful_count}"
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
        return application_results
 | 
					        return application_results
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def _log_search_results(
 | 
					    def _log_search_results(
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -15,13 +15,15 @@ from selenium.common.exceptions import NoSuchElementException
 | 
				
			||||||
from webdriver_manager.chrome import ChromeDriverManager
 | 
					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, Vacancy
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class SubmissionResult(Enum):
 | 
					class SubmissionResult(Enum):
 | 
				
			||||||
    SUCCESS = "success"
 | 
					    SUCCESS = "success"
 | 
				
			||||||
    FAILED = "failed"
 | 
					    FAILED = "failed"
 | 
				
			||||||
    SKIPPED = "skipped"
 | 
					    SKIPPED = "skipped"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
logger = logging.getLogger(__name__)
 | 
					logger = logging.getLogger(__name__)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -44,7 +46,7 @@ class SessionManager:
 | 
				
			||||||
                "timestamp": time.time(),
 | 
					                "timestamp": time.time(),
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            with open(self.cookies_file, 'w', encoding='utf-8') as f:
 | 
					            with open(self.cookies_file, "w", encoding="utf-8") as f:
 | 
				
			||||||
                json.dump(session_data, f, indent=2, ensure_ascii=False)
 | 
					                json.dump(session_data, f, indent=2, ensure_ascii=False)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            logger.info(f"Сессия сохранена в {self.cookies_file}")
 | 
					            logger.info(f"Сессия сохранена в {self.cookies_file}")
 | 
				
			||||||
| 
						 | 
					@ -61,7 +63,7 @@ class SessionManager:
 | 
				
			||||||
                logger.info("Файл сессии не найден")
 | 
					                logger.info("Файл сессии не найден")
 | 
				
			||||||
                return False
 | 
					                return False
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            with open(self.cookies_file, 'r', encoding='utf-8') as f:
 | 
					            with open(self.cookies_file, "r", encoding="utf-8") as f:
 | 
				
			||||||
                session_data = json.load(f)
 | 
					                session_data = json.load(f)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            if not self._is_session_valid(session_data):
 | 
					            if not self._is_session_valid(session_data):
 | 
				
			||||||
| 
						 | 
					@ -250,21 +252,19 @@ class VacancyApplicator:
 | 
				
			||||||
    def __init__(self, driver: webdriver.Chrome):
 | 
					    def __init__(self, driver: webdriver.Chrome):
 | 
				
			||||||
        self.driver = driver
 | 
					        self.driver = driver
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def apply_to_vacancy(
 | 
					    def apply_to_vacancy(self, vacancy: Vacancy) -> ApplicationResult:
 | 
				
			||||||
        self, vacancy_url: str, vacancy_name: str
 | 
					 | 
				
			||||||
    ) -> ApplicationResult:
 | 
					 | 
				
			||||||
        """Подача заявки на вакансию"""
 | 
					        """Подача заявки на вакансию"""
 | 
				
			||||||
        try:
 | 
					        try:
 | 
				
			||||||
            truncated_name = UIFormatter.truncate_text(vacancy_name)
 | 
					            truncated_name = UIFormatter.truncate_text(vacancy.name)
 | 
				
			||||||
            logger.info(f"Переход к вакансии: {truncated_name}...")
 | 
					            logger.info(f"Переход к вакансии: {truncated_name}...")
 | 
				
			||||||
            self.driver.get(vacancy_url)
 | 
					            self.driver.get(vacancy.alternate_url)
 | 
				
			||||||
            time.sleep(3)
 | 
					            time.sleep(3)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            apply_button = self._find_apply_button()
 | 
					            apply_button = self._find_apply_button()
 | 
				
			||||||
            if not apply_button:
 | 
					            if not apply_button:
 | 
				
			||||||
                return ApplicationResult(
 | 
					                return ApplicationResult(
 | 
				
			||||||
                    vacancy_id="",
 | 
					                    vacancy_id="",
 | 
				
			||||||
                    vacancy_name=vacancy_name,
 | 
					                    vacancy_name=vacancy.name,
 | 
				
			||||||
                    success=False,
 | 
					                    success=False,
 | 
				
			||||||
                    error_message="Кнопка отклика не найдена",
 | 
					                    error_message="Кнопка отклика не найдена",
 | 
				
			||||||
                )
 | 
					                )
 | 
				
			||||||
| 
						 | 
					@ -273,7 +273,7 @@ class VacancyApplicator:
 | 
				
			||||||
            if self._is_already_applied(button_text):
 | 
					            if self._is_already_applied(button_text):
 | 
				
			||||||
                return ApplicationResult(
 | 
					                return ApplicationResult(
 | 
				
			||||||
                    vacancy_id="",
 | 
					                    vacancy_id="",
 | 
				
			||||||
                    vacancy_name=vacancy_name,
 | 
					                    vacancy_name=vacancy.name,
 | 
				
			||||||
                    success=False,
 | 
					                    success=False,
 | 
				
			||||||
                    already_applied=True,
 | 
					                    already_applied=True,
 | 
				
			||||||
                    error_message="Уже откликались на эту вакансию",
 | 
					                    error_message="Уже откликались на эту вакансию",
 | 
				
			||||||
| 
						 | 
					@ -284,27 +284,29 @@ class VacancyApplicator:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            logger.info("Кнопка отклика нажата, ищем форму заявки...")
 | 
					            logger.info("Кнопка отклика нажата, ищем форму заявки...")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            submit_result = self._submit_application_form()
 | 
					            submit_result = self._submit_application_form(vacancy)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            if submit_result == SubmissionResult.SUCCESS:
 | 
					            if submit_result == SubmissionResult.SUCCESS:
 | 
				
			||||||
                logger.info("✅ Заявка успешно отправлена")
 | 
					                logger.info("✅ Заявка успешно отправлена")
 | 
				
			||||||
                return ApplicationResult(
 | 
					                return ApplicationResult(
 | 
				
			||||||
                    vacancy_id="", vacancy_name=vacancy_name, success=True
 | 
					                    vacancy_id="", vacancy_name=vacancy.name, success=True
 | 
				
			||||||
                )
 | 
					                )
 | 
				
			||||||
            elif submit_result == SubmissionResult.SKIPPED:
 | 
					            elif submit_result == SubmissionResult.SKIPPED:
 | 
				
			||||||
                logger.warning("⚠️ Вакансия пропущена (нет модального окна)")
 | 
					                logger.warning("⚠️ Вакансия пропущена (нет модального окна)")
 | 
				
			||||||
                return ApplicationResult(
 | 
					                return ApplicationResult(
 | 
				
			||||||
                    vacancy_id="",
 | 
					                    vacancy_id="",
 | 
				
			||||||
                    vacancy_name=vacancy_name,
 | 
					                    vacancy_name=vacancy.name,
 | 
				
			||||||
                    success=False,
 | 
					                    success=False,
 | 
				
			||||||
                    skipped=True,
 | 
					                    skipped=True,
 | 
				
			||||||
                    error_message="Модальное окно не найдено (возможно тестовая вакансия)",
 | 
					                    error_message=(
 | 
				
			||||||
 | 
					                        "Модальное окно не найдено " "(возможно тестовая вакансия)"
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
                )
 | 
					                )
 | 
				
			||||||
            else:  # FAILED
 | 
					            else:  # FAILED
 | 
				
			||||||
                logger.warning("❌ Не удалось отправить заявку в модальном окне")
 | 
					                logger.warning("❌ Не удалось отправить заявку в модальном окне")
 | 
				
			||||||
                return ApplicationResult(
 | 
					                return ApplicationResult(
 | 
				
			||||||
                    vacancy_id="",
 | 
					                    vacancy_id="",
 | 
				
			||||||
                    vacancy_name=vacancy_name,
 | 
					                    vacancy_name=vacancy.name,
 | 
				
			||||||
                    success=False,
 | 
					                    success=False,
 | 
				
			||||||
                    error_message="Ошибка отправки в модальном окне",
 | 
					                    error_message="Ошибка отправки в модальном окне",
 | 
				
			||||||
                )
 | 
					                )
 | 
				
			||||||
| 
						 | 
					@ -313,7 +315,7 @@ class VacancyApplicator:
 | 
				
			||||||
            logger.error(f"Ошибка при подаче заявки: {e}")
 | 
					            logger.error(f"Ошибка при подаче заявки: {e}")
 | 
				
			||||||
            return ApplicationResult(
 | 
					            return ApplicationResult(
 | 
				
			||||||
                vacancy_id="",
 | 
					                vacancy_id="",
 | 
				
			||||||
                vacancy_name=vacancy_name,
 | 
					                vacancy_name=vacancy.name,
 | 
				
			||||||
                success=False,
 | 
					                success=False,
 | 
				
			||||||
                error_message=str(e),
 | 
					                error_message=str(e),
 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
| 
						 | 
					@ -334,17 +336,17 @@ class VacancyApplicator:
 | 
				
			||||||
            indicator in button_text for indicator in self.ALREADY_APPLIED_INDICATORS
 | 
					            indicator in button_text for indicator in self.ALREADY_APPLIED_INDICATORS
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def _submit_application_form(self) -> SubmissionResult:
 | 
					    def _submit_application_form(self, vacancy: Vacancy) -> SubmissionResult:
 | 
				
			||||||
        """Отправка заявки в модальном окне"""
 | 
					        """Отправка заявки в модальном окне"""
 | 
				
			||||||
        try:
 | 
					        try:
 | 
				
			||||||
            modal_selectors = [
 | 
					            modal_selectors = [
 | 
				
			||||||
                '[data-qa="modal-overlay"]',
 | 
					                '[data-qa="modal-overlay"]',
 | 
				
			||||||
                '.magritte-modal-overlay',
 | 
					                ".magritte-modal-overlay",
 | 
				
			||||||
                '[data-qa="modal"]',
 | 
					                '[data-qa="modal"]',
 | 
				
			||||||
                '[data-qa="vacancy-response-popup"]',
 | 
					                '[data-qa="vacancy-response-popup"]',
 | 
				
			||||||
                '.vacancy-response-popup',
 | 
					                ".vacancy-response-popup",
 | 
				
			||||||
                '.modal',
 | 
					                ".modal",
 | 
				
			||||||
                '.bloko-modal',
 | 
					                ".bloko-modal",
 | 
				
			||||||
            ]
 | 
					            ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            submit_selectors = [
 | 
					            submit_selectors = [
 | 
				
			||||||
| 
						 | 
					@ -372,7 +374,10 @@ class VacancyApplicator:
 | 
				
			||||||
                    continue
 | 
					                    continue
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            if not modal_found:
 | 
					            if not modal_found:
 | 
				
			||||||
                logger.warning("⚠️ Модальное окно не найдено - пропускаем вакансию (возможно тестовая или ошибка)")
 | 
					                logger.warning(
 | 
				
			||||||
 | 
					                    "⚠️ Модальное окно не найдено - пропускаем вакансию "
 | 
				
			||||||
 | 
					                    "(возможно тестовая или ошибка)"
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
                return SubmissionResult.SKIPPED
 | 
					                return SubmissionResult.SKIPPED
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            form_selectors = [
 | 
					            form_selectors = [
 | 
				
			||||||
| 
						 | 
					@ -396,6 +401,7 @@ class VacancyApplicator:
 | 
				
			||||||
                logger.warning("Форма отклика не найдена в модальном окне - пропускаем")
 | 
					                logger.warning("Форма отклика не найдена в модальном окне - пропускаем")
 | 
				
			||||||
                return SubmissionResult.SKIPPED
 | 
					                return SubmissionResult.SKIPPED
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            self._add_cover_letter_if_possible(vacancy)
 | 
				
			||||||
            time.sleep(1)
 | 
					            time.sleep(1)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            for selector in submit_selectors:
 | 
					            for selector in submit_selectors:
 | 
				
			||||||
| 
						 | 
					@ -404,8 +410,13 @@ class VacancyApplicator:
 | 
				
			||||||
                        EC.element_to_be_clickable((By.CSS_SELECTOR, selector))
 | 
					                        EC.element_to_be_clickable((By.CSS_SELECTOR, selector))
 | 
				
			||||||
                    )
 | 
					                    )
 | 
				
			||||||
                    if submit_button:
 | 
					                    if submit_button:
 | 
				
			||||||
                        logger.info(f"Нажимаем кнопку отправки: {submit_button.text.strip()}")
 | 
					                        logger.info(
 | 
				
			||||||
                        self.driver.execute_script("arguments[0].click();", submit_button)
 | 
					                            f"Нажимаем кнопку отправки: "
 | 
				
			||||||
 | 
					                            f"{submit_button.text.strip()}"
 | 
				
			||||||
 | 
					                        )
 | 
				
			||||||
 | 
					                        self.driver.execute_script(
 | 
				
			||||||
 | 
					                            "arguments[0].click();", submit_button
 | 
				
			||||||
 | 
					                        )
 | 
				
			||||||
                        time.sleep(3)
 | 
					                        time.sleep(3)
 | 
				
			||||||
                        if self._check_success_message():
 | 
					                        if self._check_success_message():
 | 
				
			||||||
                            return SubmissionResult.SUCCESS
 | 
					                            return SubmissionResult.SUCCESS
 | 
				
			||||||
| 
						 | 
					@ -421,6 +432,87 @@ class VacancyApplicator:
 | 
				
			||||||
            logger.error(f"Ошибка в модальном окне: {e}")
 | 
					            logger.error(f"Ошибка в модальном окне: {e}")
 | 
				
			||||||
            return SubmissionResult.FAILED
 | 
					            return SubmissionResult.FAILED
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def _add_cover_letter_if_possible(self, vacancy: Vacancy) -> None:
 | 
				
			||||||
 | 
					        """Добавление сопроводительного письма если возможно"""
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            cover_letter_button_selectors = [
 | 
				
			||||||
 | 
					                '[data-qa="add-cover-letter"]',
 | 
				
			||||||
 | 
					                'button[data-qa*="cover-letter"]',
 | 
				
			||||||
 | 
					                'button:contains("Добавить сопроводительное")',
 | 
				
			||||||
 | 
					                'button:contains("сопроводительное")',
 | 
				
			||||||
 | 
					            ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            cover_letter_button = None
 | 
				
			||||||
 | 
					            for selector in cover_letter_button_selectors:
 | 
				
			||||||
 | 
					                try:
 | 
				
			||||||
 | 
					                    if selector.startswith("button:contains"):
 | 
				
			||||||
 | 
					                        buttons = self.driver.find_elements(By.TAG_NAME, "button")
 | 
				
			||||||
 | 
					                        text_to_find = selector.split('"')[1].lower()
 | 
				
			||||||
 | 
					                        for button in buttons:
 | 
				
			||||||
 | 
					                            if text_to_find in button.text.lower():
 | 
				
			||||||
 | 
					                                cover_letter_button = button
 | 
				
			||||||
 | 
					                                break
 | 
				
			||||||
 | 
					                    else:
 | 
				
			||||||
 | 
					                        cover_letter_button = self.driver.find_element(
 | 
				
			||||||
 | 
					                            By.CSS_SELECTOR, selector
 | 
				
			||||||
 | 
					                        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    if cover_letter_button:
 | 
				
			||||||
 | 
					                        break
 | 
				
			||||||
 | 
					                except Exception:
 | 
				
			||||||
 | 
					                    continue
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            if not cover_letter_button:
 | 
				
			||||||
 | 
					                logger.info("Кнопка сопроводительного письма не найдена")
 | 
				
			||||||
 | 
					                return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            logger.info("Найдена кнопка сопроводительного письма, нажимаем...")
 | 
				
			||||||
 | 
					            self.driver.execute_script("arguments[0].click();", cover_letter_button)
 | 
				
			||||||
 | 
					            time.sleep(2)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            cover_letter_field_selectors = [
 | 
				
			||||||
 | 
					                'textarea[data-qa*="cover-letter"]',
 | 
				
			||||||
 | 
					                'textarea[name*="letter"]',
 | 
				
			||||||
 | 
					                'textarea[placeholder*="письм"]',
 | 
				
			||||||
 | 
					                'textarea[id*="letter"]',
 | 
				
			||||||
 | 
					                ".modal textarea",
 | 
				
			||||||
 | 
					                "form textarea",
 | 
				
			||||||
 | 
					            ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            cover_letter_field = None
 | 
				
			||||||
 | 
					            for selector in cover_letter_field_selectors:
 | 
				
			||||||
 | 
					                try:
 | 
				
			||||||
 | 
					                    cover_letter_field = WebDriverWait(self.driver, 3).until(
 | 
				
			||||||
 | 
					                        EC.presence_of_element_located((By.CSS_SELECTOR, selector))
 | 
				
			||||||
 | 
					                    )
 | 
				
			||||||
 | 
					                    if cover_letter_field:
 | 
				
			||||||
 | 
					                        break
 | 
				
			||||||
 | 
					                except Exception:
 | 
				
			||||||
 | 
					                    continue
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            if not cover_letter_field:
 | 
				
			||||||
 | 
					                logger.warning("Поле для сопроводительного письма не найдено")
 | 
				
			||||||
 | 
					                return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            logger.info("Генерация сопроводительного письма...")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            from ..services.gemini_service import GeminiAIService
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            gemini_service = GeminiAIService()
 | 
				
			||||||
 | 
					            cover_letter_text = gemini_service.generate_cover_letter(vacancy)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            if cover_letter_text:
 | 
				
			||||||
 | 
					                logger.info("Заполняем сопроводительное письмо...")
 | 
				
			||||||
 | 
					                cover_letter_field.clear()
 | 
				
			||||||
 | 
					                cover_letter_field.send_keys(cover_letter_text)
 | 
				
			||||||
 | 
					                logger.info("✅ Сопроводительное письмо добавлено")
 | 
				
			||||||
 | 
					            else:
 | 
				
			||||||
 | 
					                logger.warning("Не удалось сгенерировать сопроводительное письмо")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        except Exception as e:
 | 
				
			||||||
 | 
					            logger.warning(f"Ошибка при добавлении сопроводительного письма: {e}")
 | 
				
			||||||
 | 
					            logger.info("Продолжаем без сопроводительного письма")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def _check_success_message(self) -> bool:
 | 
					    def _check_success_message(self) -> bool:
 | 
				
			||||||
        """Проверка успешной отправки заявки"""
 | 
					        """Проверка успешной отправки заявки"""
 | 
				
			||||||
        try:
 | 
					        try:
 | 
				
			||||||
| 
						 | 
					@ -439,16 +531,21 @@ class VacancyApplicator:
 | 
				
			||||||
            success_selectors = [
 | 
					            success_selectors = [
 | 
				
			||||||
                '[data-qa*="success"]',
 | 
					                '[data-qa*="success"]',
 | 
				
			||||||
                '[data-qa*="sent"]',
 | 
					                '[data-qa*="sent"]',
 | 
				
			||||||
                '.success-message',
 | 
					                ".success-message",
 | 
				
			||||||
                '.response-sent',
 | 
					                ".response-sent",
 | 
				
			||||||
                '[class*="success"]',
 | 
					                '[class*="success"]',
 | 
				
			||||||
            ]
 | 
					            ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            for selector in success_selectors:
 | 
					            for selector in success_selectors:
 | 
				
			||||||
                try:
 | 
					                try:
 | 
				
			||||||
                    success_element = self.driver.find_element(By.CSS_SELECTOR, selector)
 | 
					                    success_element = self.driver.find_element(
 | 
				
			||||||
 | 
					                        By.CSS_SELECTOR, selector
 | 
				
			||||||
 | 
					                    )
 | 
				
			||||||
                    if success_element and success_element.is_displayed():
 | 
					                    if success_element and success_element.is_displayed():
 | 
				
			||||||
                        logger.info(f"Найден элемент успеха: {selector} - {success_element.text}")
 | 
					                        logger.info(
 | 
				
			||||||
 | 
					                            f"Найден элемент успеха: {selector} - "
 | 
				
			||||||
 | 
					                            f"{success_element.text}"
 | 
				
			||||||
 | 
					                        )
 | 
				
			||||||
                        return True
 | 
					                        return True
 | 
				
			||||||
                except Exception:
 | 
					                except Exception:
 | 
				
			||||||
                    continue
 | 
					                    continue
 | 
				
			||||||
| 
						 | 
					@ -461,7 +558,11 @@ class VacancyApplicator:
 | 
				
			||||||
                    return True
 | 
					                    return True
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            current_url = self.driver.current_url
 | 
					            current_url = self.driver.current_url
 | 
				
			||||||
            if "sent" in current_url or "success" in current_url or "response" in current_url:
 | 
					            if (
 | 
				
			||||||
 | 
					                "sent" in current_url
 | 
				
			||||||
 | 
					                or "success" in current_url
 | 
				
			||||||
 | 
					                or "response" in current_url
 | 
				
			||||||
 | 
					            ):
 | 
				
			||||||
                logger.info(f"URL указывает на успешную отправку: {current_url}")
 | 
					                logger.info(f"URL указывает на успешную отправку: {current_url}")
 | 
				
			||||||
                return True
 | 
					                return True
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -531,19 +632,17 @@ class BrowserService:
 | 
				
			||||||
            self._is_authenticated = True
 | 
					            self._is_authenticated = True
 | 
				
			||||||
        return success
 | 
					        return success
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def apply_to_vacancy(
 | 
					    def apply_to_vacancy(self, vacancy: Vacancy) -> ApplicationResult:
 | 
				
			||||||
        self, vacancy_url: str, vacancy_name: str
 | 
					 | 
				
			||||||
    ) -> ApplicationResult:
 | 
					 | 
				
			||||||
        """Подача заявки на вакансию"""
 | 
					        """Подача заявки на вакансию"""
 | 
				
			||||||
        if not self.is_ready():
 | 
					        if not self.is_ready():
 | 
				
			||||||
            return ApplicationResult(
 | 
					            return ApplicationResult(
 | 
				
			||||||
                vacancy_id="",
 | 
					                vacancy_id="",
 | 
				
			||||||
                vacancy_name=vacancy_name,
 | 
					                vacancy_name=vacancy.name,
 | 
				
			||||||
                success=False,
 | 
					                success=False,
 | 
				
			||||||
                error_message="Браузер не готов или нет авторизации",
 | 
					                error_message="Браузер не готов или нет авторизации",
 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        return self.applicator.apply_to_vacancy(vacancy_url, vacancy_name)
 | 
					        return self.applicator.apply_to_vacancy(vacancy)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def add_random_pause(self) -> None:
 | 
					    def add_random_pause(self) -> None:
 | 
				
			||||||
        """Случайная пауза между действиями"""
 | 
					        """Случайная пауза между действиями"""
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -84,8 +84,14 @@ class GeminiApiClient:
 | 
				
			||||||
                json_str = content[json_start:json_end]
 | 
					                json_str = content[json_start:json_end]
 | 
				
			||||||
                parsed_response = json.loads(json_str)
 | 
					                parsed_response = json.loads(json_str)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                score = parsed_response.get("match_score", 0)
 | 
					                if "match_score" in parsed_response:
 | 
				
			||||||
                logger.info(f"Gemini анализ завершен: {score}")
 | 
					                    score = parsed_response.get("match_score", 0)
 | 
				
			||||||
 | 
					                    logger.info(f"Gemini анализ завершен: {score}")
 | 
				
			||||||
 | 
					                elif "cover_letter" in parsed_response:
 | 
				
			||||||
 | 
					                    logger.info("Gemini сгенерировал сопроводительное письмо")
 | 
				
			||||||
 | 
					                else:
 | 
				
			||||||
 | 
					                    logger.info("Получен ответ от Gemini")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                return parsed_response
 | 
					                return parsed_response
 | 
				
			||||||
            else:
 | 
					            else:
 | 
				
			||||||
                logger.error("JSON не найден в ответе Gemini")
 | 
					                logger.error("JSON не найден в ответе Gemini")
 | 
				
			||||||
| 
						 | 
					@ -330,3 +336,88 @@ class GeminiAIService:
 | 
				
			||||||
            return score >= settings.gemini.match_threshold
 | 
					            return score >= settings.gemini.match_threshold
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        return self.analyzer.should_apply(vacancy)
 | 
					        return self.analyzer.should_apply(vacancy)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def generate_cover_letter(self, vacancy: Vacancy) -> Optional[str]:
 | 
				
			||||||
 | 
					        """Генерация сопроводительного письма для вакансии"""
 | 
				
			||||||
 | 
					        if not self.is_available():
 | 
				
			||||||
 | 
					            logger.warning("Gemini API недоступен, используем базовое письмо")
 | 
				
			||||||
 | 
					            return self._get_default_cover_letter()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            resume_data = self.resume_loader.load()
 | 
				
			||||||
 | 
					            vacancy_text = self._get_vacancy_full_text(vacancy)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            experience_text = resume_data.get("experience", "")
 | 
				
			||||||
 | 
					            about_me_text = resume_data.get("about_me", "")
 | 
				
			||||||
 | 
					            skills_text = resume_data.get("skills", "")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            my_profile = f"""
 | 
				
			||||||
 | 
					Опыт работы:
 | 
				
			||||||
 | 
					{experience_text}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					О себе:
 | 
				
			||||||
 | 
					{about_me_text}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Навыки и технологии:
 | 
				
			||||||
 | 
					{skills_text}
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            prompt_text = (
 | 
				
			||||||
 | 
					                "Напиши короткое, человечное и честное сопроводительное письмо "
 | 
				
			||||||
 | 
					                "для отклика на вакансию на русском языке. Не придумывай опыт, "
 | 
				
			||||||
 | 
					                "которого нет. Используй только мой реальный опыт и навыки ниже. "
 | 
				
			||||||
 | 
					                "Пиши по делу, дружелюбно и без официоза. Не делай письмо слишком "
 | 
				
			||||||
 | 
					                "длинным. Всегда заканчивай строкой «Telegram — @itqen»."
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            prompt = f"""{prompt_text}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Верни только JSON с ключом "cover_letter", без других пояснений.**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Пример формата вывода:
 | 
				
			||||||
 | 
					{{"cover_letter": "текст письма здесь"}}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Вот мой опыт:**
 | 
				
			||||||
 | 
					{my_profile}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Вот текст вакансии:**
 | 
				
			||||||
 | 
					{vacancy_text}"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            logger.info("Генерация сопроводительного письма через Gemini")
 | 
				
			||||||
 | 
					            response = self.api_client.generate_content(prompt)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            if response and "cover_letter" in response:
 | 
				
			||||||
 | 
					                cover_letter = response["cover_letter"]
 | 
				
			||||||
 | 
					                logger.info("Сопроводительное письмо сгенерировано")
 | 
				
			||||||
 | 
					                return cover_letter
 | 
				
			||||||
 | 
					            else:
 | 
				
			||||||
 | 
					                logger.error("Не удалось получить сопроводительное письмо от Gemini")
 | 
				
			||||||
 | 
					                return self._get_default_cover_letter()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        except Exception as e:
 | 
				
			||||||
 | 
					            logger.error(f"Ошибка генерации сопроводительного письма: {e}")
 | 
				
			||||||
 | 
					            return self._get_default_cover_letter()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def _get_vacancy_full_text(self, vacancy: Vacancy) -> str:
 | 
				
			||||||
 | 
					        """Получение полного текста вакансии"""
 | 
				
			||||||
 | 
					        parts = [
 | 
				
			||||||
 | 
					            f"Название: {vacancy.name}",
 | 
				
			||||||
 | 
					            f"Компания: {vacancy.employer.name}",
 | 
				
			||||||
 | 
					        ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if vacancy.snippet.requirement:
 | 
				
			||||||
 | 
					            parts.append(f"Требования: {vacancy.snippet.requirement}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if vacancy.snippet.responsibility:
 | 
				
			||||||
 | 
					            parts.append(f"Обязанности: {vacancy.snippet.responsibility}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return "\n\n".join(parts)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def _get_default_cover_letter(self) -> str:
 | 
				
			||||||
 | 
					        """Базовое сопроводительное письмо на случай ошибки"""
 | 
				
			||||||
 | 
					        return """Добрый день!
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Заинтересован в данной вакансии. Готов обсудить детали и возможности сотрудничества.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					С уважением,
 | 
				
			||||||
 | 
					Telegram — @itqen"""
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
		Loading…
	
		Reference in New Issue