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:
itqop 2025-06-28 19:30:56 +03:00
parent fd8da44b84
commit 5973d0f70e
6 changed files with 280 additions and 67 deletions

11
.flake8 Normal file
View File

@ -0,0 +1,11 @@
[flake8]
max-line-length = 88
extend-ignore = E203, W503
exclude =
.git,
__pycache__,
.pytest_cache,
venv,
env,
.venv,
.env

View File

@ -85,7 +85,7 @@ class CLIInterface:
print(f"📝 Всего заявок: {stats['total_applications']}")
print(f"✅ Успешных: {stats['successful']}")
print(f"❌ Неудачных: {stats['failed']}")
if "skipped" in stats and stats["skipped"] > 0:
print(f"⏭️ Пропущено: {stats['skipped']}")

View File

@ -197,7 +197,7 @@ class Settings:
return bool(self.gemini.api_key)
def get_exclude_keywords(self) -> list:
return ['стажер', 'cv']
return ["стажер", "cv"]
settings = Settings()

View File

@ -131,7 +131,9 @@ class AutomationOrchestrator:
def _apply_to_vacancies(self, vacancies: List[Vacancy]) -> List[ApplicationResult]:
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("💡 Лимит считается только по успешным заявкам")
@ -141,27 +143,34 @@ class AutomationOrchestrator:
for vacancy in vacancies:
if successful_count >= max_successful_apps:
logger.info(f"🎯 Достигнут лимит успешных заявок: {max_successful_apps}")
logger.info(
f"🎯 Достигнут лимит успешных заявок: {max_successful_apps}"
)
break
processed_count += 1
truncated_name = UIFormatter.truncate_text(vacancy.name, medium=True)
logger.info(
f"Обработка {processed_count}: {truncated_name} (успешных: {successful_count}/{max_successful_apps})"
f"Обработка {processed_count}: {truncated_name} "
f"(успешных: {successful_count}/{max_successful_apps})"
)
try:
result = self.browser_service.apply_to_vacancy(
vacancy.alternate_url, vacancy.name
)
result = self.browser_service.apply_to_vacancy(vacancy)
application_results.append(result)
self._log_application_result(result)
if result.success:
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()
except Exception as e:
@ -174,7 +183,10 @@ class AutomationOrchestrator:
)
application_results.append(error_result)
logger.info(f"🏁 Обработка завершена. Обработано вакансий: {processed_count}, успешных заявок: {successful_count}")
logger.info(
f"🏁 Обработка завершена. Обработано вакансий: {processed_count}, "
f"успешных заявок: {successful_count}"
)
return application_results
def _log_search_results(

View File

@ -15,13 +15,15 @@ from selenium.common.exceptions import NoSuchElementException
from webdriver_manager.chrome import ChromeDriverManager
from ..config.settings import settings, AppConstants, UIFormatter
from ..models.vacancy import ApplicationResult
from ..models.vacancy import ApplicationResult, Vacancy
class SubmissionResult(Enum):
SUCCESS = "success"
FAILED = "failed"
FAILED = "failed"
SKIPPED = "skipped"
logger = logging.getLogger(__name__)
@ -44,7 +46,7 @@ class SessionManager:
"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)
logger.info(f"Сессия сохранена в {self.cookies_file}")
@ -61,7 +63,7 @@ class SessionManager:
logger.info("Файл сессии не найден")
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)
if not self._is_session_valid(session_data):
@ -167,11 +169,11 @@ class AuthenticationHandler:
try:
print("\n🔐 ПРОВЕРКА СОХРАНЕННОЙ СЕССИИ")
logger.info("Проверяем сохраненную сессию...")
if self.session_manager.load_session():
self.driver.refresh()
time.sleep(3)
if self._check_authentication():
print("✅ Использована сохраненная сессия")
logger.info("Авторизация через сохраненную сессию успешна!")
@ -193,12 +195,12 @@ class AuthenticationHandler:
if self._check_authentication():
logger.info("Авторизация успешна!")
if self.session_manager.save_session():
print("✅ Сессия сохранена для следующих запусков")
else:
print("⚠️ Не удалось сохранить сессию")
return True
else:
logger.error("Авторизация не завершена")
@ -250,21 +252,19 @@ class VacancyApplicator:
def __init__(self, driver: webdriver.Chrome):
self.driver = driver
def apply_to_vacancy(
self, vacancy_url: str, vacancy_name: str
) -> ApplicationResult:
def apply_to_vacancy(self, vacancy: Vacancy) -> ApplicationResult:
"""Подача заявки на вакансию"""
try:
truncated_name = UIFormatter.truncate_text(vacancy_name)
truncated_name = UIFormatter.truncate_text(vacancy.name)
logger.info(f"Переход к вакансии: {truncated_name}...")
self.driver.get(vacancy_url)
self.driver.get(vacancy.alternate_url)
time.sleep(3)
apply_button = self._find_apply_button()
if not apply_button:
return ApplicationResult(
vacancy_id="",
vacancy_name=vacancy_name,
vacancy_name=vacancy.name,
success=False,
error_message="Кнопка отклика не найдена",
)
@ -273,7 +273,7 @@ class VacancyApplicator:
if self._is_already_applied(button_text):
return ApplicationResult(
vacancy_id="",
vacancy_name=vacancy_name,
vacancy_name=vacancy.name,
success=False,
already_applied=True,
error_message="Уже откликались на эту вакансию",
@ -283,28 +283,30 @@ class VacancyApplicator:
time.sleep(2)
logger.info("Кнопка отклика нажата, ищем форму заявки...")
submit_result = self._submit_application_form()
submit_result = self._submit_application_form(vacancy)
if submit_result == SubmissionResult.SUCCESS:
logger.info("✅ Заявка успешно отправлена")
return ApplicationResult(
vacancy_id="", vacancy_name=vacancy_name, success=True
vacancy_id="", vacancy_name=vacancy.name, success=True
)
elif submit_result == SubmissionResult.SKIPPED:
logger.warning("⚠️ Вакансия пропущена (нет модального окна)")
return ApplicationResult(
vacancy_id="",
vacancy_name=vacancy_name,
vacancy_name=vacancy.name,
success=False,
skipped=True,
error_message="Модальное окно не найдено (возможно тестовая вакансия)",
error_message=(
"Модальное окно не найдено " "(возможно тестовая вакансия)"
),
)
else: # FAILED
logger.warning("Не удалось отправить заявку в модальном окне")
return ApplicationResult(
vacancy_id="",
vacancy_name=vacancy_name,
vacancy_name=vacancy.name,
success=False,
error_message="Ошибка отправки в модальном окне",
)
@ -313,7 +315,7 @@ class VacancyApplicator:
logger.error(f"Ошибка при подаче заявки: {e}")
return ApplicationResult(
vacancy_id="",
vacancy_name=vacancy_name,
vacancy_name=vacancy.name,
success=False,
error_message=str(e),
)
@ -334,19 +336,19 @@ class VacancyApplicator:
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:
modal_selectors = [
'[data-qa="modal-overlay"]',
'.magritte-modal-overlay',
".magritte-modal-overlay",
'[data-qa="modal"]',
'[data-qa="vacancy-response-popup"]',
'.vacancy-response-popup',
'.modal',
'.bloko-modal',
".vacancy-response-popup",
".modal",
".bloko-modal",
]
submit_selectors = [
'[data-qa="vacancy-response-submit-popup"]',
'button[form="RESPONSE_MODAL_FORM_ID"]',
@ -372,7 +374,10 @@ class VacancyApplicator:
continue
if not modal_found:
logger.warning("⚠️ Модальное окно не найдено - пропускаем вакансию (возможно тестовая или ошибка)")
logger.warning(
"⚠️ Модальное окно не найдено - пропускаем вакансию "
"(возможно тестовая или ошибка)"
)
return SubmissionResult.SKIPPED
form_selectors = [
@ -380,7 +385,7 @@ class VacancyApplicator:
'form[id="RESPONSE_MODAL_FORM_ID"]',
'form[data-qa*="response"]',
]
form_found = False
for form_selector in form_selectors:
try:
@ -391,11 +396,12 @@ class VacancyApplicator:
break
except Exception:
continue
if not form_found:
logger.warning("Форма отклика не найдена в модальном окне - пропускаем")
return SubmissionResult.SKIPPED
self._add_cover_letter_if_possible(vacancy)
time.sleep(1)
for selector in submit_selectors:
@ -404,8 +410,13 @@ class VacancyApplicator:
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)
logger.info(
f"Нажимаем кнопку отправки: "
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
@ -421,12 +432,93 @@ class VacancyApplicator:
logger.error(f"Ошибка в модальном окне: {e}")
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:
"""Проверка успешной отправки заявки"""
try:
success_indicators = [
"отклик отправлен",
"заявка отправлена",
"заявка отправлена",
"успешно отправлено",
"спасибо за отклик",
"ваш отклик получен",
@ -435,50 +527,59 @@ class VacancyApplicator:
"резюме отправлено",
"откликнулись на вакансию",
]
success_selectors = [
'[data-qa*="success"]',
'[data-qa*="sent"]',
'.success-message',
'.response-sent',
".success-message",
".response-sent",
'[class*="success"]',
]
for selector in success_selectors:
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():
logger.info(f"Найден элемент успеха: {selector} - {success_element.text}")
logger.info(
f"Найден элемент успеха: {selector} - "
f"{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:
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
@ -531,19 +632,17 @@ class BrowserService:
self._is_authenticated = True
return success
def apply_to_vacancy(
self, vacancy_url: str, vacancy_name: str
) -> ApplicationResult:
def apply_to_vacancy(self, vacancy: Vacancy) -> ApplicationResult:
"""Подача заявки на вакансию"""
if not self.is_ready():
return ApplicationResult(
vacancy_id="",
vacancy_name=vacancy_name,
vacancy_name=vacancy.name,
success=False,
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:
"""Случайная пауза между действиями"""

View File

@ -84,8 +84,14 @@ class GeminiApiClient:
json_str = content[json_start:json_end]
parsed_response = json.loads(json_str)
score = parsed_response.get("match_score", 0)
logger.info(f"Gemini анализ завершен: {score}")
if "match_score" in parsed_response:
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
else:
logger.error("JSON не найден в ответе Gemini")
@ -330,3 +336,88 @@ class GeminiAIService:
return score >= settings.gemini.match_threshold
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"""