This commit is contained in:
queukat 2024-09-07 14:15:32 +03:00
parent 35cc5d3bde
commit b6ceeb44ac
8 changed files with 406 additions and 345 deletions

View file

@ -3,89 +3,28 @@ import os
import re
import textwrap
import time
from abc import ABC, abstractmethod
from typing import Dict, List, Union
from datetime import datetime
from functools import wraps
from pathlib import Path
from typing import Dict, List
import httpx
from Levenshtein import distance
from dotenv import load_dotenv
from httpx import HTTPStatusError
from langchain_core.messages.ai import AIMessage
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompt_values import StringPromptValue
from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI
from Levenshtein import distance
import src.strings as strings
from src.utils import logger
load_dotenv()
class AIModel(ABC):
@abstractmethod
def invoke(self, prompt: str) -> str:
pass
class OpenAIModel(AIModel):
def __init__(self, api_key: str, llm_model: str, llm_api_url: str):
from langchain_openai import ChatOpenAI
self.model = ChatOpenAI(model_name=llm_model, openai_api_key=api_key,
temperature=0.4, base_url=llm_api_url)
def invoke(self, prompt: str) -> str:
print("invoke in openai")
response = self.model.invoke(prompt)
return response
class ClaudeModel(AIModel):
def __init__(self, api_key: str, llm_model: str, llm_api_url: str):
from langchain_anthropic import ChatAnthropic
self.model = ChatAnthropic(model=llm_model, api_key=api_key,
temperature=0.4, base_url=llm_api_url)
def invoke(self, prompt: str) -> str:
response = self.model.invoke(prompt)
return response
class OllamaModel(AIModel):
def __init__(self, api_key: str, llm_model: str, llm_api_url: str):
from langchain_ollama import ChatOllama
self.model = ChatOllama(model=llm_model, base_url=llm_api_url)
def invoke(self, prompt: str) -> str:
response = self.model.invoke(prompt)
return response
class AIAdapter:
def __init__(self, config: dict, api_key: str):
self.model = self._create_model(config, api_key)
def _create_model(self, config: dict, api_key: str) -> AIModel:
llm_model_type = config['llm_model_type']
llm_model = config['llm_model']
llm_api_url = config['llm_api_url']
print('Using {0} with {1} from {2}'.format(llm_model_type, llm_model, llm_api_url))
if llm_model_type == "openai":
return OpenAIModel(api_key, llm_model, llm_api_url)
elif llm_model_type == "claude":
return ClaudeModel(api_key, llm_model, llm_api_url)
elif llm_model_type == "ollama":
return OllamaModel(api_key, llm_model, llm_api_url)
else:
raise ValueError(f"Unsupported model type: {model_type}")
def invoke(self, prompt: str) -> str:
return self.model.invoke(prompt)
class LLMLogger:
def __init__(self, llm: Union[OpenAIModel, OllamaModel, ClaudeModel]):
def __init__(self, llm: ChatOpenAI):
logger.debug("Initializing LLMLogger with LLM: %s", llm)
self.llm = llm
logger.debug("LLMLogger successfully initialized with LLM: %s", llm)
@ -108,7 +47,6 @@ class LLMLogger:
prompts = prompts.text
logger.debug("Prompts converted to text: %s", prompts)
elif isinstance(prompts, Dict):
# Convert prompts to a dictionary if they are not in the expected format
logger.debug("Prompts are of type Dict")
try:
prompts = {
@ -137,7 +75,7 @@ class LLMLogger:
except Exception as e:
logger.error("Error obtaining current time: %s", str(e))
raise
# Extract token usage details from the response
try:
token_usage = parsed_reply["usage_metadata"]
output_tokens = token_usage["output_tokens"]
@ -147,14 +85,14 @@ class LLMLogger:
except KeyError as e:
logger.error("KeyError in parsed_reply structure: %s", str(e))
raise
# Extract model details from the response
try:
model_name = parsed_reply["response_metadata"]["model_name"]
logger.debug("Model name: %s", model_name)
except KeyError as e:
logger.error("KeyError in response_metadata: %s", str(e))
raise
# Calculate the total cost of the API call
try:
prompt_price_per_token = 0.00000015
completion_price_per_token = 0.0000006
@ -169,7 +107,7 @@ class LLMLogger:
"model": model_name,
"time": current_time,
"prompts": prompts,
"replies": parsed_reply["content"], # Response content
"replies": parsed_reply["content"],
"total_tokens": total_tokens,
"input_tokens": input_tokens,
"output_tokens": output_tokens,
@ -179,7 +117,7 @@ class LLMLogger:
except KeyError as e:
logger.error("Error creating log entry: missing key %s in parsed_reply", str(e))
raise
# Write the log entry to the log file in JSON format
try:
with open(calls_log, "a", encoding="utf-8") as f:
json_string = json.dumps(log_entry, ensure_ascii=False, indent=4)
@ -191,13 +129,12 @@ class LLMLogger:
class LoggerChatModel:
def __init__(self, llm: Union[OpenAIModel, OllamaModel, ClaudeModel]):
def __init__(self, llm: ChatOpenAI):
logger.debug("Initializing LoggerChatModel with LLM: %s", llm)
self.llm = llm
logger.debug("LoggerChatModel successfully initialized with LLM: %s", llm)
def __call__(self, messages: List[Dict[str, str]]) -> str:
# Call the LLM with the provided messages and log the response.
logger.debug("Entering __call__ method with messages: %s", messages)
while True:
try:
@ -221,18 +158,25 @@ class LoggerChatModel:
if retry_after:
wait_time = int(retry_after)
logger.warning("Rate limit exceeded. Waiting for %d seconds before retrying (extracted from 'retry-after' header)...", wait_time)
logger.warning(
"Rate limit exceeded. Waiting for %d seconds before retrying (extracted from 'retry-after' header)...",
wait_time)
time.sleep(wait_time)
elif retry_after_ms:
wait_time = int(retry_after_ms) / 1000.0
logger.warning("Rate limit exceeded. Waiting for %f seconds before retrying (extracted from 'retry-after-ms' header)...", wait_time)
logger.warning(
"Rate limit exceeded. Waiting for %f seconds before retrying (extracted from 'retry-after-ms' header)...",
wait_time)
time.sleep(wait_time)
else:
wait_time = 30 # Время ожидания по умолчанию
logger.warning("'retry-after' header not found. Waiting for %d seconds before retrying (default)...", wait_time)
wait_time = 30
logger.warning(
"'retry-after' header not found. Waiting for %d seconds before retrying (default)...",
wait_time)
time.sleep(wait_time)
else:
logger.error("HTTP error occurred with status code: %d, waiting 30 seconds before retrying", e.response.status_code)
logger.error("HTTP error occurred with status code: %d, waiting 30 seconds before retrying",
e.response.status_code)
time.sleep(30)
except Exception as e:
@ -242,8 +186,6 @@ class LoggerChatModel:
continue
def parse_llmresult(self, llmresult: AIMessage) -> Dict[str, Dict]:
# Parse the LLM result into a structured format.
logger.debug("Parsing LLM result: %s", llmresult)
try:
@ -280,11 +222,11 @@ class LoggerChatModel:
raise
class GPTAnswerer:
def __init__(self, config, llm_api_key):
self.ai_adapter = AIAdapter(config, llm_api_key)
self.llm_cheap = LoggerChatModel(self.ai_adapter)
def __init__(self, openai_api_key):
self.llm_cheap = LoggerChatModel(
ChatOpenAI(model_name="gpt-4o-mini", openai_api_key=openai_api_key, temperature=0.4)
)
logger.debug("GPTAnswerer initialized with API key")
@property
@ -309,7 +251,6 @@ class GPTAnswerer:
@staticmethod
def _preprocess_template_string(template: str) -> str:
# Preprocess a template string to remove unnecessary indentation.
logger.debug("Preprocessing template string")
return textwrap.dedent(template)
@ -343,7 +284,6 @@ class GPTAnswerer:
return prompt | self.llm_cheap | StrOutputParser()
def answer_question_textual_wide_range(self, question: str) -> str:
# Define chains for each section of the resume
logger.debug("Answering textual question: %s", question)
chains = {
"personal_information": self._create_chain(strings.personal_information_template),
@ -452,17 +392,14 @@ class GPTAnswerer:
chain = prompt | self.llm_cheap | StrOutputParser()
output = chain.invoke({"question": question})
logger.debug("Section determined from question: %s", output)
match = re.search(r"(Personal information|Self Identification|Legal Authorization|Work Preferences|Education Details|Experience Details|Projects|Availability|Salary Expectations|Certifications|Languages|Interests|Cover letter)", output, re.IGNORECASE)
if not match:
raise ValueError("Could not extract section name from the response.")
section_name = match.group(1).lower().replace(" ", "_")
section_name = output.lower().replace(" ", "_")
if section_name == "cover_letter":
chain = chains.get(section_name)
output = chain.invoke({"resume": self.resume, "job_description": self.job_description})
logger.debug("Cover letter generated: %s", output)
return output
resume_section = getattr(self.resume, section_name, None) or getattr(self.job_application_profile, section_name, None)
resume_section = getattr(self.resume, section_name, None) or getattr(self.job_application_profile, section_name,
None)
if resume_section is None:
logger.error("Section '%s' not found in either resume or job_application_profile.", section_name)
raise ValueError(f"Section '{section_name}' not found in either resume or job_application_profile.")
@ -479,7 +416,9 @@ class GPTAnswerer:
func_template = self._preprocess_template_string(strings.numeric_question_template)
prompt = ChatPromptTemplate.from_template(func_template)
chain = prompt | self.llm_cheap | StrOutputParser()
output_str = chain.invoke({"resume_educations": self.resume.education_details,"resume_jobs": self.resume.experience_details,"resume_projects": self.resume.projects , "question": question})
output_str = chain.invoke(
{"resume_educations": self.resume.education_details, "resume_jobs": self.resume.experience_details,
"resume_projects": self.resume.projects, "question": question})
logger.debug("Raw output for numeric question: %s", output_str)
try:
output = self.extract_number_from_string(output_str)
@ -511,10 +450,12 @@ class GPTAnswerer:
return best_option
def resume_or_cover(self, phrase: str) -> str:
# Define the prompt template
logger.debug("Determining if phrase refers to resume or cover letter: %s", phrase)
prompt_template = """
Given the following phrase, respond with only 'resume' if the phrase is about a resume, or 'cover' if it's about a cover letter. If the phrase contains only the word 'upload', consider it as 'cover'. Do not provide any additional information or explanations.
Given the following phrase, respond with only 'resume' if the phrase is about a resume, or 'cover' if it's about a cover letter.
If the phrase contains only one word 'upload', consider it as 'cover'.
If the phrase contains 'upload resume', consider it as 'resume'.
Do not provide any additional information or explanations.
phrase: {phrase}
"""

View file

@ -1,5 +1,5 @@
from dataclasses import dataclass
from typing import Dict, List
import yaml
from src.utils import logger
@ -13,6 +13,7 @@ class SelfIdentification:
disability: str
ethnicity: str
@dataclass
class LegalAuthorization:
eu_work_authorization: str
@ -24,6 +25,7 @@ class LegalAuthorization:
legally_allowed_to_work_in_eu: str
requires_eu_sponsorship: str
@dataclass
class WorkPreferences:
remote_work: str
@ -33,14 +35,17 @@ class WorkPreferences:
willing_to_undergo_drug_tests: str
willing_to_undergo_background_checks: str
@dataclass
class Availability:
notice_period: str
@dataclass
class SalaryExpectations:
salary_range_usd: str
@dataclass
class JobApplicationProfile:
self_identification: SelfIdentification
@ -159,6 +164,7 @@ class JobApplicationProfile:
def __str__(self):
logger.debug("Generating string representation of JobApplicationProfile")
def format_dataclass(obj):
return "\n".join(f"{field.name}: {getattr(obj, field.name)}" for field in obj.__dataclass_fields__.values())

View file

@ -1,9 +1,10 @@
import random
import time
from selenium.common.exceptions import NoSuchElementException, TimeoutException
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.ui import WebDriverWait
from src.utils import logger
@ -107,7 +108,6 @@ class LinkedInAuthenticator:
buttons = self.driver.find_elements(By.CLASS_NAME, 'share-box-feed-entry__trigger')
logger.debug("Found %d 'Start a post' buttons", len(buttons))
# Выведем текст всех найденных кнопок в лог для диагностики
for i, button in enumerate(buttons):
logger.debug("Button %d text: %s", i + 1, button.text.strip())
@ -115,7 +115,6 @@ class LinkedInAuthenticator:
logger.info("Found 'Start a post' button indicating user is logged in.")
return True
# Альтернативная проверка авторизации по наличию изображения профиля
profile_img_elements = self.driver.find_elements(By.XPATH, "//img[contains(@alt, 'Photo of')]")
if profile_img_elements:
logger.info("Profile image found. Assuming user is logged in.")

View file

@ -23,6 +23,7 @@ class LinkedInBotState:
raise ValueError(f"{key.replace('_', ' ').capitalize()} must be set before proceeding.")
logger.debug("State validation passed")
class LinkedInBotFacade:
def __init__(self, login_component, apply_component):
logger.debug("Initializing LinkedInBotFacade")

View file

@ -3,26 +3,28 @@ import json
import os
import random
import re
import tempfile
import time
import traceback
from datetime import date
from typing import List, Optional, Any, Tuple
from httpx import HTTPStatusError
from openai import RateLimitError
from reportlab.lib.pagesizes import letter
from reportlab.pdfgen import canvas
from selenium.common.exceptions import NoSuchElementException, TimeoutException
from selenium.webdriver import ActionChains
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.remote.webelement import WebElement
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.ui import Select, WebDriverWait
from selenium.webdriver import ActionChains
import src.utils as utils
from src.utils import logger
class LinkedInEasyApplier:
def __init__(self, driver: Any, resume_dir: Optional[str], set_old_answers: List[Tuple[str, str, str]], gpt_answerer: Any, resume_generator_manager):
def __init__(self, driver: Any, resume_dir: Optional[str], set_old_answers: List[Tuple[str, str, str]],
gpt_answerer: Any, resume_generator_manager):
logger.debug("Initializing LinkedInEasyApplier")
if resume_dir is None or not os.path.exists(resume_dir):
resume_dir = None
@ -56,11 +58,28 @@ class LinkedInEasyApplier:
logger.error("Error loading questions data from JSON file: %s", tb_str)
raise Exception(f"Error loading questions data from JSON file: \nTraceback:\n{tb_str}")
def check_for_premium_redirect(self, job: Any, max_attempts=3):
"""Проверяет, был ли выполнен редирект на страницу LinkedIn Premium.
В случае редиректа возвращает пользователя на исходную страницу вакансии."""
current_url = self.driver.current_url
attempts = 0
while "linkedin.com/premium" in current_url and attempts < max_attempts:
logger.warning("Redirected to LinkedIn Premium page. Attempting to return to job page.")
attempts += 1
self.driver.get(job.link)
time.sleep(2)
current_url = self.driver.current_url
if "linkedin.com/premium" in current_url:
logger.error("Failed to return to job page after %d attempts. Cannot apply for the job.", max_attempts)
raise Exception(
f"Redirected to LinkedIn Premium page and failed to return after {max_attempts} attempts. Job application aborted.")
def job_apply(self, job: Any):
logger.debug("Starting job application for job: %s", job)
# Открываем страницу с вакансией
try:
self.driver.get(job.link)
logger.debug("Navigated to job link: %s", job.link)
@ -68,62 +87,60 @@ class LinkedInEasyApplier:
logger.error("Failed to navigate to job link: %s, error: %s", job.link, str(e))
raise
# Добавляем небольшую паузу для загрузки страницы
time.sleep(random.uniform(3, 5))
self.check_for_premium_redirect(job)
try:
# Поиск кнопки 'Easy Apply'
logger.debug("Searching for 'Easy Apply' button on job page")
easy_apply_button = self._find_easy_apply_button()
# Получаем описание вакансии
self.driver.execute_script("document.activeElement.blur();")
logger.debug("Focus removed from the active element")
self.check_for_premium_redirect(job)
easy_apply_button = self._find_easy_apply_button(job)
self.check_for_premium_redirect(job)
logger.debug("Retrieving job description")
job_description = self._get_job_description()
job.set_job_description(job_description)
logger.debug("Job description set: %s", job_description[:100]) # Логируем только первые 100 символов
logger.debug("Job description set: %s", job_description[:100])
# Получаем ссылку на рекрутера (если есть)
logger.debug("Retrieving recruiter link")
recruiter_link = self._get_job_recruiter()
job.set_recruiter_link(recruiter_link)
logger.debug("Recruiter link set: %s", recruiter_link)
# Действие: нажимаем на кнопку 'Easy Apply'
logger.debug("Attempting to click 'Easy Apply' button")
actions = ActionChains(self.driver)
actions.move_to_element(easy_apply_button).click().perform()
logger.debug("'Easy Apply' button clicked successfully")
# Передача информации о работе для дальнейшей обработки
logger.debug("Passing job information to GPT Answerer")
self.gpt_answerer.set_job(job)
# Заполнение формы подачи заявки
logger.debug("Filling out application form")
self._fill_application_form(job)
logger.debug("Job application process completed successfully for job: %s", job)
except Exception as e:
# Захват и логирование полного traceback в случае ошибки
tb_str = traceback.format_exc()
logger.error("Failed to apply to job: %s. Error traceback: %s", job, tb_str)
# Отмена заявки в случае ошибки
logger.debug("Discarding application due to failure")
self._discard_application()
# Поднятие исключения с оригинальной ошибкой
raise Exception(f"Failed to apply to job! Original exception:\nTraceback:\n{tb_str}")
def _find_easy_apply_button(self) -> WebElement:
def _find_easy_apply_button(self, job: Any) -> WebElement:
logger.debug("Searching for 'Easy Apply' button")
attempt = 0
# Список методов поиска кнопки
search_methods = [
{
'description': "find all 'Easy Apply' buttons using find_elements",
'find_elements': True, # Используем find_elements для поиска всех кнопок
'find_elements': True,
'xpath': '//button[contains(@class, "jobs-apply-button") and contains(., "Easy Apply")]'
},
{
@ -137,20 +154,21 @@ class LinkedInEasyApplier:
]
while attempt < 2:
self.check_for_premium_redirect(job)
self._scroll_page()
for method in search_methods:
try:
logger.debug(f"Attempting search using {method['description']}")
# Если метод использует find_elements
if method.get('find_elements'):
# Поиск всех кнопок "Easy Apply"
buttons = self.driver.find_elements(By.XPATH, method['xpath'])
if buttons:
for index, button in enumerate(buttons):
try:
# Проверка видимости и кликабельности каждой кнопки
WebDriverWait(self.driver, 10).until(EC.visibility_of(button))
WebDriverWait(self.driver, 10).until(EC.element_to_be_clickable(button))
logger.debug(f"Found 'Easy Apply' button {index + 1}, attempting to click")
@ -160,7 +178,7 @@ class LinkedInEasyApplier:
else:
raise TimeoutException("No 'Easy Apply' buttons found")
else:
# Стандартный метод с WebDriverWait для одного элемента
button = WebDriverWait(self.driver, 10).until(
EC.presence_of_element_located((By.XPATH, method['xpath']))
)
@ -172,7 +190,10 @@ class LinkedInEasyApplier:
except TimeoutException:
logger.warning(f"Timeout during search using {method['description']}")
except Exception as e:
logger.warning(f"Failed to click 'Easy Apply' button using {method['description']} on attempt {attempt + 1}: {e}")
logger.warning(
f"Failed to click 'Easy Apply' button using {method['description']} on attempt {attempt + 1}: {e}")
self.check_for_premium_redirect(job)
if attempt == 0:
logger.debug("Refreshing page to retry finding 'Easy Apply' button")
@ -180,7 +201,6 @@ class LinkedInEasyApplier:
time.sleep(random.randint(3, 5))
attempt += 1
# Если не удалось найти кнопку, выводим HTML для отладки
page_source = self.driver.page_source
logger.error("No clickable 'Easy Apply' button found after 2 attempts. Page source:\n%s", page_source)
raise Exception("No clickable 'Easy Apply' button found")
@ -189,7 +209,8 @@ class LinkedInEasyApplier:
logger.debug("Getting job description")
try:
try:
see_more_button = self.driver.find_element(By.XPATH, '//button[@aria-label="Click to see more description"]')
see_more_button = self.driver.find_element(By.XPATH,
'//button[@aria-label="Click to see more description"]')
actions = ActionChains(self.driver)
actions.move_to_element(see_more_button).click().perform()
time.sleep(2)
@ -216,7 +237,8 @@ class LinkedInEasyApplier:
)
logger.debug("Hiring team section found")
recruiter_elements = hiring_team_section.find_elements(By.XPATH, './/following::a[contains(@href, "linkedin.com/in/")]')
recruiter_elements = hiring_team_section.find_elements(By.XPATH,
'.//following::a[contains(@href, "linkedin.com/in/")]')
if recruiter_elements:
recruiter_element = recruiter_elements[0]
@ -289,18 +311,17 @@ class LinkedInEasyApplier:
def fill_up(self, job) -> None:
logger.debug("Filling up form sections for job: %s", job)
# Используем WebDriverWait для ожидания элемента с классом 'jobs-easy-apply-content'
try:
easy_apply_content = WebDriverWait(self.driver, 10).until(
EC.presence_of_element_located((By.CLASS_NAME, 'jobs-easy-apply-content'))
)
# После нахождения 'jobs-easy-apply-content' ищем элементы с классом 'pb4'
pb4_elements = easy_apply_content.find_elements(By.CLASS_NAME, 'pb4')
for element in pb4_elements:
self._process_form_element(element, job)
except Exception as e:
logger.error(f"Failed to find form elements: {e}")
def _process_form_element(self, element: WebElement, job) -> None:
logger.debug("Processing form element")
if self._is_upload_field(element):
@ -308,6 +329,47 @@ class LinkedInEasyApplier:
else:
self._fill_additional_questions()
def _handle_dropdown_fields(self, element: WebElement) -> None:
logger.debug("Handling dropdown fields")
dropdown = element.find_element(By.TAG_NAME, 'select')
select = Select(dropdown)
options = [option.text for option in select.options]
logger.debug(f"Dropdown options found: {options}")
parent_element = dropdown.find_element(By.XPATH, '../..')
label_elements = parent_element.find_elements(By.TAG_NAME, 'label')
if label_elements:
question_text = label_elements[0].text.lower()
else:
question_text = "unknown"
logger.debug(f"Detected question text: {question_text}")
existing_answer = None
for item in self.all_data:
if self._sanitize_text(question_text) in item['question'] and item['type'] == 'dropdown':
existing_answer = item['answer']
break
if existing_answer:
logger.debug(f"Found existing answer for question '{question_text}': {existing_answer}")
else:
logger.debug(f"No existing answer found, querying model for: {question_text}")
existing_answer = self.gpt_answerer.answer_question_from_options(question_text, options)
logger.debug(f"Model provided answer: {existing_answer}")
self._save_questions_to_json({'type': 'dropdown', 'question': question_text, 'answer': existing_answer})
if existing_answer in options:
select.select_by_visible_text(existing_answer)
logger.debug(f"Selected option: {existing_answer}")
else:
logger.error(f"Answer '{existing_answer}' is not a valid option in the dropdown")
raise Exception(f"Invalid option selected: {existing_answer}")
def _is_upload_field(self, element: WebElement) -> bool:
is_upload = bool(element.find_elements(By.XPATH, ".//input[@type='file']"))
logger.debug("Element is upload field: %s", is_upload)
@ -317,7 +379,8 @@ class LinkedInEasyApplier:
logger.debug("Handling upload fields")
try:
show_more_button = self.driver.find_element(By.XPATH, "//button[contains(@aria-label, 'Show more resumes')]")
show_more_button = self.driver.find_element(By.XPATH,
"//button[contains(@aria-label, 'Show more resumes')]")
show_more_button.click()
logger.debug("Clicked 'Show more resumes' button")
except NoSuchElementException:
@ -339,7 +402,7 @@ class LinkedInEasyApplier:
self._create_and_upload_resume(element, job)
elif 'cover' in output:
logger.debug("Uploading cover letter")
self._create_and_upload_cover_letter(element)
self._create_and_upload_cover_letter(element, job)
logger.debug("Finished handling upload fields")
@ -424,20 +487,69 @@ class LinkedInEasyApplier:
logger.error(f"Resume upload failed: {tb_str}")
raise Exception(f"Upload failed: \nTraceback:\n{tb_str}")
def _create_and_upload_cover_letter(self, element: WebElement) -> None:
logger.debug("Creating and uploading cover letter")
cover_letter = self.gpt_answerer.answer_question_textual_wide_range("Write a cover letter")
with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as temp_pdf_file:
letter_path = temp_pdf_file.name
c = canvas.Canvas(letter_path, pagesize=letter)
def _create_and_upload_cover_letter(self, element: WebElement, job) -> None:
logger.debug("Starting the process of creating and uploading cover letter.")
cover_letter_text = self.gpt_answerer.answer_question_textual_wide_range("Write a cover letter")
folder_path = 'generated_cv'
try:
if not os.path.exists(folder_path):
logger.debug(f"Creating directory at path: {folder_path}")
os.makedirs(folder_path, exist_ok=True)
except Exception as e:
logger.error(f"Failed to create directory: {folder_path}. Error: {e}")
raise
while True:
try:
timestamp = int(time.time())
file_path_pdf = os.path.join(folder_path, f"Cover_Letter_{timestamp}.pdf")
logger.debug(f"Generated file path for cover letter: {file_path_pdf}")
c = canvas.Canvas(file_path_pdf, pagesize=letter)
_, height = letter
text_object = c.beginText(100, height - 100)
text_object.setFont("Helvetica", 12)
text_object.textLines(cover_letter)
text_object.textLines(cover_letter_text)
c.drawText(text_object)
c.save()
element.send_keys(letter_path)
logger.debug("Cover letter created and uploaded successfully: %s", letter_path)
logger.debug(f"Cover letter successfully generated and saved to: {file_path_pdf}")
break
except Exception as e:
logger.error(f"Failed to generate cover letter: {e}")
tb_str = traceback.format_exc()
logger.error(f"Traceback: {tb_str}")
raise
file_size = os.path.getsize(file_path_pdf)
max_file_size = 2 * 1024 * 1024 # 2 MB
logger.debug(f"Cover letter file size: {file_size} bytes")
if file_size > max_file_size:
logger.error(f"Cover letter file size exceeds 2 MB: {file_size} bytes")
raise ValueError("Cover letter file size exceeds the maximum limit of 2 MB.")
allowed_extensions = {'.pdf', '.doc', '.docx'}
file_extension = os.path.splitext(file_path_pdf)[1].lower()
logger.debug(f"Cover letter file extension: {file_extension}")
if file_extension not in allowed_extensions:
logger.error(f"Invalid cover letter file format: {file_extension}")
raise ValueError("Cover letter file format is not allowed. Only PDF, DOC, and DOCX formats are supported.")
try:
logger.debug(f"Uploading cover letter from path: {file_path_pdf}")
element.send_keys(os.path.abspath(file_path_pdf))
job.cover_letter_path = os.path.abspath(file_path_pdf)
time.sleep(2)
logger.debug(f"Cover letter created and uploaded successfully: {file_path_pdf}")
except Exception as e:
tb_str = traceback.format_exc()
logger.error(f"Cover letter upload failed: {tb_str}")
raise Exception(f"Upload failed: \nTraceback:\n{tb_str}")
def _fill_additional_questions(self) -> None:
logger.debug("Filling additional questions")
@ -445,7 +557,6 @@ class LinkedInEasyApplier:
for section in form_sections:
self._process_form_section(section)
def _process_form_section(self, section: WebElement) -> None:
logger.debug("Processing form section")
if self._handle_terms_of_service(section):
@ -460,13 +571,15 @@ class LinkedInEasyApplier:
if self._find_and_handle_date_question(section):
logger.debug("Handled date question")
return
if self._find_and_handle_dropdown_question(section):
logger.debug("Handled dropdown question")
return
def _handle_terms_of_service(self, element: WebElement) -> bool:
checkbox = element.find_elements(By.TAG_NAME, 'label')
if checkbox and any(term in checkbox[0].text.lower() for term in ['terms of service', 'privacy policy', 'terms of use']):
if checkbox and any(
term in checkbox[0].text.lower() for term in ['terms of service', 'privacy policy', 'terms of use']):
checkbox[0].click()
logger.debug("Clicked terms of service checkbox")
return True
@ -502,31 +615,29 @@ class LinkedInEasyApplier:
if text_fields:
text_field = text_fields[0]
question_text = section.find_element(By.TAG_NAME, 'label').text.lower()
question_text = section.find_element(By.TAG_NAME, 'label').text.lower().strip()
logger.debug(f"Found text field with label: {question_text}")
is_numeric = self._is_numeric_field(text_field)
logger.debug(f"Is the field numeric? {'Yes' if is_numeric else 'No'}")
if is_numeric:
question_type = 'numeric'
answer = self.gpt_answerer.answer_question_numeric(question_text)
logger.debug(f"Generated numeric answer: {answer}")
else:
question_type = 'textbox'
answer = self.gpt_answerer.answer_question_textual_wide_range(question_text)
logger.debug(f"Generated textual answer: {answer}")
existing_answer = None
question_type = 'numeric' if is_numeric else 'textbox'
for item in self.all_data:
if item['question'] == self._sanitize_text(question_text) and item['type'] == question_type:
logger.debug(
f"Comparing sanitized stored question: '{self._sanitize_text(item['question'])}' and type: '{item.get('type')}' with current question: '{self._sanitize_text(question_text)}' and type: '{question_type}'")
if self._sanitize_text(item['question']) == self._sanitize_text(question_text) and item.get(
'type') == question_type:
existing_answer = item
logger.debug(f"Found existing answer in the data: {existing_answer['answer']}")
break
if existing_answer:
self._enter_text(text_field, existing_answer['answer'])
logger.debug("Entered existing textbox answer.")
logger.debug("Entered existing answer into the textbox.")
time.sleep(1)
text_field.send_keys(Keys.ARROW_DOWN)
@ -534,9 +645,16 @@ class LinkedInEasyApplier:
logger.debug("Selected first option from the dropdown.")
return True
if is_numeric:
answer = self.gpt_answerer.answer_question_numeric(question_text)
logger.debug(f"Generated numeric answer: {answer}")
else:
answer = self.gpt_answerer.answer_question_textual_wide_range(question_text)
logger.debug(f"Generated textual answer: {answer}")
self._save_questions_to_json({'type': question_type, 'question': question_text, 'answer': answer})
self._enter_text(text_field, answer)
logger.debug("Entered new textbox answer and saved it to JSON.")
logger.debug("Entered new answer into the textbox and saved it to JSON.")
time.sleep(1)
text_field.send_keys(Keys.ARROW_DOWN)
@ -555,7 +673,6 @@ class LinkedInEasyApplier:
answer_date = self.gpt_answerer.answer_question_date()
answer_text = answer_date.strftime("%Y-%m-%d")
existing_answer = None
for item in self.all_data:
if self._sanitize_text(question_text) in item['question'] and item['type'] == 'date':
@ -574,56 +691,44 @@ class LinkedInEasyApplier:
def _find_and_handle_dropdown_question(self, section: WebElement) -> bool:
try:
question = section.find_element(By.CLASS_NAME, 'jobs-easy-apply-form-element')
question_text = question.find_element(By.TAG_NAME, 'label').text.lower()
logger.debug(f"Processing dropdown or combobox question: {question_text}")
try:
dropdown = question.find_element(By.TAG_NAME, 'select')
dropdowns = question.find_elements(By.TAG_NAME, 'select')
if dropdowns:
dropdown = dropdowns[0]
select = Select(dropdown)
options = [option.text for option in select.options]
logger.debug(f"Dropdown options found: {options}")
current_selection = select.first_selected_option.text
logger.debug(f"Current selection: {current_selection}")
existing_answer = None
for item in self.all_data:
if self._sanitize_text(question_text) in item['question'] and item['type'] == 'dropdown':
existing_answer = item
existing_answer = item['answer']
break
if existing_answer:
self._select_dropdown_option(dropdown, existing_answer['answer'])
logger.debug("Selected existing dropdown answer")
logger.debug(f"Found existing answer for question '{question_text}': {existing_answer}")
if current_selection != existing_answer:
logger.debug(f"Updating selection to: {existing_answer}")
self._select_dropdown_option(dropdown, existing_answer)
return True
logger.debug(f"No existing answer found, querying model for: {question_text}")
answer = self.gpt_answerer.answer_question_from_options(question_text, options)
self._save_questions_to_json({'type': 'dropdown', 'question': question_text, 'answer': answer})
self._select_dropdown_option(dropdown, answer)
logger.debug("Selected new dropdown answer")
return True
except NoSuchElementException:
combobox = question.find_element(By.TAG_NAME, 'input')
logger.debug(f"Found combobox with ID: {combobox.get_attribute('id')}")
existing_answer = None
for item in self.all_data:
if self._sanitize_text(question_text) in item['question'] and item['type'] == 'combobox':
existing_answer = item
break
if existing_answer:
self._enter_text(combobox, existing_answer['answer'])
logger.debug("Entered existing combobox answer")
return True
answer = self.gpt_answerer.answer_question_textual_wide_range(question_text)
self._save_questions_to_json({'type': 'combobox', 'question': question_text, 'answer': answer})
self._enter_text(combobox, answer)
logger.debug("Entered new combobox answer")
logger.debug(f"Selected new dropdown answer: {answer}")
return True
return False
except Exception as e:
logger.warning("Failed to handle dropdown or combobox question: %s", e)
logger.warning(f"Failed to handle dropdown or combobox question: {e}")
return False
def _is_numeric_field(self, field: WebElement) -> bool:
@ -677,7 +782,6 @@ class LinkedInEasyApplier:
logger.error("Error saving questions data to JSON file: %s", tb_str)
raise Exception(f"Error saving questions data to JSON file: \nTraceback:\n{tb_str}")
def _sanitize_text(self, text: str) -> str:
sanitized_text = text.lower().strip().replace('"', '').replace('\\', '')
sanitized_text = re.sub(r'[\x00-\x1F\x7F]', '', sanitized_text).replace('\n', ' ').replace('\r', '').rstrip(',')

View file

@ -1,15 +1,16 @@
import json
import os
import random
import time
import traceback
from itertools import product
from pathlib import Path
from selenium.common.exceptions import NoSuchElementException
from selenium.webdriver.common.by import By
import src.utils as utils
from src.job import Job
from src.linkedIn_easy_applier import LinkedInEasyApplier
import json
from src.utils import logger
@ -33,6 +34,7 @@ class EnvironmentKeys:
logger.debug("Read environment key %s as bool: %s", key, value)
return value
class LinkedInJobManager:
def __init__(self, driver):
logger.debug("Initializing LinkedInJobManager")
@ -47,7 +49,6 @@ class LinkedInJobManager:
self.title_blacklist = parameters.get('titleBlacklist', []) or []
self.positions = parameters.get('positions', [])
self.locations = parameters.get('locations', [])
self.apply_once_at_company = parameters.get('applyOnceAtCompany', False)
self.base_search_url = self.get_base_search_url(parameters)
self.seen_jobs = []
resume_path = parameters.get('uploads', {}).get('resume', None)
@ -66,7 +67,8 @@ class LinkedInJobManager:
def start_applying(self):
logger.debug("Starting job application process")
self.easy_applier_component = LinkedInEasyApplier(self.driver, self.resume_path, self.set_old_answers, self.gpt_answerer, self.resume_generator_manager)
self.easy_applier_component = LinkedInEasyApplier(self.driver, self.resume_path, self.set_old_answers,
self.gpt_answerer, self.resume_generator_manager)
searches = list(product(self.positions, self.locations))
random.shuffle(searches)
page_sleep = 0
@ -134,9 +136,13 @@ class LinkedInJobManager:
time.sleep(sleep_time)
page_sleep += 1
def get_jobs_from_page(self):
"""
Функция для получения списка вакансий на текущей странице.
Если вакансии не найдены, возвращает пустой список.
"""
try:
no_jobs_element = self.driver.find_element(By.CLASS_NAME, 'jobs-search-two-pane__no-results-banner--expand')
if 'No matching jobs found' in no_jobs_element.text or 'unfortunately, things aren' in self.driver.page_source.lower():
utils.printyellow("No matching jobs found on this page.")
@ -151,7 +157,8 @@ class LinkedInJobManager:
utils.scroll_slow(self.driver, job_results)
utils.scroll_slow(self.driver, job_results, step=300, reverse=True)
job_list_elements = self.driver.find_elements(By.CLASS_NAME, 'scaffold-layout__list-container')[0].find_elements(By.CLASS_NAME, 'jobs-search-results__list-item')
job_list_elements = self.driver.find_elements(By.CLASS_NAME, 'scaffold-layout__list-container')[
0].find_elements(By.CLASS_NAME, 'jobs-search-results__list-item')
if not job_list_elements:
utils.printyellow("No job class elements found on page.")
logger.debug("No job class elements found on page, skipping.")
@ -180,7 +187,8 @@ class LinkedInJobManager:
job_results = self.driver.find_element(By.CLASS_NAME, "jobs-search-results-list")
utils.scroll_slow(self.driver, job_results)
utils.scroll_slow(self.driver, job_results, step=300, reverse=True)
job_list_elements = self.driver.find_elements(By.CLASS_NAME, 'scaffold-layout__list-container')[0].find_elements(By.CLASS_NAME, 'jobs-search-results__list-item')
job_list_elements = self.driver.find_elements(By.CLASS_NAME, 'scaffold-layout__list-container')[
0].find_elements(By.CLASS_NAME, 'jobs-search-results__list-item')
if not job_list_elements:
utils.printyellow("No job class elements found on page, moving to next page.")
logger.debug("No job class elements found on page, skipping")
@ -192,12 +200,6 @@ class LinkedInJobManager:
logger.debug("Job blacklisted: %s at %s", job.title, job.company)
self.write_to_file(job, "skipped")
continue
if self.is_already_applied_to_job(job.title, job.company, job.link):
self.write_to_file(job, "skipped")
continue
if self.is_already_applied_to_company(job.company):
self.write_to_file(job, "skipped")
continue
try:
if job.apply_method not in {"Continue", "Applied", "Apply"}:
self.easy_applier_component.job_apply(job)
@ -244,7 +246,8 @@ class LinkedInJobManager:
url_parts = []
if parameters['remote']:
url_parts.append("f_CF=f_WRA")
experience_levels = [str(i+1) for i, (level, v) in enumerate(parameters.get('experienceLevel', {}).items()) if v]
experience_levels = [str(i + 1) for i, (level, v) in enumerate(parameters.get('experienceLevel', {}).items()) if
v]
if experience_levels:
url_parts.append(f"f_E={','.join(experience_levels)}")
url_parts.append(f"distance={parameters['distance']}")
@ -266,7 +269,8 @@ class LinkedInJobManager:
def next_job_page(self, position, location, job_page):
logger.debug("Navigating to next job page: %s in %s, page %d", position, location, job_page)
self.driver.get(f"https://www.linkedin.com/jobs/search/{self.base_search_url}&keywords={position}{location}&start={job_page * 25}")
self.driver.get(
f"https://www.linkedin.com/jobs/search/{self.base_search_url}&keywords={position}{location}&start={job_page * 25}")
def extract_job_information_from_tile(self, job_tile):
logger.debug("Extracting job information from tile")
@ -287,7 +291,7 @@ class LinkedInJobManager:
try:
apply_method = job_tile.find_element(By.CLASS_NAME, 'job-card-container__apply-method').text
except NoSuchElementException:
apply_method = "Applied" # Подразумеваем, что вакансия уже подана
apply_method = "Applied"
utils.printyellow("Apply method not found, assuming 'Applied'.")
logger.warning("Apply method not found, assuming 'Applied'.")
@ -299,33 +303,6 @@ class LinkedInJobManager:
title_blacklisted = any(word in job_title_words for word in self.title_blacklist)
company_blacklisted = company.strip().lower() in (word.strip().lower() for word in self.company_blacklist)
link_seen = link in self.seen_jobs
is_blacklisted = title_blacklisted or company_blacklisted or link_seen
logger.debug("Job blacklisted status: %s", is_blacklisted)
return is_blacklisted
def is_already_applied_to_job(self, job_title, company, link):
link_seen = link in self.seen_jobs
if link_seen:
utils.printyellow(f"Already applied to job: {job_title} at {company}, skipping...")
return link_seen
def is_already_applied_to_company(self, company):
if not self.apply_once_at_company:
return False
output_files = ["success.json"]
for file_name in output_files:
file_path = self.output_file_directory / file_name
if file_path.exists():
with open(file_path, 'r', encoding='utf-8') as f:
try:
existing_data = json.load(f)
for applied_job in existing_data:
if applied_job['company'].strip().lower() == company.strip().lower():
utils.printyellow(f"Already applied at {company} (once per company policy), skipping...")
return True
except json.JSONDecodeError:
continue
return False

View file

@ -181,7 +181,7 @@ Answer the following question based on the provided language skills.
- Answer questions directly.
- If it seems likely that you have the experience, even if not explicitly defined, answer as if you have the experience.
- If unsure, respond with "I have no experience with that, but I learn fast" or "Not yet, but willing to learn."
- Keep the answer under 140 characters.
- Keep the answer under 140 characters. Do not add any additional languages what is not in my experience
## Example
My resume: Fluent in Italian and English.
@ -238,7 +238,6 @@ This comprehensive overview will serve as a guideline for the recruitment proces
# Job Description Summary"""
coverletter_template = """
Compose a brief and impactful cover letter based on the provided job description and resume. The letter should be no longer than three paragraphs and should be written in a professional, yet conversational tone. Avoid using any placeholders, and ensure that the letter flows naturally and is tailored to the job.
@ -371,7 +370,6 @@ Options: [1-2, 3-5, 6-10, 10+]
## """
try_to_fix_template = """\
The objective is to fix the text of a form input on a web page.

View file

@ -1,23 +1,32 @@
import logging
import os
import random
import time
from selenium import webdriver
import logging
log_file = "app_log.log"
logging.basicConfig(
level=logging.DEBUG,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler(log_file, mode='a', encoding='utf-8'),
logging.StreamHandler()
],
force=True # This will reset the root logger's handlers and apply the new configuration
)
# Настройка логирования
logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
# Отключаем логирование для selenium и urllib3
logging.getLogger("selenium.webdriver.remote.remote_connection").setLevel(logging.WARNING)
logging.getLogger("urllib3").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)
file_handler = logging.FileHandler(log_file, mode='a', encoding='utf-8')
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
logger.setLevel(logging.DEBUG)
chromeProfilePath = os.path.join(os.getcwd(), "chrome_profile", "linkedin_profile")
def ensure_chrome_profile():
logger.debug("Ensuring Chrome profile exists at path: %s", chromeProfilePath)
profile_dir = os.path.dirname(chromeProfilePath)
@ -29,51 +38,74 @@ def ensure_chrome_profile():
logger.debug("Created Chrome profile directory: %s", chromeProfilePath)
return chromeProfilePath
def is_scrollable(element):
scroll_height = element.get_attribute("scrollHeight")
client_height = element.get_attribute("clientHeight")
scrollable = int(scroll_height) > int(client_height)
logger.debug("Element scrollable check: scrollHeight=%s, clientHeight=%s, scrollable=%s", scroll_height, client_height, scrollable)
logger.debug("Element scrollable check: scrollHeight=%s, clientHeight=%s, scrollable=%s", scroll_height,
client_height, scrollable)
return scrollable
def scroll_slow(driver, scrollable_element, start=0, end=3600, step=300, reverse=False):
logger.debug("Starting slow scroll: start=%d, end=%d, step=%d, reverse=%s", start, end, step, reverse)
if reverse:
start, end = end, start
step = -step
if step == 0:
logger.error("Step value cannot be zero.")
raise ValueError("Step cannot be zero.")
max_scroll_height = int(scrollable_element.get_attribute("scrollHeight"))
current_scroll_position = int(scrollable_element.get_attribute("scrollTop"))
logger.debug("Max scroll height of the element: %d", max_scroll_height)
logger.debug("Current scroll position: %d", current_scroll_position)
if reverse:
if current_scroll_position < start:
start = current_scroll_position
logger.debug("Adjusted start position for upward scroll: %d", start)
else:
if end > max_scroll_height:
logger.warning("End value exceeds the scroll height. Adjusting end to %d", max_scroll_height)
end = max_scroll_height
script_scroll_to = "arguments[0].scrollTop = arguments[1];"
try:
if scrollable_element.is_displayed():
if not is_scrollable(scrollable_element):
logger.warning("The element is not scrollable.")
print("The element is not scrollable.")
return
if (step > 0 and start >= end) or (step < 0 and start <= end):
logger.warning("No scrolling will occur due to incorrect start/end values.")
print("No scrolling will occur due to incorrect start/end values.")
return
for position in range(start, end, step):
position = start
while (step > 0 and position < end) or (step < 0 and position > end):
try:
driver.execute_script(script_scroll_to, scrollable_element, position)
logger.debug("Scrolled to position: %d", position)
except Exception as e:
logger.error("Error during scrolling: %s", e)
print(f"Error during scrolling: {e}")
time.sleep(random.uniform(1.0, 1.6))
position += step
step = max(10, abs(step) - 10) * (-1 if reverse else 1)
time.sleep(random.uniform(0.6, 1.5))
driver.execute_script(script_scroll_to, scrollable_element, end)
logger.debug("Scrolled to final position: %d", end)
time.sleep(1)
time.sleep(0.5)
else:
logger.warning("The element is not visible.")
print("The element is not visible.")
@ -81,7 +113,8 @@ def scroll_slow(driver, scrollable_element, start=0, end=3600, step=300, reverse
logger.error("Exception occurred during scrolling: %s", e)
print(f"Exception occurred: {e}")
def chromeBrowserOptions():
def chrome_browser_options():
logger.debug("Setting Chrome browser options")
ensure_chrome_profile()
options = webdriver.ChromeOptions()
@ -112,10 +145,10 @@ def chromeBrowserOptions():
options.add_experimental_option("prefs", prefs)
if len(chromeProfilePath) > 0:
initialPath = os.path.dirname(chromeProfilePath)
profileDir = os.path.basename(chromeProfilePath)
options.add_argument('--user-data-dir=' + initialPath)
options.add_argument("--profile-directory=" + profileDir)
initial_path = os.path.dirname(chromeProfilePath)
profile_dir = os.path.basename(chromeProfilePath)
options.add_argument('--user-data-dir=' + initial_path)
options.add_argument("--profile-directory=" + profile_dir)
logger.debug("Using Chrome profile directory: %s", chromeProfilePath)
else:
options.add_argument("--incognito")
@ -123,14 +156,16 @@ def chromeBrowserOptions():
return options
def printred(text):
RED = "\033[91m"
RESET = "\033[0m"
red = "\033[91m"
reset = "\033[0m"
logger.debug("Printing text in red: %s", text)
print(f"{RED}{text}{RESET}")
print(f"{red}{text}{reset}")
def printyellow(text):
YELLOW = "\033[93m"
RESET = "\033[0m"
yellow = "\033[93m"
reset = "\033[0m"
logger.debug("Printing text in yellow: %s", text)
print(f"{YELLOW}{text}{RESET}")
print(f"{yellow}{text}{reset}")