diff --git a/src/gpt.py b/src/gpt.py index 9495e2a..97a1168 100644 --- a/src/gpt.py +++ b/src/gpt.py @@ -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,11 +47,10 @@ 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 = { - f"prompt_{i+1}": prompt.content + f"prompt_{i + 1}": prompt.content for i, prompt in enumerate(prompts.messages) } logger.debug("Prompts converted to dictionary: %s", prompts) @@ -123,7 +61,7 @@ class LLMLogger: logger.debug("Prompts are of unknown type, attempting default conversion") try: prompts = { - f"prompt_{i+1}": prompt.content + f"prompt_{i + 1}": prompt.content for i, prompt in enumerate(prompts.messages) } logger.debug("Prompts converted to dictionary using default method: %s", 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) @@ -336,14 +277,13 @@ class GPTAnswerer: output = chain.invoke({"text": text}) logger.debug("Summary generated: %s", output) return output - + def _create_chain(self, template: str): logger.debug("Creating chain with template: %s", template) prompt = ChatPromptTemplate.from_template(template) 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} """ diff --git a/src/job_application_profile.py b/src/job_application_profile.py index 43c4db1..5330c2b 100644 --- a/src/job_application_profile.py +++ b/src/job_application_profile.py @@ -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()) diff --git a/src/linkedIn_authenticator.py b/src/linkedIn_authenticator.py index d84fc22..8136d89 100644 --- a/src/linkedIn_authenticator.py +++ b/src/linkedIn_authenticator.py @@ -1,15 +1,16 @@ 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 class LinkedInAuthenticator: - + def __init__(self, driver=None): self.driver = driver self.email = "" @@ -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.") diff --git a/src/linkedIn_bot_facade.py b/src/linkedIn_bot_facade.py index f87b9da..2f1732c 100644 --- a/src/linkedIn_bot_facade.py +++ b/src/linkedIn_bot_facade.py @@ -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") diff --git a/src/linkedIn_easy_applier.py b/src/linkedIn_easy_applier.py index d3f9bbc..137af0b 100644 --- a/src/linkedIn_easy_applier.py +++ b/src/linkedIn_easy_applier.py @@ -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,112 +402,160 @@ 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") def _create_and_upload_resume(self, element, job): - logger.debug("Starting the process of creating and uploading resume.") - folder_path = 'generated_cv' + logger.debug("Starting the process of creating and uploading resume.") + 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: - if not os.path.exists(folder_path): - logger.debug(f"Creating directory at path: {folder_path}") - os.makedirs(folder_path, exist_ok=True) + timestamp = int(time.time()) + file_path_pdf = os.path.join(folder_path, f"CV_{timestamp}.pdf") + logger.debug(f"Generated file path for resume: {file_path_pdf}") + + logger.debug(f"Generating resume for job: {job.title} at {job.company}") + resume_pdf_base64 = self.resume_generator_manager.pdf_base64(job_description_text=job.description) + with open(file_path_pdf, "xb") as f: + f.write(base64.b64decode(resume_pdf_base64)) + logger.debug(f"Resume successfully generated and saved to: {file_path_pdf}") + + break + except HTTPStatusError as e: + if e.response.status_code == 429: + + retry_after = e.response.headers.get('retry-after') + retry_after_ms = e.response.headers.get('retry-after-ms') + + if retry_after: + wait_time = int(retry_after) + logger.warning(f"Rate limit exceeded, waiting {wait_time} seconds before retrying...") + elif retry_after_ms: + wait_time = int(retry_after_ms) / 1000.0 + logger.warning(f"Rate limit exceeded, waiting {wait_time} milliseconds before retrying...") + else: + wait_time = 20 + logger.warning(f"Rate limit exceeded, waiting {wait_time} seconds before retrying...") + + time.sleep(wait_time) + else: + logger.error(f"HTTP error: {e}") + raise + except Exception as e: - logger.error(f"Failed to create directory: {folder_path}. Error: {e}") + logger.error(f"Failed to generate resume: {e}") + tb_str = traceback.format_exc() + logger.error(f"Traceback: {tb_str}") + if "RateLimitError" in str(e): + logger.warning("Rate limit error encountered, retrying...") + time.sleep(20) + else: + raise + + file_size = os.path.getsize(file_path_pdf) + max_file_size = 2 * 1024 * 1024 # 2 MB + logger.debug(f"Resume file size: {file_size} bytes") + if file_size > max_file_size: + logger.error(f"Resume file size exceeds 2 MB: {file_size} bytes") + raise ValueError("Resume 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"Resume file extension: {file_extension}") + if file_extension not in allowed_extensions: + logger.error(f"Invalid resume file format: {file_extension}") + raise ValueError("Resume file format is not allowed. Only PDF, DOC, and DOCX formats are supported.") + + try: + logger.debug(f"Uploading resume from path: {file_path_pdf}") + element.send_keys(os.path.abspath(file_path_pdf)) + job.pdf_path = os.path.abspath(file_path_pdf) + time.sleep(2) + logger.debug(f"Resume created and uploaded successfully: {file_path_pdf}") + except Exception as e: + tb_str = traceback.format_exc() + 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, 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) + c.drawText(text_object) + c.save() + 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 - while True: - try: - timestamp = int(time.time()) - file_path_pdf = os.path.join(folder_path, f"CV_{timestamp}.pdf") - logger.debug(f"Generated file path for resume: {file_path_pdf}") + 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.") - logger.debug(f"Generating resume for job: {job.title} at {job.company}") - resume_pdf_base64 = self.resume_generator_manager.pdf_base64(job_description_text=job.description) - with open(file_path_pdf, "xb") as f: - f.write(base64.b64decode(resume_pdf_base64)) - logger.debug(f"Resume successfully generated and saved to: {file_path_pdf}") + 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.") - break - except HTTPStatusError as e: - if e.response.status_code == 429: + try: - retry_after = e.response.headers.get('retry-after') - retry_after_ms = e.response.headers.get('retry-after-ms') - - if retry_after: - wait_time = int(retry_after) - logger.warning(f"Rate limit exceeded, waiting {wait_time} seconds before retrying...") - elif retry_after_ms: - wait_time = int(retry_after_ms) / 1000.0 - logger.warning(f"Rate limit exceeded, waiting {wait_time} milliseconds before retrying...") - else: - wait_time = 20 - logger.warning(f"Rate limit exceeded, waiting {wait_time} seconds before retrying...") - - time.sleep(wait_time) - else: - logger.error(f"HTTP error: {e}") - raise - - except Exception as e: - logger.error(f"Failed to generate resume: {e}") - tb_str = traceback.format_exc() - logger.error(f"Traceback: {tb_str}") - if "RateLimitError" in str(e): - logger.warning("Rate limit error encountered, retrying...") - time.sleep(20) - else: - raise - - file_size = os.path.getsize(file_path_pdf) - max_file_size = 2 * 1024 * 1024 # 2 MB - logger.debug(f"Resume file size: {file_size} bytes") - if file_size > max_file_size: - logger.error(f"Resume file size exceeds 2 MB: {file_size} bytes") - raise ValueError("Resume 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"Resume file extension: {file_extension}") - if file_extension not in allowed_extensions: - logger.error(f"Invalid resume file format: {file_extension}") - raise ValueError("Resume file format is not allowed. Only PDF, DOC, and DOCX formats are supported.") - - try: - logger.debug(f"Uploading resume from path: {file_path_pdf}") - element.send_keys(os.path.abspath(file_path_pdf)) - job.pdf_path = os.path.abspath(file_path_pdf) - time.sleep(2) - logger.debug(f"Resume created and uploaded successfully: {file_path_pdf}") - except Exception as e: - tb_str = traceback.format_exc() - 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) - _, height = letter - text_object = c.beginText(100, height - 100) - text_object.setFont("Helvetica", 12) - text_object.textLines(cover_letter) - 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"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") form_sections = self.driver.find_elements(By.CLASS_NAME, 'jobs-easy-apply-form-section__grouping') for section in form_sections: self._process_form_section(section) - def _process_form_section(self, section: WebElement) -> None: logger.debug("Processing form 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 @@ -478,7 +591,7 @@ class LinkedInEasyApplier: if radios: question_text = section.text.lower() options = [radio.text.lower() for radio in radios] - + existing_answer = None for item in self.all_data: if self._sanitize_text(question_text) in item['question'] and item['type'] == 'radio': @@ -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(',') diff --git a/src/linkedIn_job_manager.py b/src/linkedIn_job_manager.py index 82602dc..88227c8 100644 --- a/src/linkedIn_job_manager.py +++ b/src/linkedIn_job_manager.py @@ -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,24 +187,19 @@ 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") return - job_list = [Job(*self.extract_job_information_from_tile(job_element)) for job_element in job_list_elements] + job_list = [Job(*self.extract_job_information_from_tile(job_element)) for job_element in job_list_elements] for job in job_list: if self.is_blacklisted(job.title, job.company, job.link): utils.printyellow(f"Blacklisted {job.title} at {job.company}, skipping...") 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) @@ -208,7 +210,7 @@ class LinkedInJobManager: utils.printred(f"Failed to apply for {job.title} at {job.company}: {e}") self.write_to_file(job, "failed") continue - + def write_to_file(self, job, file_name): logger.debug("Writing job application result to file: %s", file_name) pdf_path = Path(job.pdf_path).resolve() @@ -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']}") @@ -263,11 +266,12 @@ class LinkedInJobManager: full_url = f"?{base_url}{date_param}" logger.debug("Base search URL constructed: %s", full_url) return full_url - + 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") job_title, company, job_location, apply_method, link = "", "", "", "", "" @@ -287,45 +291,18 @@ 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'.") return job_title, company, job_location, link, apply_method - + def is_blacklisted(self, job_title, company, link): logger.debug("Checking if job is blacklisted: %s at %s", job_title, company) job_title_words = job_title.lower().split(' ') 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 diff --git a/src/strings.py b/src/strings.py index f54abc1..16cb84e 100644 --- a/src/strings.py +++ b/src/strings.py @@ -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. diff --git a/src/utils.py b/src/utils.py index 61c40f0..44d022f 100644 --- a/src/utils.py +++ b/src/utils.py @@ -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 end > max_scroll_height: - logger.warning("End value exceeds the scroll height. Adjusting end to %d", max_scroll_height) - end = max_scroll_height + 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): + return + + 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}")