From dfd79dc4c7ecf46e1c8116389d95cb470e583eb9 Mon Sep 17 00:00:00 2001 From: Shivam Sareen Date: Fri, 6 Sep 2024 12:34:25 -0700 Subject: [PATCH 01/21] Update README.md Added command to create virtual environment for windows based machine --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 0cd47de..f5bfeaa 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,11 @@ LinkedIn_AIHawk steps in as a game-changing solution to these challenges. It's n source virtual/bin/activate ``` + or for Windows-based machines - + ```bash + .\virtual\Scripts\activate + ``` + 5. **Install the required packages:** ```bash pip install -r requirements.txt From 82bd9aa9cc1e2400825df561b594237c049b111f Mon Sep 17 00:00:00 2001 From: Rohith Sv Date: Fri, 6 Sep 2024 19:29:06 -0400 Subject: [PATCH 02/21] gitignore updated to skip /virtual folder --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 6d06188..50bbd27 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,5 @@ generated_cv* .vscode chrome_profile answers.json -data* \ No newline at end of file +data* +*virtual \ No newline at end of file From 58584def99926abbef458b11fd481b554447b781 Mon Sep 17 00:00:00 2001 From: queukat Date: Sun, 8 Sep 2024 17:32:07 +0300 Subject: [PATCH 03/21] new func --- src/linkedIn_easy_applier.py | 74 +++++++++++++--- src/linkedIn_job_manager.py | 161 ++++++++++++++++++++++++++++++----- src/utils.py | 10 +++ 3 files changed, 212 insertions(+), 33 deletions(-) diff --git a/src/linkedIn_easy_applier.py b/src/linkedIn_easy_applier.py index 951fea8..eb7322a 100644 --- a/src/linkedIn_easy_applier.py +++ b/src/linkedIn_easy_applier.py @@ -8,7 +8,7 @@ import traceback from typing import List, Optional, Any, Tuple from httpx import HTTPStatusError -from reportlab.lib.pagesizes import letter +from reportlab.lib.pagesizes import A4 from reportlab.pdfgen import canvas from selenium.common.exceptions import NoSuchElementException, TimeoutException from selenium.webdriver import ActionChains @@ -62,8 +62,7 @@ class LinkedInEasyApplier: def check_for_premium_redirect(self, job: Any, max_attempts=3): - """Проверяет, был ли выполнен редирект на страницу LinkedIn Premium. - В случае редиректа возвращает пользователя на исходную страницу вакансии.""" + current_url = self.driver.current_url attempts = 0 @@ -514,11 +513,48 @@ class LinkedInEasyApplier: 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) + c = canvas.Canvas(file_path_pdf, pagesize=A4) + page_width, page_height = A4 + text_object = c.beginText(50, page_height - 50) text_object.setFont("Helvetica", 12) - text_object.textLines(cover_letter_text) + + max_width = page_width - 100 + bottom_margin = 50 + available_height = page_height - bottom_margin - 50 + + def split_text_by_width(text, font, font_size, max_width): + wrapped_lines = [] + for line in text.splitlines(): + + if utils.stringWidth(line, font, font_size) > max_width: + words = line.split() + new_line = "" + for word in words: + if utils.stringWidth(new_line + word + " ", font, font_size) <= max_width: + new_line += word + " " + else: + wrapped_lines.append(new_line.strip()) + new_line = word + " " + wrapped_lines.append(new_line.strip()) + else: + wrapped_lines.append(line) + return wrapped_lines + + + lines = split_text_by_width(cover_letter_text, "Helvetica", 12, max_width) + + for line in lines: + text_height = text_object.getY() + if text_height > bottom_margin: + text_object.textLine(line) + else: + + c.drawText(text_object) + c.showPage() + text_object = c.beginText(50, page_height - 50) + text_object.setFont("Helvetica", 12) + text_object.textLine(line) + c.drawText(text_object) c.save() logger.debug(f"Cover letter successfully generated and saved to: {file_path_pdf}") @@ -530,6 +566,7 @@ class LinkedInEasyApplier: 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") @@ -701,12 +738,14 @@ 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}") + # Если не удалось найти элемент с классом, пробуем искать по атрибуту 'data-test-text-entity-list-form-select' dropdowns = question.find_elements(By.TAG_NAME, 'select') + if not dropdowns: + dropdowns = section.find_elements(By.CSS_SELECTOR, '[data-test-text-entity-list-form-select]') + if dropdowns: dropdown = dropdowns[0] select = Select(dropdown) @@ -714,9 +753,14 @@ class LinkedInEasyApplier: logger.debug(f"Dropdown options found: {options}") + # Извлечение текста вопроса + question_text = question.find_element(By.TAG_NAME, 'label').text.lower() + logger.debug(f"Processing dropdown or combobox question: {question_text}") + 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': @@ -738,9 +782,15 @@ class LinkedInEasyApplier: logger.debug(f"Selected new dropdown answer: {answer}") return True - return False + else: + + logger.debug(f"No dropdown found. Logging elements for debugging.") + elements = section.find_elements(By.XPATH, ".//*") + logger.debug(f"Elements found: {[element.tag_name for element in elements]}") + return False + except Exception as e: - logger.warning(f"Failed to handle dropdown or combobox question: {e}") + logger.warning(f"Failed to handle dropdown or combobox question: {e}", exc_info=True) return False def _is_numeric_field(self, field: WebElement) -> bool: diff --git a/src/linkedIn_job_manager.py b/src/linkedIn_job_manager.py index 5b6e53b..adda476 100644 --- a/src/linkedIn_job_manager.py +++ b/src/linkedIn_job_manager.py @@ -5,6 +5,7 @@ import time from itertools import product from pathlib import Path +from inputimeout import inputimeout, TimeoutOccurred from selenium.common.exceptions import NoSuchElementException from selenium.webdriver.common.by import By @@ -45,13 +46,18 @@ class LinkedInJobManager: def set_parameters(self, parameters): logger.debug("Setting parameters for LinkedInJobManager") - self.company_blacklist = parameters.get('companyBlacklist', []) or [] + self.company_blacklist = parameters.get('company_blacklist', []) or [] 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 = [] + + job_applicants_threshold = parameters.get('job_applicants_threshold', {}) + self.min_applicants = job_applicants_threshold.get('min_applicants', 0) + self.max_applicants = job_applicants_threshold.get('max_applicants', float('inf')) + resume_path = parameters.get('uploads', {}).get('resume', None) self.resume_path = Path(resume_path) if resume_path and Path(resume_path).exists() else None self.output_file_directory = Path(parameters['outputFileDirectory']) @@ -109,32 +115,80 @@ class LinkedInJobManager: utils.printyellow("Applying to jobs on this page has been completed!") time_left = minimum_page_time - time.time() + + # Ask user if they want to skip waiting, with timeout if time_left > 0: - utils.printyellow(f"Sleeping for {time_left} seconds.") - logger.debug("Sleeping for %d seconds", time_left) - time.sleep(time_left) - minimum_page_time = time.time() + minimum_time + try: + user_input = inputimeout( + prompt=f"Sleeping for {time_left} seconds. Press 'y' to skip waiting. Timeout 10 seconds : ", + timeout=10).strip().lower() + except TimeoutOccurred: + user_input = '' # No input after timeout + if user_input == 'y': + logger.debug("User chose to skip waiting.") + utils.printyellow("User skipped waiting.") + else: + logger.debug(f"Sleeping for {time_left} seconds as user chose not to skip.") + utils.printyellow(f"Sleeping for {time_left} seconds.") + time.sleep(time_left) + + minimum_page_time = time.time() + minimum_time + if page_sleep % 5 == 0: sleep_time = random.randint(5, 34) - utils.printyellow(f"Sleeping for {sleep_time / 60} minutes.") - logger.debug("Sleeping for %d seconds", sleep_time) - time.sleep(sleep_time) + try: + user_input = inputimeout( + prompt=f"Sleeping for {sleep_time / 60} minutes. Press 'y' to skip waiting. Timeout 10 seconds : ", + timeout=10).strip().lower() + except TimeoutOccurred: + user_input = '' # No input after timeout + if user_input == 'y': + logger.debug("User chose to skip waiting.") + utils.printyellow("User skipped waiting.") + else: + logger.debug(f"Sleeping for {sleep_time} seconds.") + utils.printyellow(f"Sleeping for {sleep_time / 60} minutes.") + time.sleep(sleep_time) page_sleep += 1 except Exception as e: logger.error("Unexpected error during job search: %s", e) utils.printred(f"Unexpected error: {e}") continue + time_left = minimum_page_time - time.time() + if time_left > 0: - utils.printyellow(f"Sleeping for {time_left} seconds.") - logger.debug("Sleeping for %d seconds", time_left) - time.sleep(time_left) - minimum_page_time = time.time() + minimum_time + try: + user_input = inputimeout( + prompt=f"Sleeping for {time_left} seconds. Press 'y' to skip waiting. Timeout 10 seconds : ", + timeout=10).strip().lower() + except TimeoutOccurred: + user_input = '' # No input after timeout + if user_input == 'y': + logger.debug("User chose to skip waiting.") + utils.printyellow("User skipped waiting.") + else: + logger.debug(f"Sleeping for {time_left} seconds as user chose not to skip.") + utils.printyellow(f"Sleeping for {time_left} seconds.") + time.sleep(time_left) + + minimum_page_time = time.time() + minimum_time + if page_sleep % 5 == 0: sleep_time = random.randint(50, 90) - utils.printyellow(f"Sleeping for {sleep_time / 60} minutes.") - logger.debug("Sleeping for %d seconds", sleep_time) - time.sleep(sleep_time) + try: + user_input = inputimeout( + prompt=f"Sleeping for {sleep_time / 60} minutes. Press 'y' to skip waiting: ", + timeout=10).strip().lower() + except TimeoutOccurred: + user_input = '' # No input after timeout + if user_input == 'y': + logger.debug("User chose to skip waiting.") + utils.printyellow("User skipped waiting.") + else: + logger.debug(f"Sleeping for {sleep_time} seconds.") + utils.printyellow(f"Sleeping for {sleep_time / 60} minutes.") + time.sleep(sleep_time) page_sleep += 1 def get_jobs_from_page(self): @@ -183,16 +237,82 @@ class LinkedInJobManager: pass 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) + # 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') + 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] + for job in job_list: + + try: + logger.debug(f"Starting applicant count search for job: {job.title} at {job.company}") + + # Find all job insight elements + job_insight_elements = self.driver.find_elements(By.CLASS_NAME, + "job-details-jobs-unified-top-card__job-insight") + logger.debug(f"Found {len(job_insight_elements)} job insight elements") + + # Initialize applicants_count as None + applicants_count = None + + # Iterate over each job insight element to find the one containing the word "applicant" + for element in job_insight_elements: + logger.debug(f"Checking element text: {element.text}") + if "applicant" in element.text.lower(): + # Found an element containing "applicant" + applicants_text = element.text.strip() + logger.debug(f"Applicants text found: {applicants_text}") + + # Extract numeric digits from the text (e.g., "70 applicants" -> "70") + applicants_count = ''.join(filter(str.isdigit, applicants_text)) + logger.debug(f"Extracted applicants count: {applicants_count}") + + if applicants_count: + if "over" in applicants_text.lower(): + applicants_count = int(applicants_count) + 1 # Handle "over X applicants" + logger.debug(f"Applicants count adjusted for 'over': {applicants_count}") + else: + applicants_count = int(applicants_count) # Convert the extracted number to an integer + break + + # Check if applicants_count is valid (not None) before performing comparisons + if applicants_count is not None: + # Perform the threshold check for applicants count + if applicants_count < self.min_applicants or applicants_count > self.max_applicants: + utils.printyellow( + f"Skipping {job.title} at {job.company} due to applicants count: {applicants_count}") + logger.debug(f"Skipping {job.title} at {job.company}, applicants count: {applicants_count}") + self.write_to_file(job, "skipped_due_to_applicants") + continue # Skip this job if applicants count is outside the threshold + else: + logger.debug(f"Applicants count {applicants_count} is within the threshold") + else: + # If no applicants count was found, log a warning but continue the process + logger.warning( + f"Applicants count not found for {job.title} at {job.company}, continuing with application.") + except NoSuchElementException: + # Log a warning if the job insight elements are not found, but do not stop the job application process + logger.warning( + f"Applicants count elements not found for {job.title} at {job.company}, continuing with application.") + except ValueError as e: + # Handle errors when parsing the applicants count + logger.error(f"Error parsing applicants count for {job.title} at {job.company}: {e}") + except Exception as e: + # Catch any other exceptions to ensure the process continues + logger.error( + f"Unexpected error during applicants count processing for {job.title} at {job.company}: {e}") + + # Continue with the job application process regardless of the applicants count check + logger.debug(f"Continuing with job application for {job.title} at {job.company}") + 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) @@ -200,7 +320,7 @@ class LinkedInJobManager: continue if self.is_already_applied_to_job(job.title, job.company, job.link): self.write_to_file(job, "skipped") - continue + continue if self.is_already_applied_to_company(job.company): self.write_to_file(job, "skipped") continue @@ -307,7 +427,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 @@ -322,8 +441,8 @@ class LinkedInJobManager: def is_already_applied_to_company(self, company): if not self.apply_once_at_company: - return False - + return False + output_files = ["success.json"] for file_name in output_files: file_path = self.output_file_directory / file_name diff --git a/src/utils.py b/src/utils.py index 44d022f..f4e4d4a 100644 --- a/src/utils.py +++ b/src/utils.py @@ -90,7 +90,13 @@ def scroll_slow(driver, scrollable_element, start=0, end=3600, step=300, reverse return position = start + previous_position = None # Tracking the previous position to avoid duplicate scrolls while (step > 0 and position < end) or (step < 0 and position > end): + if position == previous_position: + # Avoid re-scrolling to the same position + logger.debug("Stopping scroll as position hasn't changed: %d", position) + break + try: driver.execute_script(script_scroll_to, scrollable_element, position) logger.debug("Scrolled to position: %d", position) @@ -98,11 +104,15 @@ def scroll_slow(driver, scrollable_element, start=0, end=3600, step=300, reverse logger.error("Error during scrolling: %s", e) print(f"Error during scrolling: {e}") + previous_position = position position += step + + # Decrease the step but ensure it doesn't reverse direction step = max(10, abs(step) - 10) * (-1 if reverse else 1) time.sleep(random.uniform(0.6, 1.5)) + # Ensure the final scroll position is correct driver.execute_script(script_scroll_to, scrollable_element, end) logger.debug("Scrolled to final position: %d", end) time.sleep(0.5) From 3f2fdb6742af001be5e231ff542ae8e1dd29d5fc Mon Sep 17 00:00:00 2001 From: queukat <75810528+queukat@users.noreply.github.com> Date: Sun, 8 Sep 2024 16:40:06 +0200 Subject: [PATCH 04/21] Update linkedIn_easy_applier.py --- src/linkedIn_easy_applier.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/linkedIn_easy_applier.py b/src/linkedIn_easy_applier.py index eb7322a..0861b15 100644 --- a/src/linkedIn_easy_applier.py +++ b/src/linkedIn_easy_applier.py @@ -166,7 +166,8 @@ class LinkedInEasyApplier: logger.debug(f"Attempting search using {method['description']}") if method.get('find_elements'): - # Поиск всех кнопок "Easy Apply" + + buttons = self.driver.find_elements(By.XPATH, method['xpath']) if buttons: for index, button in enumerate(buttons): @@ -738,10 +739,8 @@ 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') - # Если не удалось найти элемент с классом, пробуем искать по атрибуту 'data-test-text-entity-list-form-select' dropdowns = question.find_elements(By.TAG_NAME, 'select') if not dropdowns: dropdowns = section.find_elements(By.CSS_SELECTOR, '[data-test-text-entity-list-form-select]') @@ -753,14 +752,13 @@ class LinkedInEasyApplier: logger.debug(f"Dropdown options found: {options}") - # Извлечение текста вопроса + question_text = question.find_element(By.TAG_NAME, 'label').text.lower() logger.debug(f"Processing dropdown or combobox question: {question_text}") 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': From 62133ef0282f2137de4081b0badf72a57d034784 Mon Sep 17 00:00:00 2001 From: queukat Date: Sun, 8 Sep 2024 17:44:30 +0300 Subject: [PATCH 05/21] new func --- data_folder/config.yaml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/data_folder/config.yaml b/data_folder/config.yaml index 53b71f1..1cbe9ed 100644 --- a/data_folder/config.yaml +++ b/data_folder/config.yaml @@ -35,13 +35,17 @@ applyOnceAtCompany: [true/false] distance: 100 -companyBlacklist: +company_blacklist: - Company1 - Company2 titleBlacklist: - word1 - word2 + +job_applicants_threshold: + min_applicants: 0 + max_applicants: 100 llm_model_type: openai llm_model: gpt-4o From 5efe6c3048f57fbadf9ff1655a23cc02dc975b55 Mon Sep 17 00:00:00 2001 From: queukat Date: Sun, 8 Sep 2024 17:46:00 +0300 Subject: [PATCH 06/21] reformat code --- src/gpt.py | 26 ++++--- src/linkedIn_easy_applier.py | 11 --- src/linkedIn_job_manager.py | 4 +- src/linkedin-api.py | 139 ++++++++++++++++------------------- 4 files changed, 82 insertions(+), 98 deletions(-) diff --git a/src/gpt.py b/src/gpt.py index c22f123..4107797 100644 --- a/src/gpt.py +++ b/src/gpt.py @@ -3,11 +3,11 @@ import os import re import textwrap import time -from datetime import datetime from abc import ABC, abstractmethod -from typing import Dict, List, Union +from datetime import datetime from pathlib import Path from typing import Dict, List +from typing import Union import httpx from Levenshtein import distance @@ -16,39 +16,42 @@ 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 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) + 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 @@ -58,6 +61,7 @@ class OllamaModel(AIModel): 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) @@ -67,7 +71,7 @@ class AIAdapter: 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": @@ -80,9 +84,9 @@ class AIAdapter: def invoke(self, prompt: str) -> str: return self.model.invoke(prompt) + class LLMLogger: - def __init__(self, llm: Union[OpenAIModel, OllamaModel, ClaudeModel]): self.llm = llm @@ -189,7 +193,6 @@ class LLMLogger: class LoggerChatModel: - def __init__(self, llm: Union[OpenAIModel, OllamaModel, ClaudeModel]): self.llm = llm @@ -247,7 +250,6 @@ class LoggerChatModel: time.sleep(30) continue - def parse_llmresult(self, llmresult: AIMessage) -> Dict[str, Dict]: logger.debug("Parsing LLM result: %s", llmresult) @@ -454,12 +456,14 @@ class GPTAnswerer: chain = prompt | self.llm_cheap | StrOutputParser() output = chain.invoke({"question": question}) - 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) + 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(" ", "_") - + if section_name == "cover_letter": chain = chains.get(section_name) output = chain.invoke({"resume": self.resume, "job_description": self.job_description}) diff --git a/src/linkedIn_easy_applier.py b/src/linkedIn_easy_applier.py index 0861b15..cf64245 100644 --- a/src/linkedIn_easy_applier.py +++ b/src/linkedIn_easy_applier.py @@ -37,7 +37,6 @@ class LinkedInEasyApplier: logger.debug("LinkedInEasyApplier initialized successfully") - def _load_questions_from_json(self) -> List[dict]: output_file = 'answers.json' logger.debug("Loading questions from JSON file: %s", output_file) @@ -60,7 +59,6 @@ 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): current_url = self.driver.current_url @@ -79,7 +77,6 @@ class LinkedInEasyApplier: 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) @@ -167,7 +164,6 @@ class LinkedInEasyApplier: if method.get('find_elements'): - buttons = self.driver.find_elements(By.XPATH, method['xpath']) if buttons: for index, button in enumerate(buttons): @@ -209,7 +205,6 @@ class LinkedInEasyApplier: 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") - def _get_job_description(self) -> str: logger.debug("Getting job description") try: @@ -541,7 +536,6 @@ class LinkedInEasyApplier: wrapped_lines.append(line) return wrapped_lines - lines = split_text_by_width(cover_letter_text, "Helvetica", 12, max_width) for line in lines: @@ -567,7 +561,6 @@ class LinkedInEasyApplier: 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") @@ -670,7 +663,6 @@ class LinkedInEasyApplier: for item in self.all_data: - 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}'") @@ -697,7 +689,6 @@ class LinkedInEasyApplier: 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 answer into the textbox and saved it to JSON.") @@ -730,7 +721,6 @@ class LinkedInEasyApplier: logger.debug("Entered existing date answer") return True - self._save_questions_to_json({'type': 'date', 'question': question_text, 'answer': answer_text}) self._enter_text(date_field, answer_text) logger.debug("Entered new date answer") @@ -752,7 +742,6 @@ class LinkedInEasyApplier: logger.debug(f"Dropdown options found: {options}") - question_text = question.find_element(By.TAG_NAME, 'label').text.lower() logger.debug(f"Processing dropdown or combobox question: {question_text}") diff --git a/src/linkedIn_job_manager.py b/src/linkedIn_job_manager.py index adda476..9308708 100644 --- a/src/linkedIn_job_manager.py +++ b/src/linkedIn_job_manager.py @@ -452,9 +452,9 @@ class LinkedInJobManager: 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...") + utils.printyellow( + f"Already applied at {company} (once per company policy), skipping...") return True except json.JSONDecodeError: continue return False - diff --git a/src/linkedin-api.py b/src/linkedin-api.py index 37f727d..c061493 100644 --- a/src/linkedin-api.py +++ b/src/linkedin-api.py @@ -1,58 +1,59 @@ -from typing import Dict, List -from linkedin_api import Linkedin -from typing import Optional, Union, Literal -from urllib.parse import quote, urlencode import logging -import json +from typing import Dict, List +from typing import Optional, Union, Literal +from urllib.parse import urlencode + +from linkedin_api import Linkedin # set log to all debug logging.basicConfig(level=logging.INFO) + class LinkedInEvolvedAPI(Linkedin): already_applied_jobs: List[str] = [] - + def __init__(self, username, password): super().__init__(username, password) def search_jobs( - self, - keywords: Optional[str] = None, - companies: Optional[List[str]] = None, - experience: Optional[ - List[ - Union[ - Literal["1"], - Literal["2"], - Literal["3"], - Literal["4"], - Literal["5"], - Literal["6"], + self, + keywords: Optional[str] = None, + companies: Optional[List[str]] = None, + experience: Optional[ + List[ + Union[ + Literal["1"], + Literal["2"], + Literal["3"], + Literal["4"], + Literal["5"], + Literal["6"], + ] ] - ] - ] = None, - job_type: Optional[ - List[ - Union[ - Literal["F"], - Literal["C"], - Literal["P"], - Literal["T"], - Literal["I"], - Literal["V"], - Literal["O"], + ] = None, + job_type: Optional[ + List[ + Union[ + Literal["F"], + Literal["C"], + Literal["P"], + Literal["T"], + Literal["I"], + Literal["V"], + Literal["O"], + ] ] - ] - ] = None, - job_title: Optional[List[str]] = None, - industries: Optional[List[str]] = None, - location_name: Optional[str] = None, - remote: Optional[List[Union[Literal["1"], Literal["2"], Literal["3"]]]] = None, - listed_at: None | int = None, - distance: Optional[int] = None, - easy_apply: Optional[bool] = True, - limit=-1, - offset=0, - **kwargs, + ] = None, + job_title: Optional[List[str]] = None, + industries: Optional[List[str]] = None, + location_name: Optional[str] = None, + remote: Optional[List[Union[Literal["1"], Literal["2"], Literal["3"]]]] = None, + listed_at: None | int = None, + distance: Optional[int] = None, + easy_apply: Optional[bool] = True, + limit=-1, + offset=0, + **kwargs, ) -> List[Dict]: """Perform a LinkedIn search for jobs. @@ -154,21 +155,21 @@ class LinkedInEvolvedAPI(Linkedin): e["job_id"] = trackingUrn if e.get("$type") == "com.linkedin.voyager.dash.jobs.JobPosting": new_data.append(e) - + if not new_data: break results.extend(new_data) if ( - (-1 < limit <= len(results)) - or len(results) / count >= Linkedin._MAX_REPEATED_REQUESTS + (-1 < limit <= len(results)) + or len(results) / count >= Linkedin._MAX_REPEATED_REQUESTS ) or len(elements) == 0: break self.logger.debug(f"results grew to {len(results)}") return results - - def get_fields_for_easy_apply(self,job_id: str) -> List[Dict]: + + def get_fields_for_easy_apply(self, job_id: str) -> List[Dict]: """Get fields needed for easy apply jobs. :param job_id: Job ID @@ -181,14 +182,12 @@ class LinkedInEvolvedAPI(Linkedin): cookie_str = "; ".join([f"{k}={v}" for k, v in cookies.items()]) headers: Dict[str, str] = self._headers() - headers["Accept"] = "application/vnd.linkedin.normalized+json+2.1" headers["csrf-token"] = cookies["JSESSIONID"].replace('"', "") headers["Cookie"] = cookie_str headers["Connection"] = "keep-alive" - default_params = { "decorationId": "com.linkedin.voyager.dash.deco.jobs.OnsiteApplyApplication-67", "jobPostingUrn": f"urn:li:fsd_jobPosting:{job_id}", @@ -217,26 +216,26 @@ class LinkedInEvolvedAPI(Linkedin): except ValueError: self.logger.error("Failed to parse JSON response") return [] - + form_components = [] for item in data.get("included", []): - if 'formComponent' in item: + if 'formComponent' in item: urn = item['urn'] try: title = item['title']['text'] except TypeError: title = urn - + form_component_type = list(item['formComponent'].keys())[0] form_component_details = item['formComponent'][form_component_type] - + component_info = { 'title': title, 'urn': urn, 'formComponentType': form_component_type, } - + if 'textSelectableOptions' in form_component_details: options = [ opt['optionText']['text'] for opt in form_component_details['textSelectableOptions'] @@ -244,18 +243,18 @@ class LinkedInEvolvedAPI(Linkedin): component_info['selectableOptions'] = options elif 'selectableOptions' in form_component_details: options = [ - opt['textSelectableOption']['optionText']['text'] + opt['textSelectableOption']['optionText']['text'] for opt in form_component_details['selectableOptions'] ] component_info['selectableOptions'] = options - + form_components.append(component_info) return form_components - - def apply_to_job(self,job_id: str, fields: dict, followCompany: bool = True) -> bool: + + def apply_to_job(self, job_id: str, fields: dict, followCompany: bool = True) -> bool: return False - + # ToDo: Implement apply to job parser first # How need to be implemented: # 1. Get fields for easy apply job from the previous method (get_fields_for_easy_apply) @@ -265,11 +264,11 @@ class LinkedInEvolvedAPI(Linkedin): # {'title': 'Quanti anni di esperienza di lavoro hai con Router?', 'urn': 'urn:li:fsd_formElement:urn:li:jobs_applyformcommon_easyApplyFormElement:(4013860791,9478711764,numeric)', 'formComponentType': 'singleLineTextFormComponent', 'response': '5'} # To fill, you can temporary use input() function to get the data from the user manually for testing purposes (for the further implementation, the question will be asked to AI implementation and automatically filled) # Build a working payload. - + # EXAMPLE OF WORKING PAYLOAD # 4005350454 is job_id, so need to be replaced with the job_id - #{ + # { # "followCompany": true, # "responses": [ # { @@ -349,23 +348,19 @@ class LinkedInEvolvedAPI(Linkedin): # } # ], # "trackingId": "" - #} + # } # Push the commit to the repository and create a pull request to the v3 branch. - + def set_job_as_applied(self, job_id: str) -> None: self.already_applied_jobs.append(job_id) - - - - - ## EXAMPLE USAGE if __name__ == "__main__": - api: LinkedInEvolvedAPI = LinkedInEvolvedAPI(username="", password="") - jobs = api.search_jobs(keywords="Frontend Developer", location_name="Italia", limit=100, easy_apply=True, offset=1, listed_at=None) + api: LinkedInEvolvedAPI = LinkedInEvolvedAPI(username="", password="") + jobs = api.search_jobs(keywords="Frontend Developer", location_name="Italia", limit=100, easy_apply=True, offset=1, + listed_at=None) for job in jobs: job_id: str = job["job_id"] print(f"Job ID: {job_id}") @@ -379,7 +374,3 @@ if __name__ == "__main__": for field in fields: print(field) break - - - - \ No newline at end of file From 20382d14c3cb65554a7b7d871c30b3a845e725f2 Mon Sep 17 00:00:00 2001 From: Federico <85809106+feder-cr@users.noreply.github.com> Date: Sun, 8 Sep 2024 23:21:03 +0200 Subject: [PATCH 07/21] Update README.md --- README.md | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 15fe7b8..ef78a18 100644 --- a/README.md +++ b/README.md @@ -259,11 +259,12 @@ Each section has specific fields to fill out: - Example: ```yaml education_details: - - degree: "Bachelor's Degree" - university: "University of Example" - gpa: "3.8/4" - graduation_year: "2022" + - education_level: "Bachelor's Degree" + institution: "University of Example" field_of_study: "Software Engineering" + final_evaluation_grade: "4/4" + start_date: "2021" + year_of_completion: "2023" exam: Algorithms: "A" Data Structures: "B+" @@ -274,13 +275,13 @@ Each section has specific fields to fill out: - `experience_details:` - This section details your work experience, including job roles, companies, and key responsibilities. - - **position**: Your job title or role. - - **company**: The name of the company or organization where you worked. - - **employment_period**: The timeframe during which you were employed in the role (e.g., MM/YYYY - MM/YYYY). - - **location**: The city and country where the company is located. - - **industry**: The industry or field in which the company operates. - - **key_responsibilities**: A list of major responsibilities or duties you had in the role. - - **skills_acquired**: Skills or expertise gained through this role. + - position: Your job title or role. + - company: The name of the company or organization where you worked. + - employment_period: The timeframe during which you were employed in the role (e.g., MM/YYYY - MM/YYYY). + - location: The city and country where the company is located. + - industry: The industry or field in which the company operates. + - key_responsibilities: A list of major responsibilities or duties you had in the role. + - skills_acquired: Skills or expertise gained through this role. - Example: ```yaml @@ -333,7 +334,8 @@ Each section has specific fields to fill out: - `certifications:` - Include any professional certifications you have earned. - - **certification_name**: The name of the certification. + - name: "PMP" + description: "Certification for project management professionals, issued by the Project Management Institute (PMI)" - Example: ```yaml From 5d8372700a154c1bdf8d1a12eec8564268a156a1 Mon Sep 17 00:00:00 2001 From: Federico <85809106+feder-cr@users.noreply.github.com> Date: Sun, 8 Sep 2024 23:23:11 +0200 Subject: [PATCH 08/21] Update README.md --- README.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index ef78a18..5b1bc4b 100644 --- a/README.md +++ b/README.md @@ -275,13 +275,13 @@ Each section has specific fields to fill out: - `experience_details:` - This section details your work experience, including job roles, companies, and key responsibilities. - - position: Your job title or role. - - company: The name of the company or organization where you worked. - - employment_period: The timeframe during which you were employed in the role (e.g., MM/YYYY - MM/YYYY). - - location: The city and country where the company is located. - - industry: The industry or field in which the company operates. - - key_responsibilities: A list of major responsibilities or duties you had in the role. - - skills_acquired: Skills or expertise gained through this role. + - **position**: Your job title or role. + - **company**: The name of the company or organization where you worked. + - **employment_period**: The timeframe during which you were employed in the role (e.g., MM/YYYY - MM/YYYY). + - **location**: The city and country where the company is located. + - **industry**: The industry or field in which the company operates. + - **key_responsibilities**: A list of major responsibilities or duties you had in the role. + - **skills_acquired**: Skills or expertise gained through this role. - Example: ```yaml From ec671eba888e0084deb758b51f3edd73ed7f1296 Mon Sep 17 00:00:00 2001 From: feder-cr <85809106+feder-cr@users.noreply.github.com> Date: Sun, 8 Sep 2024 23:40:56 +0200 Subject: [PATCH 09/21] new plain_text_format --- data_folder_example/plain_text_resume.yaml | 170 +++++++++++---------- 1 file changed, 88 insertions(+), 82 deletions(-) diff --git a/data_folder_example/plain_text_resume.yaml b/data_folder_example/plain_text_resume.yaml index 2ba5666..012a5b8 100644 --- a/data_folder_example/plain_text_resume.yaml +++ b/data_folder_example/plain_text_resume.yaml @@ -1,118 +1,124 @@ personal_information: - name: "Liam" - surname: "Murphy" - date_of_birth: "15/08/1995" - country: "Ireland" - city: "Galway" - address: "Galway City Center" - phone_prefix: "+353" - phone: "871234567" - email: "liam.murphy@gmail.com" - github: "https://github.com/liam-murphy" - linkedin: "https://www.linkedin.com/in/liam-murphy/" - + name: "Giovanni" + surname: "Bianchi" + date_of_birth: "12/02/1988" + country: "Italy" + city: "Rome" + address: "Via Nazionale, 45" + phone_prefix: "+39" + phone: "3345678901" + email: "giovanni.bianchi@example.com" + github: "https://github.com/giovanni-bianchi" + linkedin: "https://www.linkedin.com/in/giovanni-bianchi/" + education_details: - - degree: "Bachelor's Degree" - university: "National University of Ireland, Galway" - gpa: "4/4" - graduation_year: "2020" - field_of_study: "Computer Science" + - education_level: "Master's Degree" + institution: "University of Rome" + field_of_study: "Computer Engineering" + final_evaluation_grade: "110/110" + start_date: "2011" + year_of_completion: "2013" exam: - Information Theory and Inference: "4" - Algorithm Analysis and Design: "4" - Object-Oriented Languages and Programming: "4" - Linear Algebra and Numerical Analysis: "4" - Database: "4" + Computer Networks: "30/30" + Advanced Algorithms: "30/30" + Database Systems: "30/30" + Embedded Systems: "30/30" + Artificial Intelligence: "30/30" experience_details: - - position: "Co-Founder & Software Engineer" - company: "CryptoWave Solutions" - employment_period: "03/2021 - Present" - location: "Ireland" - industry: "Blockchain Technology" + - position: "Senior Software Engineer" + company: "TechSolutions" + employment_period: "01/2018 - Present" + location: "Rome, Italy" + industry: "Software Development" key_responsibilities: - - responsibility_1: "Co-founded and led a startup specializing in app and software development with a focus on blockchain technology" - - responsibility_2: "Provided blockchain consultations for 10+ companies, enhancing their software capabilities with secure, decentralized solutions" - - responsibility_3: "Developed blockchain applications, integrated cutting-edge technology to meet client needs and drive industry innovation" + - responsibility_1: "Led a team of developers in designing and implementing enterprise software solutions" + - responsibility_2: "Architected scalable systems to handle high-volume data processing" + - responsibility_3: "Optimized application performance and reduced downtime by 20%" skills_acquired: - - "Blockchain development" - - "Software engineering" - - "Consultancy" + - "Software architecture" + - "Team leadership" + - "Performance optimization" - - position: "Research Intern" - company: "National University of Ireland, Galway" - employment_period: "11/2022 - 03/2023" - location: "Galway, Ireland" - industry: "IoT Security Research" + - position: "Software Developer" + company: "Innovatech" + employment_period: "06/2015 - 12/2017" + location: "Milan, Italy" + industry: "Technology" key_responsibilities: - - responsibility_1: "Conducted in-depth research on IoT security, focusing on binary instrumentation and runtime monitoring" - - responsibility_2: "Performed in-depth study of the MQTT protocol and Falco" - - responsibility_3: "Developed multiple software components including MQTT packet analysis library, Falco adapter, and RML monitor in Prolog" - - responsibility_4: "Authored thesis 'Binary Instrumentation for Runtime Monitoring of Internet of Things Systems Using Falco'" + - responsibility_1: "Developed and maintained web applications using modern technologies" + - responsibility_2: "Collaborated with UX/UI designers to enhance user experience" + - responsibility_3: "Implemented automated testing procedures to ensure code quality" skills_acquired: - - "IoT security" - - "Binary instrumentation" - - "MQTT protocol" - - "Prolog programming" + - "Web development" + - "User experience design" + - "Automated testing" - - position: "Software Engineer" - company: "University Hospital Galway" - employment_period: "05/2022 - 11/2022" - location: "Galway, Ireland" - industry: "Healthcare IT" + - position: "Junior Developer" + company: "StartUp Hub" + employment_period: "01/2014 - 05/2015" + location: "Florence, Italy" + industry: "Startups" key_responsibilities: - - responsibility_1: "Integrated and enforced robust security protocols" - - responsibility_2: "Developed and maintained a critical software tool for password validation used by over 1,600 employees" - - responsibility_3: "Played an integral role in the hospital's cybersecurity team" + - responsibility_1: "Assisted in the development of mobile applications and web platforms" + - responsibility_2: "Participated in code reviews and contributed to software design discussions" + - responsibility_3: "Resolved bugs and implemented feature enhancements" skills_acquired: - - "Cybersecurity" - - "Software development" - - "Password validation" + - "Mobile app development" + - "Code reviews" + - "Bug fixing" projects: - - name: "JobBot" - description: "AI-driven tool to automate and personalize job applications on LinkedIn, gained over 3000 stars on GitHub, improving efficiency and reducing application time" - link: "https://github.com/liam-murphy/jobbot" - - name: "mqtt-packet-parser" - description: "Developed a Node.js module for parsing MQTT packets, improved parsing efficiency by 40%" - link: "https://github.com/liam-murphy/mqtt-packet-parser" + - name: "E-Commerce Platform" + description: "Developed a scalable e-commerce platform with advanced features like real-time inventory tracking and user analytics" + link: "https://github.com/giovanni-bianchi/ecommerce-platform" + - name: "Smart Home Automation" + description: "Created a smart home automation system integrating various IoT devices for remote control and monitoring" + link: "https://github.com/giovanni-bianchi/smart-home-automation" achievements: - - name: "Winner of an Irish public competition" - description: "Won first place in a public competition with a perfect score of 70/70, securing a Software Developer position at University Hospital Galway" - - name: "Galway Merit Scholarship" - description: "Awarded annually from 2018 to 2020 in recognition of academic excellence and contribution" - - name: "GitHub Recognition" - description: "Gained over 3000 stars on GitHub with JobBot project" + - name: "Top Innovator Award" + description: "Recognized for innovative solutions and contributions to high-impact projects at TechSolutions" + - name: "Best Young Developer" + description: "Awarded for outstanding performance and contributions during the first three years at Innovatech" certifications: - - "C1" + - name: "Certified Ethical Hacker (CEH)" + description: "Certification for expertise in ethical hacking and cybersecurity practices" + - name: "AWS Certified DevOps Engineer" + description: "Certification for DevOps practices and using AWS for cloud services" + - name: "Microsoft Certified: Azure Solutions Architect Expert" + description: "Certification for designing and implementing Azure solutions" + - name: "Certified Kubernetes Administrator (CKA)" + description: "Certification for managing and orchestrating Kubernetes clusters" + - name: "Certified Data Privacy Professional (CDPP)" + description: "Certification for ensuring data privacy and compliance with regulations" languages: - - language: "English" + - language: "Italian" proficiency: "Native" - - language: "Spanish" - proficiency: "Professional" + - language: "English" + proficiency: "Fluent" interests: - - "Full-Stack Development" - - "Software Architecture" - - "IoT system design and development" + - "Cloud Computing" + - "Cybersecurity" + - "IoT Development" - "Artificial Intelligence" - - "Cloud Technologies" + - "Data Privacy" availability: - notice_period: "immediately" + notice_period: "2 months" salary_expectations: - salary_range_usd: "100000" + salary_range_usd: "90000 - 110000" self_identification: gender: "Male" - pronouns: "He" + pronouns: "He/Him" veteran: "No" disability: "No" - ethnicity: "white" + ethnicity: "White" legal_authorization: eu_work_authorization: "Yes" @@ -130,4 +136,4 @@ work_preferences: open_to_relocation: "Yes" willing_to_complete_assessments: "Yes" willing_to_undergo_drug_tests: "Yes" - willing_to_undergo_background_checks: "Yes" \ No newline at end of file + willing_to_undergo_background_checks: "Yes" From 5b3cdf8f807adcc48880b1e39409f8c164270a26 Mon Sep 17 00:00:00 2001 From: Federico <85809106+feder-cr@users.noreply.github.com> Date: Sun, 8 Sep 2024 23:42:58 +0200 Subject: [PATCH 10/21] new plain_text_resume.yaml format --- data_folder/plain_text_resume.yaml | 92 ++++++++++++++++++------------ 1 file changed, 55 insertions(+), 37 deletions(-) diff --git a/data_folder/plain_text_resume.yaml b/data_folder/plain_text_resume.yaml index aabcee4..82bfd61 100644 --- a/data_folder/plain_text_resume.yaml +++ b/data_folder/plain_text_resume.yaml @@ -1,7 +1,7 @@ personal_information: name: "[Your Name]" surname: "[Your Surname]" - date_of_birth: "[DD/MM/YYYY]" + date_of_birth: "[Your Date of Birth]" country: "[Your Country]" city: "[Your City]" address: "[Your Address]" @@ -12,74 +12,92 @@ personal_information: linkedin: "[Your LinkedIn Profile URL]" education_details: - - degree: "[Your Degree]" - university: "[Your University]" - gpa: "[Your GPA]" - graduation_year: "[Year of Graduation]" + - education_level: "[Your Education Level]" + institution: "[Your Institution]" field_of_study: "[Your Field of Study]" + final_evaluation_grade: "[Your Final Evaluation Grade]" + start_date: "[Start Date]" + year_of_completion: "[Year of Completion]" exam: - [Course Name 1]: "[Grade]" - [Course Name 2]: "[Grade]" - [Course Name 3]: "[Grade]" - [Course Name 4]: "[Grade]" - [Course Name 5]: "[Grade]" + exam_name_1: "[Grade]" + exam_name_2: "[Grade]" + exam_name_3: "[Grade]" + exam_name_4: "[Grade]" + exam_name_5: "[Grade]" + exam_name_6: "[Grade]" experience_details: - - position: "[Your Job Title]" + - position: "[Your Position]" company: "[Company Name]" - employment_period: "[Start Date] - [End Date]" + employment_period: "[Employment Period]" location: "[Location]" industry: "[Industry]" key_responsibilities: - - responsibility_1: "[Key Responsibility 1]" - - responsibility_2: "[Key Responsibility 2]" - - responsibility_3: "[Key Responsibility 3]" + - responsibility_1: "[Responsibility Description]" + - responsibility_2: "[Responsibility Description]" + - responsibility_3: "[Responsibility Description]" skills_acquired: - - "[Skill 1]" - - "[Skill 2]" - - "[Skill 3]" + - "[Skill]" + - "[Skill]" + - "[Skill]" + + - position: "[Your Position]" + company: "[Company Name]" + employment_period: "[Employment Period]" + location: "[Location]" + industry: "[Industry]" + key_responsibilities: + - responsibility_1: "[Responsibility Description]" + - responsibility_2: "[Responsibility Description]" + - responsibility_3: "[Responsibility Description]" + skills_acquired: + - "[Skill]" + - "[Skill]" + - "[Skill]" projects: - name: "[Project Name]" - description: "[Brief Description of the Project]" - link: "[Project URL]" + description: "[Project Description]" + link: "[Project Link]" + - name: "[Project Name]" - description: "[Brief Description of the Project]" - link: "[Project URL]" + description: "[Project Description]" + link: "[Project Link]" achievements: - - name: "[Achievement Title]" - description: "[Brief Description of the Achievement]" - - name: "[Achievement Title]" - description: "[Brief Description of the Achievement]" + - name: "[Achievement Name]" + description: "[Achievement Description]" + - name: "[Achievement Name]" + description: "[Achievement Description]" certifications: - - "[Certification Name]" + - name: "[Certification Name]" + description: "[Certification Description]" + - name: "[Certification Name]" + description: "[Certification Description]" languages: - - language: "[Language Name]" + - language: "[Language]" proficiency: "[Proficiency Level]" - - language: "[Language Name]" + - language: "[Language]" proficiency: "[Proficiency Level]" interests: - - "[Interest 1]" - - "[Interest 2]" - - "[Interest 3]" - - "[Interest 4]" - - "[Interest 5]" + - "[Interest]" + - "[Interest]" + - "[Interest]" availability: notice_period: "[Notice Period]" salary_expectations: - salary_range_usd: "[Expected Salary Range in USD]" + salary_range_usd: "[Salary Range]" self_identification: gender: "[Gender]" pronouns: "[Pronouns]" - veteran: "[Veteran Status]" - disability: "[Disability Status]" + veteran: "[Yes/No]" + disability: "[Yes/No]" ethnicity: "[Ethnicity]" legal_authorization: From afd7708a2b4b2dbd90383005c892ee74b4e628fe Mon Sep 17 00:00:00 2001 From: Federico <85809106+feder-cr@users.noreply.github.com> Date: Mon, 9 Sep 2024 13:12:18 +0200 Subject: [PATCH 11/21] Update README.md --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 5b1bc4b..60228c9 100644 --- a/README.md +++ b/README.md @@ -251,7 +251,8 @@ Each section has specific fields to fill out: - This section outlines your academic background, including degrees earned and relevant coursework. - **degree**: The type of degree obtained (e.g., Bachelor's Degree, Master's Degree). - **university**: The name of the university or institution where you studied. - - **gpa**: Your Grade Point Average or equivalent measure of academic performance. + - **final_evaluation_grade**: Your Grade Point Average or equivalent measure of academic performance. + - **start_date**: The start year of your studies. - **graduation_year**: The year you graduated. - **field_of_study**: The major or focus area of your studies. - **exam**: A list of courses or subjects taken along with their respective grades. From 6540bbbb40acc88ff0138e6cfc8754243e0fe032 Mon Sep 17 00:00:00 2001 From: queukat Date: Mon, 9 Sep 2024 18:04:42 +0300 Subject: [PATCH 12/21] fixed some issues --- data_folder/config.yaml | 7 +-- data_folder_example/config.yaml | 8 ++-- main.py | 79 ++++++++++++++++++++------------- requirements.txt | 7 ++- resume_yaml_generator.py | 24 +++++++--- src/gpt.py | 48 +++++++++++--------- src/linkedIn_easy_applier.py | 17 ++++--- src/linkedIn_job_manager.py | 9 ++-- src/utils.py | 5 +++ 9 files changed, 127 insertions(+), 77 deletions(-) diff --git a/data_folder/config.yaml b/data_folder/config.yaml index 1cbe9ed..a037034 100644 --- a/data_folder/config.yaml +++ b/data_folder/config.yaml @@ -1,6 +1,6 @@ remote: [true/false] -experienceLevel: +experience_level: internship: [true/false] entry: [true/false] associate: [true/false] @@ -31,7 +31,7 @@ locations: - Country1 - Country2 -applyOnceAtCompany: [true/false] +apply_once_at_company: [ true/false] distance: 100 @@ -39,7 +39,8 @@ company_blacklist: - Company1 - Company2 -titleBlacklist: + +title_blacklist: - word1 - word2 diff --git a/data_folder_example/config.yaml b/data_folder_example/config.yaml index b9ccefa..316ab8f 100644 --- a/data_folder_example/config.yaml +++ b/data_folder_example/config.yaml @@ -1,6 +1,6 @@ remote: true -experienceLevel: +experience_level: internship: true entry: true associate: true @@ -29,15 +29,15 @@ positions: locations: - USA -applyOnceAtCompany: [true/false] +apply_once_at_company: [true/false] distance: 100 -companyBlacklist: +company_blacklist: - Noir - Crossover -titleBlacklist: +title_blacklist: llm_model_type: openai llm_model: 'gpt-4o' diff --git a/main.py b/main.py index afa9044..68a0527 100644 --- a/main.py +++ b/main.py @@ -7,9 +7,9 @@ import click from selenium import webdriver from selenium.webdriver.chrome.service import Service as ChromeService from webdriver_manager.chrome import ChromeDriverManager -from selenium.common.exceptions import WebDriverException, TimeoutException -from lib_resume_builder_AIHawk import Resume,StyleManager,FacadeManager,ResumeGenerator -from src.utils import chromeBrowserOptions +from selenium.common.exceptions import WebDriverException +from lib_resume_builder_AIHawk import Resume, StyleManager, FacadeManager, ResumeGenerator +from src.utils import chrome_browser_options from src.gpt import GPTAnswerer from src.linkedIn_authenticator import LinkedInAuthenticator from src.linkedIn_bot_facade import LinkedInBotFacade @@ -19,14 +19,16 @@ from src.job_application_profile import JobApplicationProfile # Suppress stderr sys.stderr = open(os.devnull, 'w') + class ConfigError(Exception): pass + class ConfigValidator: @staticmethod def validate_email(email: str) -> bool: return re.match(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', email) is not None - + @staticmethod def validate_yaml_file(yaml_path: Path) -> dict: try: @@ -36,37 +38,37 @@ class ConfigValidator: raise ConfigError(f"Error reading file {yaml_path}: {exc}") except FileNotFoundError: raise ConfigError(f"File not found: {yaml_path}") - - + def validate_config(config_yaml_path: Path) -> dict: parameters = ConfigValidator.validate_yaml_file(config_yaml_path) required_keys = { 'remote': bool, - 'experienceLevel': dict, + 'experience_level': dict, 'jobTypes': dict, 'date': dict, 'positions': list, 'locations': list, 'distance': int, - 'companyBlacklist': list, - 'titleBlacklist': list + 'company_blacklist': list, + 'title_blacklist': list } for key, expected_type in required_keys.items(): if key not in parameters: - if key in ['companyBlacklist', 'titleBlacklist']: + if key in ['company_blacklist', 'title_blacklist']: parameters[key] = [] else: raise ConfigError(f"Missing or invalid key '{key}' in config file {config_yaml_path}") elif not isinstance(parameters[key], expected_type): - if key in ['companyBlacklist', 'titleBlacklist'] and parameters[key] is None: + if key in ['company_blacklist', 'title_blacklist'] and parameters[key] is None: parameters[key] = [] else: - raise ConfigError(f"Invalid type for key '{key}' in config file {config_yaml_path}. Expected {expected_type}.") + raise ConfigError( + f"Invalid type for key '{key}' in config file {config_yaml_path}. Expected {expected_type}.") experience_levels = ['internship', 'entry', 'associate', 'mid-senior level', 'director', 'executive'] for level in experience_levels: - if not isinstance(parameters['experienceLevel'].get(level), bool): + if not isinstance(parameters['experience_level'].get(level), bool): raise ConfigError(f"Experience level '{level}' must be a boolean in config file {config_yaml_path}") job_types = ['full-time', 'contract', 'part-time', 'temporary', 'internship', 'other', 'volunteer'] @@ -86,9 +88,10 @@ class ConfigValidator: approved_distances = {0, 5, 10, 25, 50, 100} if parameters['distance'] not in approved_distances: - raise ConfigError(f"Invalid distance value in config file {config_yaml_path}. Must be one of: {approved_distances}") + raise ConfigError( + f"Invalid distance value in config file {config_yaml_path}. Must be one of: {approved_distances}") - for blacklist in ['companyBlacklist', 'titleBlacklist']: + for blacklist in ['company_blacklist', 'title_blacklist']: if not isinstance(parameters.get(blacklist), list): raise ConfigError(f"'{blacklist}' must be a list in config file {config_yaml_path}") if parameters[blacklist] is None: @@ -96,8 +99,6 @@ class ConfigValidator: return parameters - - @staticmethod def validate_secrets(secrets_yaml_path: Path) -> tuple: secrets = ConfigValidator.validate_yaml_file(secrets_yaml_path) @@ -113,10 +114,13 @@ class ConfigValidator: raise ConfigError(f"Password cannot be empty in secrets file {secrets_yaml_path}.") return secrets['email'], str(secrets['password']), secrets['llm_api_key'] + class FileManager: @staticmethod def find_file(name_containing: str, with_extension: str, at_path: Path) -> Path: - return next((file for file in at_path.iterdir() if name_containing.lower() in file.name.lower() and file.suffix.lower() == with_extension.lower()), None) + return next((file for file in at_path.iterdir() if + name_containing.lower() in file.name.lower() and file.suffix.lower() == with_extension.lower()), + None) @staticmethod def validate_data_folder(app_data_folder: Path) -> tuple: @@ -125,13 +129,15 @@ class FileManager: required_files = ['secrets.yaml', 'config.yaml', 'plain_text_resume.yaml'] missing_files = [file for file in required_files if not (app_data_folder / file).exists()] - + if missing_files: raise FileNotFoundError(f"Missing files in the data folder: {', '.join(missing_files)}") output_folder = app_data_folder / 'output' output_folder.mkdir(exist_ok=True) - return (app_data_folder / 'secrets.yaml', app_data_folder / 'config.yaml', app_data_folder / 'plain_text_resume.yaml', output_folder) + return ( + app_data_folder / 'secrets.yaml', app_data_folder / 'config.yaml', app_data_folder / 'plain_text_resume.yaml', + output_folder) @staticmethod def file_paths_to_dict(resume_file: Path | None, plain_text_resume_file: Path) -> dict: @@ -147,14 +153,16 @@ class FileManager: return result + def init_browser() -> webdriver.Chrome: try: - options = chromeBrowserOptions() + options = chrome_browser_options() service = ChromeService(ChromeDriverManager().install()) return webdriver.Chrome(service=service, options=options) except Exception as e: raise RuntimeError(f"Failed to initialize browser: {str(e)}") + def create_and_run_bot(email, password, parameters, llm_api_key): try: style_manager = StyleManager() @@ -162,13 +170,14 @@ def create_and_run_bot(email, password, parameters, llm_api_key): with open(parameters['uploads']['plainTextResume'], "r", encoding='utf-8') as file: plain_text_resume = file.read() resume_object = Resume(plain_text_resume) - resume_generator_manager = FacadeManager(llm_api_key, style_manager, resume_generator, resume_object, Path("data_folder/output")) + resume_generator_manager = FacadeManager(llm_api_key, style_manager, resume_generator, resume_object, + Path("data_folder/output")) os.system('cls' if os.name == 'nt' else 'clear') resume_generator_manager.choose_style() os.system('cls' if os.name == 'nt' else 'clear') - + job_application_profile_object = JobApplicationProfile(plain_text_resume) - + browser = init_browser() login_component = LinkedInAuthenticator(browser) apply_component = LinkedInJobManager(browser) @@ -187,34 +196,40 @@ def create_and_run_bot(email, password, parameters, llm_api_key): @click.command() -@click.option('--resume', type=click.Path(exists=True, file_okay=True, dir_okay=False, path_type=Path), help="Path to the resume PDF file") +@click.option('--resume', type=click.Path(exists=True, file_okay=True, dir_okay=False, path_type=Path), + help="Path to the resume PDF file") def main(resume: Path = None): try: data_folder = Path("data_folder") secrets_file, config_file, plain_text_resume_file, output_folder = FileManager.validate_data_folder(data_folder) - + parameters = ConfigValidator.validate_config(config_file) email, password, llm_api_key = ConfigValidator.validate_secrets(secrets_file) - + parameters['uploads'] = FileManager.file_paths_to_dict(resume, plain_text_resume_file) parameters['outputFileDirectory'] = output_folder - + create_and_run_bot(email, password, parameters, llm_api_key) except ConfigError as ce: print(f"Configuration error: {str(ce)}") - print("Refer to the configuration guide for troubleshooting: https://github.com/feder-cr/LinkedIn_AIHawk_automatic_job_application/blob/main/readme.md#configuration") + print( + "Refer to the configuration guide for troubleshooting: https://github.com/feder-cr/LinkedIn_AIHawk_automatic_job_application/blob/main/readme.md#configuration") except FileNotFoundError as fnf: print(f"File not found: {str(fnf)}") print("Ensure all required files are present in the data folder.") - print("Refer to the file setup guide: https://github.com/feder-cr/LinkedIn_AIHawk_automatic_job_application/blob/main/readme.md#configuration") + print( + "Refer to the file setup guide: https://github.com/feder-cr/LinkedIn_AIHawk_automatic_job_application/blob/main/readme.md#configuration") except RuntimeError as re: print(f"Runtime error: {str(re)}") - print("Refer to the configuration and troubleshooting guide: https://github.com/feder-cr/LinkedIn_AIHawk_automatic_job_application/blob/main/readme.md#configuration") + print( + "Refer to the configuration and troubleshooting guide: https://github.com/feder-cr/LinkedIn_AIHawk_automatic_job_application/blob/main/readme.md#configuration") except Exception as e: print(f"An unexpected error occurred: {str(e)}") - print("Refer to the general troubleshooting guide: https://github.com/feder-cr/LinkedIn_AIHawk_automatic_job_application/blob/main/readme.md#configuration") + print( + "Refer to the general troubleshooting guide: https://github.com/feder-cr/LinkedIn_AIHawk_automatic_job_application/blob/main/readme.md#configuration") + if __name__ == "__main__": main() diff --git a/requirements.txt b/requirements.txt index 03290b7..7e3d816 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,4 +13,9 @@ webdriver-manager==4.0.2 click git+https://github.com/feder-cr/lib_resume_builder_AIHawk.git linkedin-api -pdfminer.six==20221105 \ No newline at end of file +pdfminer.six==20221105 +inputimeout==1.0.4 +langchain-ollama==0.1.3 +langchain-anthropic==0.1.3 +jsonschema==4.23.0 +jsonschema-specifications==2023.12.1 \ No newline at end of file diff --git a/resume_yaml_generator.py b/resume_yaml_generator.py index 336a23d..053245f 100644 --- a/resume_yaml_generator.py +++ b/resume_yaml_generator.py @@ -7,19 +7,22 @@ import re from jsonschema import validate, ValidationError from pdfminer.high_level import extract_text + def load_yaml(file_path: str) -> Dict[str, Any]: with open(file_path, 'r') as file: return yaml.safe_load(file) + def load_resume_text(file_path: str) -> str: with open(file_path, 'r') as file: return file.read() + def get_api_key() -> str: secrets_path = os.path.join('data_folder', 'secrets.yaml') if not os.path.exists(secrets_path): raise FileNotFoundError(f"Secrets file not found at {secrets_path}") - + secrets = load_yaml(secrets_path) if not 'llm_api_key' in secrets: @@ -28,9 +31,10 @@ def get_api_key() -> str: api_key = secrets.get('llm_api_key') if not api_key: raise ValueError("LLM API key not found in secrets.yaml") - + return api_key + def generate_yaml_from_resume(resume_text: str, schema: Dict[str, Any], api_key: str) -> str: client = OpenAI(api_key=api_key) @@ -83,14 +87,15 @@ def generate_yaml_from_resume(resume_text: str, schema: Dict[str, Any], api_key: response = client.chat.completions.create( model="gpt-4o-mini", messages=[ - {"role": "system", "content": "You are a helpful assistant that generates structured YAML content from resume files, paying close attention to format requirements and schema structure."}, + {"role": "system", + "content": "You are a helpful assistant that generates structured YAML content from resume files, paying close attention to format requirements and schema structure."}, {"role": "user", "content": prompt} ], temperature=0.5, ) yaml_content = response.choices[0].message.content.strip() - + # Extract YAML content from between the tags match = re.search(r'(.*?)', yaml_content, re.DOTALL) if match: @@ -98,10 +103,12 @@ def generate_yaml_from_resume(resume_text: str, schema: Dict[str, Any], api_key: else: raise ValueError("YAML content not found in the expected format") + def save_yaml(data: str, output_file: str): with open(output_file, 'w') as file: file.write(data) + def validate_yaml(yaml_content: str, schema: Dict[str, Any]) -> Dict[str, Any]: try: yaml_dict = yaml.safe_load(yaml_content) @@ -110,6 +117,7 @@ def validate_yaml(yaml_content: str, schema: Dict[str, Any]) -> Dict[str, Any]: except ValidationError as e: return {"valid": False, "errors": str(e)} + def generate_report(validation_result: Dict[str, Any], output_file: str): report = f"Validation Report for {output_file}\n" report += "=" * 40 + "\n" @@ -118,14 +126,17 @@ def generate_report(validation_result: Dict[str, Any], output_file: str): else: report += "YAML is not valid. Errors:\n" report += validation_result["errors"] + "\n" - + print(report) + def pdf_to_text(pdf_path: str) -> str: return extract_text(pdf_path) + def main(): - parser = argparse.ArgumentParser(description="Generate a resume YAML file from a PDF or text resume using OpenAI API") + parser = argparse.ArgumentParser( + description="Generate a resume YAML file from a PDF or text resume using OpenAI API") parser.add_argument("--input", required=True, help="Path to the input resume file (PDF or TXT)") parser.add_argument("--output", default="data_folder/plain_text_resume.yaml", help="Path to the output YAML file") args = parser.parse_args() @@ -156,5 +167,6 @@ def main(): except Exception as e: print(f"An error occurred: {e}") + if __name__ == "__main__": main() diff --git a/src/gpt.py b/src/gpt.py index 4107797..d5f78ad 100644 --- a/src/gpt.py +++ b/src/gpt.py @@ -6,8 +6,7 @@ import time from abc import ABC, abstractmethod from datetime import datetime from pathlib import Path -from typing import Dict, List -from typing import Union +from typing import Dict, List, Union import httpx from Levenshtein import distance @@ -38,7 +37,7 @@ class OpenAIModel(AIModel): def invoke(self, prompt: str) -> str: print("invoke in openai") response = self.model.invoke(prompt) - return response + return response.content class ClaudeModel(AIModel): @@ -49,7 +48,7 @@ class ClaudeModel(AIModel): def invoke(self, prompt: str) -> str: response = self.model.invoke(prompt) - return response + return response.content class OllamaModel(AIModel): @@ -59,14 +58,14 @@ class OllamaModel(AIModel): def invoke(self, prompt: str) -> str: response = self.model.invoke(prompt) - return response + return response.content 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: + def _create_model(self, config: dict, api_key: str) -> Union[OpenAIModel, OllamaModel, ClaudeModel]: llm_model_type = config['llm_model_type'] llm_model = config['llm_model'] llm_api_url = config['llm_api_url'] @@ -79,7 +78,7 @@ class AIAdapter: elif llm_model_type == "ollama": return OllamaModel(api_key, llm_model, llm_api_url) else: - raise ValueError(f"Unsupported model type: {model_type}") + raise ValueError(f"Unsupported model type: {llm_model_type}") def invoke(self, prompt: str) -> str: return self.model.invoke(prompt) @@ -109,25 +108,34 @@ class LLMLogger: logger.debug("Prompts are of type StringPromptValue") prompts = prompts.text logger.debug("Prompts converted to text: %s", prompts) - elif isinstance(prompts, Dict): - logger.debug("Prompts are of type Dict") + elif isinstance(prompts, dict): + logger.debug("Prompts are of type dict") try: - prompts = { - f"prompt_{i + 1}": prompt.content - for i, prompt in enumerate(prompts.messages) - } - logger.debug("Prompts converted to dictionary: %s", prompts) + if "messages" in prompts: + logger.debug("Prompts contain 'messages' key") + prompts = { + f"prompt_{i + 1}": prompt["content"] + for i, prompt in enumerate(prompts["messages"]) + } + logger.debug("Prompts converted to dictionary: %s", prompts) + else: + logger.debug("Prompts dictionary does not contain 'messages' key") except Exception as e: logger.error("Error converting prompts to dictionary: %s", str(e)) raise else: logger.debug("Prompts are of unknown type, attempting default conversion") try: - prompts = { - f"prompt_{i + 1}": prompt.content - for i, prompt in enumerate(prompts.messages) - } - logger.debug("Prompts converted to dictionary using default method: %s", prompts) + if hasattr(prompts, "messages"): + logger.debug("Prompts have 'messages' attribute") + prompts = { + f"prompt_{i + 1}": prompt.content + for i, prompt in enumerate(prompts.messages) + } + logger.debug("Prompts converted to dictionary using default method: %s", prompts) + else: + logger.error("Prompts do not have 'messages' attribute, and default conversion failed") + raise ValueError("Prompts structure is not supported.") except Exception as e: logger.error("Error converting prompts using default method: %s", str(e)) raise @@ -291,7 +299,7 @@ class GPTAnswerer: def __init__(self, config, llm_api_key): self.ai_adapter = AIAdapter(config, llm_api_key) - self.llm_cheap = LoggerChatModel(self.ai_adapter) + self.llm_cheap = LoggerChatModel(self.ai_adapter.model) @property def job_description(self): diff --git a/src/linkedIn_easy_applier.py b/src/linkedIn_easy_applier.py index cf64245..9363ac5 100644 --- a/src/linkedIn_easy_applier.py +++ b/src/linkedIn_easy_applier.py @@ -5,7 +5,8 @@ import random import re import time import traceback -from typing import List, Optional, Any, Tuple +from pathlib import Path +from typing import List, Optional, Any, Tuple, Set from httpx import HTTPStatusError from reportlab.lib.pagesizes import A4 @@ -23,11 +24,13 @@ from src.utils import logger class LinkedInEasyApplier: - def __init__(self, driver: Any, resume_dir: Optional[str], set_old_answers: List[Tuple[str, str, str]], + def __init__(self, driver: Any, resume_dir: Optional[str], set_old_answers: Set[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 + else: + resume_dir = Path(resume_dir) self.driver = driver self.resume_path = resume_dir self.set_old_answers = set_old_answers @@ -538,17 +541,19 @@ class LinkedInEasyApplier: lines = split_text_by_width(cover_letter_text, "Helvetica", 12, max_width) + line_height = 14 + max_lines_per_page = int(available_height // line_height) + for line in lines: text_height = text_object.getY() - if text_height > bottom_margin: - text_object.textLine(line) - else: + if text_height - line_height < bottom_margin: c.drawText(text_object) c.showPage() text_object = c.beginText(50, page_height - 50) text_object.setFont("Helvetica", 12) - text_object.textLine(line) + + text_object.textLine(line) c.drawText(text_object) c.save() diff --git a/src/linkedIn_job_manager.py b/src/linkedIn_job_manager.py index 9308708..8be9f02 100644 --- a/src/linkedIn_job_manager.py +++ b/src/linkedIn_job_manager.py @@ -47,10 +47,10 @@ class LinkedInJobManager: def set_parameters(self, parameters): logger.debug("Setting parameters for LinkedInJobManager") self.company_blacklist = parameters.get('company_blacklist', []) or [] - self.title_blacklist = parameters.get('titleBlacklist', []) or [] + self.title_blacklist = parameters.get('title_blacklist', []) or [] self.positions = parameters.get('positions', []) self.locations = parameters.get('locations', []) - self.apply_once_at_company = parameters.get('applyOnceAtCompany', False) + self.apply_once_at_company = parameters.get('apply_once_at_company', False) self.base_search_url = self.get_base_search_url(parameters) self.seen_jobs = [] @@ -272,7 +272,7 @@ class LinkedInJobManager: logger.debug(f"Applicants text found: {applicants_text}") # Extract numeric digits from the text (e.g., "70 applicants" -> "70") - applicants_count = ''.join(filter(str.isdigit, applicants_text)) + applicants_count = ''.join([char for char in str(applicants_text) if char.isdigit()]) logger.debug(f"Extracted applicants count: {applicants_count}") if applicants_count: @@ -370,7 +370,7 @@ 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 + experience_levels = [str(i + 1) for i, (level, v) in enumerate(parameters.get('experience_level', {}).items()) if v] if experience_levels: url_parts.append(f"f_E={','.join(experience_levels)}") @@ -429,7 +429,6 @@ class LinkedInJobManager: 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 return title_blacklisted or company_blacklisted or link_seen diff --git a/src/utils.py b/src/utils.py index f4e4d4a..e8b8429 100644 --- a/src/utils.py +++ b/src/utils.py @@ -179,3 +179,8 @@ def printyellow(text): reset = "\033[0m" logger.debug("Printing text in yellow: %s", text) print(f"{yellow}{text}{reset}") + + +def stringWidth(text, font, font_size): + bbox = font.getbbox(text) + return bbox[2] - bbox[0] From 7d7110253ab5bc43855409c81cfdbcdd388e17ce Mon Sep 17 00:00:00 2001 From: blackms Date: Mon, 9 Sep 2024 17:52:07 +0200 Subject: [PATCH 13/21] Fix method invocation in LoggerChatModel This commit resolves an issue where the LoggerChatModel class was incorrectly attempting to call instances of AIModel directly as if they were callable objects. Changes include: - Modified __call__ method to explicitly use the invoke method when calling AI models. - Updated constructor documentation to clarify the type of object expected. - Added additional debug logging for better traceability of method entry and exit points. These changes ensure that the LoggerChatModel class aligns with the intended design patterns and correctly utilizes the AIModel instances, improving the maintainability and robustness of the codebase. --- .gitignore | 5 +- data_folder/config.yaml | 52 ------------- data_folder/plain_text_resume.yaml | 119 ----------------------------- data_folder/secrets.yaml | 3 - main.py | 4 +- src/gpt.py | 94 +++++++++++++++-------- 6 files changed, 67 insertions(+), 210 deletions(-) delete mode 100644 data_folder/config.yaml delete mode 100644 data_folder/plain_text_resume.yaml delete mode 100644 data_folder/secrets.yaml diff --git a/.gitignore b/.gitignore index 50bbd27..bd8925b 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,7 @@ generated_cv* chrome_profile answers.json data* -*virtual \ No newline at end of file +*virtual +data_folder/*.yaml +app_log.log +venv \ No newline at end of file diff --git a/data_folder/config.yaml b/data_folder/config.yaml deleted file mode 100644 index 1cbe9ed..0000000 --- a/data_folder/config.yaml +++ /dev/null @@ -1,52 +0,0 @@ -remote: [true/false] - -experienceLevel: - internship: [true/false] - entry: [true/false] - associate: [true/false] - mid-senior level: [true/false] - director: [true/false] - executive: [true/false] - -jobTypes: - full-time: [true/false] - contract: [true/false] - part-time: [true/false] - temporary: [true/false] - internship: [true/false] - other: [true/false] - volunteer: [true/false] - -date: - all time: [true/false] - month: [true/false] - week: [true/false] - 24 hours: [true/false] - -positions: - - position1 - - position2 - -locations: - - Country1 - - Country2 - -applyOnceAtCompany: [true/false] - -distance: 100 - -company_blacklist: - - Company1 - - Company2 - -titleBlacklist: - - word1 - - word2 - -job_applicants_threshold: - min_applicants: 0 - max_applicants: 100 - -llm_model_type: openai -llm_model: gpt-4o -llm_api_url: https://api.pawan.krd/cosmosrp/v1 \ No newline at end of file diff --git a/data_folder/plain_text_resume.yaml b/data_folder/plain_text_resume.yaml deleted file mode 100644 index 82bfd61..0000000 --- a/data_folder/plain_text_resume.yaml +++ /dev/null @@ -1,119 +0,0 @@ -personal_information: - name: "[Your Name]" - surname: "[Your Surname]" - date_of_birth: "[Your Date of Birth]" - country: "[Your Country]" - city: "[Your City]" - address: "[Your Address]" - phone_prefix: "[Your Phone Prefix]" - phone: "[Your Phone Number]" - email: "[Your Email Address]" - github: "[Your GitHub Profile URL]" - linkedin: "[Your LinkedIn Profile URL]" - -education_details: - - education_level: "[Your Education Level]" - institution: "[Your Institution]" - field_of_study: "[Your Field of Study]" - final_evaluation_grade: "[Your Final Evaluation Grade]" - start_date: "[Start Date]" - year_of_completion: "[Year of Completion]" - exam: - exam_name_1: "[Grade]" - exam_name_2: "[Grade]" - exam_name_3: "[Grade]" - exam_name_4: "[Grade]" - exam_name_5: "[Grade]" - exam_name_6: "[Grade]" - -experience_details: - - position: "[Your Position]" - company: "[Company Name]" - employment_period: "[Employment Period]" - location: "[Location]" - industry: "[Industry]" - key_responsibilities: - - responsibility_1: "[Responsibility Description]" - - responsibility_2: "[Responsibility Description]" - - responsibility_3: "[Responsibility Description]" - skills_acquired: - - "[Skill]" - - "[Skill]" - - "[Skill]" - - - position: "[Your Position]" - company: "[Company Name]" - employment_period: "[Employment Period]" - location: "[Location]" - industry: "[Industry]" - key_responsibilities: - - responsibility_1: "[Responsibility Description]" - - responsibility_2: "[Responsibility Description]" - - responsibility_3: "[Responsibility Description]" - skills_acquired: - - "[Skill]" - - "[Skill]" - - "[Skill]" - -projects: - - name: "[Project Name]" - description: "[Project Description]" - link: "[Project Link]" - - - name: "[Project Name]" - description: "[Project Description]" - link: "[Project Link]" - -achievements: - - name: "[Achievement Name]" - description: "[Achievement Description]" - - name: "[Achievement Name]" - description: "[Achievement Description]" - -certifications: - - name: "[Certification Name]" - description: "[Certification Description]" - - name: "[Certification Name]" - description: "[Certification Description]" - -languages: - - language: "[Language]" - proficiency: "[Proficiency Level]" - - language: "[Language]" - proficiency: "[Proficiency Level]" - -interests: - - "[Interest]" - - "[Interest]" - - "[Interest]" - -availability: - notice_period: "[Notice Period]" - -salary_expectations: - salary_range_usd: "[Salary Range]" - -self_identification: - gender: "[Gender]" - pronouns: "[Pronouns]" - veteran: "[Yes/No]" - disability: "[Yes/No]" - ethnicity: "[Ethnicity]" - -legal_authorization: - eu_work_authorization: "[Yes/No]" - us_work_authorization: "[Yes/No]" - requires_us_visa: "[Yes/No]" - requires_us_sponsorship: "[Yes/No]" - requires_eu_visa: "[Yes/No]" - legally_allowed_to_work_in_eu: "[Yes/No]" - legally_allowed_to_work_in_us: "[Yes/No]" - requires_eu_sponsorship: "[Yes/No]" - -work_preferences: - remote_work: "[Yes/No]" - in_person_work: "[Yes/No]" - open_to_relocation: "[Yes/No]" - willing_to_complete_assessments: "[Yes/No]" - willing_to_undergo_drug_tests: "[Yes/No]" - willing_to_undergo_background_checks: "[Yes/No]" diff --git a/data_folder/secrets.yaml b/data_folder/secrets.yaml deleted file mode 100644 index c218803..0000000 --- a/data_folder/secrets.yaml +++ /dev/null @@ -1,3 +0,0 @@ -email: myemaillinkedin@gmail.com -password: ImpossiblePassowrd10 -llm_api_key: 'sk-11KRr4uuTwpRGfeRTfj1T9BlbkFJjP8QTrswHU1yGruru2FR' \ No newline at end of file diff --git a/main.py b/main.py index afa9044..047724b 100644 --- a/main.py +++ b/main.py @@ -9,7 +9,7 @@ from selenium.webdriver.chrome.service import Service as ChromeService from webdriver_manager.chrome import ChromeDriverManager from selenium.common.exceptions import WebDriverException, TimeoutException from lib_resume_builder_AIHawk import Resume,StyleManager,FacadeManager,ResumeGenerator -from src.utils import chromeBrowserOptions +from src.utils import chrome_browser_options from src.gpt import GPTAnswerer from src.linkedIn_authenticator import LinkedInAuthenticator from src.linkedIn_bot_facade import LinkedInBotFacade @@ -149,7 +149,7 @@ class FileManager: def init_browser() -> webdriver.Chrome: try: - options = chromeBrowserOptions() + options = chrome_browser_options() service = ChromeService(ChromeDriverManager().install()) return webdriver.Chrome(service=service, options=options) except Exception as e: diff --git a/src/gpt.py b/src/gpt.py index 4107797..3a833fd 100644 --- a/src/gpt.py +++ b/src/gpt.py @@ -70,7 +70,8 @@ class AIAdapter: 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)) + 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) @@ -79,7 +80,7 @@ class AIAdapter: elif llm_model_type == "ollama": return OllamaModel(api_key, llm_model, llm_api_url) else: - raise ValueError(f"Unsupported model type: {model_type}") + raise ValueError(f"Unsupported model type: {llm_model_type}") def invoke(self, prompt: str) -> str: return self.model.invoke(prompt) @@ -99,7 +100,8 @@ class LLMLogger: logger.debug("Parsed reply received: %s", parsed_reply) try: - calls_log = os.path.join(Path("data_folder/output"), "open_ai_calls.json") + calls_log = os.path.join( + Path("data_folder/output"), "open_ai_calls.json") logger.debug("Logging path determined: %s", calls_log) except Exception as e: logger.error("Error determining the log path: %s", str(e)) @@ -118,18 +120,22 @@ class LLMLogger: } logger.debug("Prompts converted to dictionary: %s", prompts) except Exception as e: - logger.error("Error converting prompts to dictionary: %s", str(e)) + logger.error( + "Error converting prompts to dictionary: %s", str(e)) raise else: - logger.debug("Prompts are of unknown type, attempting default conversion") + logger.debug( + "Prompts are of unknown type, attempting default conversion") try: prompts = { f"prompt_{i + 1}": prompt.content for i, prompt in enumerate(prompts.messages) } - logger.debug("Prompts converted to dictionary using default method: %s", prompts) + logger.debug( + "Prompts converted to dictionary using default method: %s", prompts) except Exception as e: - logger.error("Error converting prompts using default method: %s", str(e)) + logger.error( + "Error converting prompts using default method: %s", str(e)) raise try: @@ -144,7 +150,8 @@ class LLMLogger: output_tokens = token_usage["output_tokens"] input_tokens = token_usage["input_tokens"] total_tokens = token_usage["total_tokens"] - logger.debug("Token usage - Input: %d, Output: %d, Total: %d", input_tokens, output_tokens, total_tokens) + logger.debug("Token usage - Input: %d, Output: %d, Total: %d", + input_tokens, output_tokens, total_tokens) except KeyError as e: logger.error("KeyError in parsed_reply structure: %s", str(e)) raise @@ -159,7 +166,8 @@ class LLMLogger: try: prompt_price_per_token = 0.00000015 completion_price_per_token = 0.0000006 - total_cost = (input_tokens * prompt_price_per_token) + (output_tokens * completion_price_per_token) + total_cost = (input_tokens * prompt_price_per_token) + \ + (output_tokens * completion_price_per_token) logger.debug("Total cost calculated: %f", total_cost) except Exception as e: logger.error("Error calculating total cost: %s", str(e)) @@ -178,12 +186,14 @@ class LLMLogger: } logger.debug("Log entry created: %s", log_entry) except KeyError as e: - logger.error("Error creating log entry: missing key %s in parsed_reply", str(e)) + logger.error( + "Error creating log entry: missing key %s in parsed_reply", str(e)) raise try: with open(calls_log, "a", encoding="utf-8") as f: - json_string = json.dumps(log_entry, ensure_ascii=False, indent=4) + json_string = json.dumps( + log_entry, ensure_ascii=False, indent=4) f.write(json_string + "\n") logger.debug("Log entry written to file: %s", calls_log) except Exception as e: @@ -194,23 +204,24 @@ class LLMLogger: class LoggerChatModel: def __init__(self, llm: Union[OpenAIModel, OllamaModel, ClaudeModel]): - self.llm = llm - logger.debug("LoggerChatModel successfully initialized with LLM: %s", llm) + logger.debug( + "LoggerChatModel successfully initialized with LLM: %s", llm) def __call__(self, messages: List[Dict[str, str]]) -> str: - logger.debug("Entering __call__ method with messages: %s", messages) while True: try: logger.debug("Attempting to call the LLM with messages") - reply = self.llm(messages) + # Ensure you're using invoke to call the model + reply = self.llm.invoke(messages) logger.debug("LLM response received: %s", reply) parsed_reply = self.parse_llmresult(reply) logger.debug("Parsed LLM reply: %s", parsed_reply) - LLMLogger.log_request(prompts=messages, parsed_reply=parsed_reply) + LLMLogger.log_request( + prompts=messages, parsed_reply=parsed_reply) logger.debug("Request successfully logged") return reply @@ -246,7 +257,8 @@ class LoggerChatModel: except Exception as e: logger.error("Unexpected error occurred: %s", str(e)) - logger.info("Waiting for 30 seconds before retrying due to an unexpected error.") + logger.info( + "Waiting for 30 seconds before retrying due to an unexpected error.") time.sleep(30) continue @@ -279,11 +291,13 @@ class LoggerChatModel: return parsed_result except KeyError as e: - logger.error("KeyError while parsing LLM result: missing key %s", str(e)) + logger.error( + "KeyError while parsing LLM result: missing key %s", str(e)) raise except Exception as e: - logger.error("Unexpected error while parsing LLM result: %s", str(e)) + logger.error( + "Unexpected error while parsing LLM result: %s", str(e)) raise @@ -299,7 +313,8 @@ class GPTAnswerer: @staticmethod def find_best_match(text: str, options: list[str]) -> str: - logger.debug("Finding best match for text: '%s' in options: %s", text, options) + logger.debug( + "Finding best match for text: '%s' in options: %s", text, options) distances = [ (option, distance(text.lower(), option.lower())) for option in options ] @@ -325,10 +340,12 @@ class GPTAnswerer: def set_job(self, job): logger.debug("Setting job: %s", job) self.job = job - self.job.set_summarize_job_description(self.summarize_job_description(self.job.description)) + self.job.set_summarize_job_description( + self.summarize_job_description(self.job.description)) def set_job_application_profile(self, job_application_profile): - logger.debug("Setting job application profile: %s", job_application_profile) + logger.debug("Setting job application profile: %s", + job_application_profile) self.job_application_profile = job_application_profile def summarize_job_description(self, text: str) -> str: @@ -336,7 +353,8 @@ class GPTAnswerer: strings.summarize_prompt_template = self._preprocess_template_string( strings.summarize_prompt_template ) - prompt = ChatPromptTemplate.from_template(strings.summarize_prompt_template) + prompt = ChatPromptTemplate.from_template( + strings.summarize_prompt_template) chain = prompt | self.llm_cheap | StrOutputParser() output = chain.invoke({"text": text}) logger.debug("Summary generated: %s", output) @@ -460,31 +478,37 @@ class GPTAnswerer: 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.") + raise ValueError( + "Could not extract section name from the response.") section_name = match.group(1).lower().replace(" ", "_") if section_name == "cover_letter": chain = chains.get(section_name) - output = chain.invoke({"resume": self.resume, "job_description": self.job_description}) + 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) 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.") + 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.") chain = chains.get(section_name) if chain is None: logger.error("Chain not defined for section '%s'", section_name) raise ValueError(f"Chain not defined for section '{section_name}'") - output = chain.invoke({"resume_section": resume_section, "question": question}) + output = chain.invoke( + {"resume_section": resume_section, "question": question}) logger.debug("Question answered: %s", output) return output def answer_question_numeric(self, question: str, default_experience: int = 3) -> int: logger.debug("Answering numeric question: %s", question) - func_template = self._preprocess_template_string(strings.numeric_question_template) + 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( @@ -495,7 +519,8 @@ class GPTAnswerer: output = self.extract_number_from_string(output_str) logger.debug("Extracted number: %d", output) except ValueError: - logger.warning("Failed to extract number, using default experience: %d", default_experience) + logger.warning( + "Failed to extract number, using default experience: %d", default_experience) output = default_experience return output @@ -511,17 +536,20 @@ class GPTAnswerer: def answer_question_from_options(self, question: str, options: list[str]) -> str: logger.debug("Answering question from options: %s", question) - func_template = self._preprocess_template_string(strings.options_template) + func_template = self._preprocess_template_string( + strings.options_template) prompt = ChatPromptTemplate.from_template(func_template) chain = prompt | self.llm_cheap | StrOutputParser() - output_str = chain.invoke({"resume": self.resume, "question": question, "options": options}) + output_str = chain.invoke( + {"resume": self.resume, "question": question, "options": options}) logger.debug("Raw output for options question: %s", output_str) best_option = self.find_best_match(output_str, options) logger.debug("Best option determined: %s", best_option) return best_option def resume_or_cover(self, phrase: str) -> str: - logger.debug("Determining if phrase refers to resume or cover letter: %s", phrase) + 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 one word 'upload', consider it as 'cover'. From 3a8627c20feff4bb109c5fe5d69d0f96c14516ca Mon Sep 17 00:00:00 2001 From: blackms Date: Mon, 9 Sep 2024 18:01:54 +0200 Subject: [PATCH 14/21] Missing inputimeout in requirements.txt --- requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 03290b7..bfc8d8c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,4 +13,5 @@ webdriver-manager==4.0.2 click git+https://github.com/feder-cr/lib_resume_builder_AIHawk.git linkedin-api -pdfminer.six==20221105 \ No newline at end of file +pdfminer.six==20221105 +inputimeout \ No newline at end of file From f8e1572a4494ae4b5841ea7ea0a37c2818c12877 Mon Sep 17 00:00:00 2001 From: blackms Date: Mon, 9 Sep 2024 19:57:25 +0200 Subject: [PATCH 15/21] Restored data_folder and optimized gitignore file --- .gitignore | 171 +++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 154 insertions(+), 17 deletions(-) diff --git a/.gitignore b/.gitignore index bd8925b..c5c01ec 100644 --- a/.gitignore +++ b/.gitignore @@ -1,18 +1,155 @@ -*.csv -__pycache__/** -.idea/** -open_ai_calls.log -test* -openaiSelenium* -open_ai_calls.json -_* +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ +_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv’s dependency resolution may lead to different +# Pipfile.lock files generated on each colleague’s machine. +# Thus, uncomment the following line if the pipenv environment is expected to be identical +# across all environments. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env .venv -generated_cv* -.vscode -chrome_profile -answers.json -data* -*virtual -data_folder/*.yaml -app_log.log -venv \ No newline at end of file +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ + +# PyCharm and all JetBrains IDEs +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 +.idea/ +*.iml + +# Visual Studio Code +.vscode/ + +# Visual Studio 2015/2017/2019/2022 +.vs/ +*.opendb +*.VC.db + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# Mono Auto Generated Files +mono_crash.* + +# Project Specific +data_folder/output/* +generated_cv/* +chrome_profile/* +answers.json \ No newline at end of file From bffb561ad560bd75e8b4a417e2d6dd7ce2829f13 Mon Sep 17 00:00:00 2001 From: blackms Date: Mon, 9 Sep 2024 19:59:43 +0200 Subject: [PATCH 16/21] yaml files --- data_folder/config.yaml | 52 +++++++++++++ data_folder/plain_text_resume.yaml | 119 +++++++++++++++++++++++++++++ data_folder/secrets.yaml | 3 + 3 files changed, 174 insertions(+) create mode 100644 data_folder/config.yaml create mode 100644 data_folder/plain_text_resume.yaml create mode 100644 data_folder/secrets.yaml diff --git a/data_folder/config.yaml b/data_folder/config.yaml new file mode 100644 index 0000000..1cbe9ed --- /dev/null +++ b/data_folder/config.yaml @@ -0,0 +1,52 @@ +remote: [true/false] + +experienceLevel: + internship: [true/false] + entry: [true/false] + associate: [true/false] + mid-senior level: [true/false] + director: [true/false] + executive: [true/false] + +jobTypes: + full-time: [true/false] + contract: [true/false] + part-time: [true/false] + temporary: [true/false] + internship: [true/false] + other: [true/false] + volunteer: [true/false] + +date: + all time: [true/false] + month: [true/false] + week: [true/false] + 24 hours: [true/false] + +positions: + - position1 + - position2 + +locations: + - Country1 + - Country2 + +applyOnceAtCompany: [true/false] + +distance: 100 + +company_blacklist: + - Company1 + - Company2 + +titleBlacklist: + - word1 + - word2 + +job_applicants_threshold: + min_applicants: 0 + max_applicants: 100 + +llm_model_type: openai +llm_model: gpt-4o +llm_api_url: https://api.pawan.krd/cosmosrp/v1 \ No newline at end of file diff --git a/data_folder/plain_text_resume.yaml b/data_folder/plain_text_resume.yaml new file mode 100644 index 0000000..0c55645 --- /dev/null +++ b/data_folder/plain_text_resume.yaml @@ -0,0 +1,119 @@ +personal_information: + name: "[Your Name]" + surname: "[Your Surname]" + date_of_birth: "[Your Date of Birth]" + country: "[Your Country]" + city: "[Your City]" + address: "[Your Address]" + phone_prefix: "[Your Phone Prefix]" + phone: "[Your Phone Number]" + email: "[Your Email Address]" + github: "[Your GitHub Profile URL]" + linkedin: "[Your LinkedIn Profile URL]" + +education_details: + - education_level: "[Your Education Level]" + institution: "[Your Institution]" + field_of_study: "[Your Field of Study]" + final_evaluation_grade: "[Your Final Evaluation Grade]" + start_date: "[Start Date]" + year_of_completion: "[Year of Completion]" + exam: + exam_name_1: "[Grade]" + exam_name_2: "[Grade]" + exam_name_3: "[Grade]" + exam_name_4: "[Grade]" + exam_name_5: "[Grade]" + exam_name_6: "[Grade]" + +experience_details: + - position: "[Your Position]" + company: "[Company Name]" + employment_period: "[Employment Period]" + location: "[Location]" + industry: "[Industry]" + key_responsibilities: + - responsibility_1: "[Responsibility Description]" + - responsibility_2: "[Responsibility Description]" + - responsibility_3: "[Responsibility Description]" + skills_acquired: + - "[Skill]" + - "[Skill]" + - "[Skill]" + + - position: "[Your Position]" + company: "[Company Name]" + employment_period: "[Employment Period]" + location: "[Location]" + industry: "[Industry]" + key_responsibilities: + - responsibility_1: "[Responsibility Description]" + - responsibility_2: "[Responsibility Description]" + - responsibility_3: "[Responsibility Description]" + skills_acquired: + - "[Skill]" + - "[Skill]" + - "[Skill]" + +projects: + - name: "[Project Name]" + description: "[Project Description]" + link: "[Project Link]" + + - name: "[Project Name]" + description: "[Project Description]" + link: "[Project Link]" + +achievements: + - name: "[Achievement Name]" + description: "[Achievement Description]" + - name: "[Achievement Name]" + description: "[Achievement Description]" + +certifications: + - name: "[Certification Name]" + description: "[Certification Description]" + - name: "[Certification Name]" + description: "[Certification Description]" + +languages: + - language: "[Language]" + proficiency: "[Proficiency Level]" + - language: "[Language]" + proficiency: "[Proficiency Level]" + +interests: + - "[Interest]" + - "[Interest]" + - "[Interest]" + +availability: + notice_period: "[Notice Period]" + +salary_expectations: + salary_range_usd: "[Salary Range]" + +self_identification: + gender: "[Gender]" + pronouns: "[Pronouns]" + veteran: "[Yes/No]" + disability: "[Yes/No]" + ethnicity: "[Ethnicity]" + +legal_authorization: + eu_work_authorization: "[Yes/No]" + us_work_authorization: "[Yes/No]" + requires_us_visa: "[Yes/No]" + requires_us_sponsorship: "[Yes/No]" + requires_eu_visa: "[Yes/No]" + legally_allowed_to_work_in_eu: "[Yes/No]" + legally_allowed_to_work_in_us: "[Yes/No]" + requires_eu_sponsorship: "[Yes/No]" + +work_preferences: + remote_work: "[Yes/No]" + in_person_work: "[Yes/No]" + open_to_relocation: "[Yes/No]" + willing_to_complete_assessments: "[Yes/No]" + willing_to_undergo_drug_tests: "[Yes/No]" + willing_to_undergo_background_checks: "[Yes/No]" \ No newline at end of file diff --git a/data_folder/secrets.yaml b/data_folder/secrets.yaml new file mode 100644 index 0000000..c218803 --- /dev/null +++ b/data_folder/secrets.yaml @@ -0,0 +1,3 @@ +email: myemaillinkedin@gmail.com +password: ImpossiblePassowrd10 +llm_api_key: 'sk-11KRr4uuTwpRGfeRTfj1T9BlbkFJjP8QTrswHU1yGruru2FR' \ No newline at end of file From b554357be99dc4127bd3eee783d4701198a20a44 Mon Sep 17 00:00:00 2001 From: queukat Date: Mon, 9 Sep 2024 23:39:12 +0300 Subject: [PATCH 17/21] Revert "fixed some issues" This reverts commit 6540bbbb40acc88ff0138e6cfc8754243e0fe032. --- data_folder/config.yaml | 7 ++- data_folder_example/config.yaml | 8 ++-- main.py | 79 +++++++++++++-------------------- requirements.txt | 7 +-- resume_yaml_generator.py | 24 +++------- src/gpt.py | 48 +++++++++----------- src/linkedIn_easy_applier.py | 17 +++---- src/linkedIn_job_manager.py | 9 ++-- src/utils.py | 5 --- 9 files changed, 77 insertions(+), 127 deletions(-) diff --git a/data_folder/config.yaml b/data_folder/config.yaml index a037034..1cbe9ed 100644 --- a/data_folder/config.yaml +++ b/data_folder/config.yaml @@ -1,6 +1,6 @@ remote: [true/false] -experience_level: +experienceLevel: internship: [true/false] entry: [true/false] associate: [true/false] @@ -31,7 +31,7 @@ locations: - Country1 - Country2 -apply_once_at_company: [ true/false] +applyOnceAtCompany: [true/false] distance: 100 @@ -39,8 +39,7 @@ company_blacklist: - Company1 - Company2 - -title_blacklist: +titleBlacklist: - word1 - word2 diff --git a/data_folder_example/config.yaml b/data_folder_example/config.yaml index 316ab8f..b9ccefa 100644 --- a/data_folder_example/config.yaml +++ b/data_folder_example/config.yaml @@ -1,6 +1,6 @@ remote: true -experience_level: +experienceLevel: internship: true entry: true associate: true @@ -29,15 +29,15 @@ positions: locations: - USA -apply_once_at_company: [true/false] +applyOnceAtCompany: [true/false] distance: 100 -company_blacklist: +companyBlacklist: - Noir - Crossover -title_blacklist: +titleBlacklist: llm_model_type: openai llm_model: 'gpt-4o' diff --git a/main.py b/main.py index 68a0527..afa9044 100644 --- a/main.py +++ b/main.py @@ -7,9 +7,9 @@ import click from selenium import webdriver from selenium.webdriver.chrome.service import Service as ChromeService from webdriver_manager.chrome import ChromeDriverManager -from selenium.common.exceptions import WebDriverException -from lib_resume_builder_AIHawk import Resume, StyleManager, FacadeManager, ResumeGenerator -from src.utils import chrome_browser_options +from selenium.common.exceptions import WebDriverException, TimeoutException +from lib_resume_builder_AIHawk import Resume,StyleManager,FacadeManager,ResumeGenerator +from src.utils import chromeBrowserOptions from src.gpt import GPTAnswerer from src.linkedIn_authenticator import LinkedInAuthenticator from src.linkedIn_bot_facade import LinkedInBotFacade @@ -19,16 +19,14 @@ from src.job_application_profile import JobApplicationProfile # Suppress stderr sys.stderr = open(os.devnull, 'w') - class ConfigError(Exception): pass - class ConfigValidator: @staticmethod def validate_email(email: str) -> bool: return re.match(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', email) is not None - + @staticmethod def validate_yaml_file(yaml_path: Path) -> dict: try: @@ -38,37 +36,37 @@ class ConfigValidator: raise ConfigError(f"Error reading file {yaml_path}: {exc}") except FileNotFoundError: raise ConfigError(f"File not found: {yaml_path}") - + + def validate_config(config_yaml_path: Path) -> dict: parameters = ConfigValidator.validate_yaml_file(config_yaml_path) required_keys = { 'remote': bool, - 'experience_level': dict, + 'experienceLevel': dict, 'jobTypes': dict, 'date': dict, 'positions': list, 'locations': list, 'distance': int, - 'company_blacklist': list, - 'title_blacklist': list + 'companyBlacklist': list, + 'titleBlacklist': list } for key, expected_type in required_keys.items(): if key not in parameters: - if key in ['company_blacklist', 'title_blacklist']: + if key in ['companyBlacklist', 'titleBlacklist']: parameters[key] = [] else: raise ConfigError(f"Missing or invalid key '{key}' in config file {config_yaml_path}") elif not isinstance(parameters[key], expected_type): - if key in ['company_blacklist', 'title_blacklist'] and parameters[key] is None: + if key in ['companyBlacklist', 'titleBlacklist'] and parameters[key] is None: parameters[key] = [] else: - raise ConfigError( - f"Invalid type for key '{key}' in config file {config_yaml_path}. Expected {expected_type}.") + raise ConfigError(f"Invalid type for key '{key}' in config file {config_yaml_path}. Expected {expected_type}.") experience_levels = ['internship', 'entry', 'associate', 'mid-senior level', 'director', 'executive'] for level in experience_levels: - if not isinstance(parameters['experience_level'].get(level), bool): + if not isinstance(parameters['experienceLevel'].get(level), bool): raise ConfigError(f"Experience level '{level}' must be a boolean in config file {config_yaml_path}") job_types = ['full-time', 'contract', 'part-time', 'temporary', 'internship', 'other', 'volunteer'] @@ -88,10 +86,9 @@ class ConfigValidator: approved_distances = {0, 5, 10, 25, 50, 100} if parameters['distance'] not in approved_distances: - raise ConfigError( - f"Invalid distance value in config file {config_yaml_path}. Must be one of: {approved_distances}") + raise ConfigError(f"Invalid distance value in config file {config_yaml_path}. Must be one of: {approved_distances}") - for blacklist in ['company_blacklist', 'title_blacklist']: + for blacklist in ['companyBlacklist', 'titleBlacklist']: if not isinstance(parameters.get(blacklist), list): raise ConfigError(f"'{blacklist}' must be a list in config file {config_yaml_path}") if parameters[blacklist] is None: @@ -99,6 +96,8 @@ class ConfigValidator: return parameters + + @staticmethod def validate_secrets(secrets_yaml_path: Path) -> tuple: secrets = ConfigValidator.validate_yaml_file(secrets_yaml_path) @@ -114,13 +113,10 @@ class ConfigValidator: raise ConfigError(f"Password cannot be empty in secrets file {secrets_yaml_path}.") return secrets['email'], str(secrets['password']), secrets['llm_api_key'] - class FileManager: @staticmethod def find_file(name_containing: str, with_extension: str, at_path: Path) -> Path: - return next((file for file in at_path.iterdir() if - name_containing.lower() in file.name.lower() and file.suffix.lower() == with_extension.lower()), - None) + return next((file for file in at_path.iterdir() if name_containing.lower() in file.name.lower() and file.suffix.lower() == with_extension.lower()), None) @staticmethod def validate_data_folder(app_data_folder: Path) -> tuple: @@ -129,15 +125,13 @@ class FileManager: required_files = ['secrets.yaml', 'config.yaml', 'plain_text_resume.yaml'] missing_files = [file for file in required_files if not (app_data_folder / file).exists()] - + if missing_files: raise FileNotFoundError(f"Missing files in the data folder: {', '.join(missing_files)}") output_folder = app_data_folder / 'output' output_folder.mkdir(exist_ok=True) - return ( - app_data_folder / 'secrets.yaml', app_data_folder / 'config.yaml', app_data_folder / 'plain_text_resume.yaml', - output_folder) + return (app_data_folder / 'secrets.yaml', app_data_folder / 'config.yaml', app_data_folder / 'plain_text_resume.yaml', output_folder) @staticmethod def file_paths_to_dict(resume_file: Path | None, plain_text_resume_file: Path) -> dict: @@ -153,16 +147,14 @@ class FileManager: return result - def init_browser() -> webdriver.Chrome: try: - options = chrome_browser_options() + options = chromeBrowserOptions() service = ChromeService(ChromeDriverManager().install()) return webdriver.Chrome(service=service, options=options) except Exception as e: raise RuntimeError(f"Failed to initialize browser: {str(e)}") - def create_and_run_bot(email, password, parameters, llm_api_key): try: style_manager = StyleManager() @@ -170,14 +162,13 @@ def create_and_run_bot(email, password, parameters, llm_api_key): with open(parameters['uploads']['plainTextResume'], "r", encoding='utf-8') as file: plain_text_resume = file.read() resume_object = Resume(plain_text_resume) - resume_generator_manager = FacadeManager(llm_api_key, style_manager, resume_generator, resume_object, - Path("data_folder/output")) + resume_generator_manager = FacadeManager(llm_api_key, style_manager, resume_generator, resume_object, Path("data_folder/output")) os.system('cls' if os.name == 'nt' else 'clear') resume_generator_manager.choose_style() os.system('cls' if os.name == 'nt' else 'clear') - + job_application_profile_object = JobApplicationProfile(plain_text_resume) - + browser = init_browser() login_component = LinkedInAuthenticator(browser) apply_component = LinkedInJobManager(browser) @@ -196,40 +187,34 @@ def create_and_run_bot(email, password, parameters, llm_api_key): @click.command() -@click.option('--resume', type=click.Path(exists=True, file_okay=True, dir_okay=False, path_type=Path), - help="Path to the resume PDF file") +@click.option('--resume', type=click.Path(exists=True, file_okay=True, dir_okay=False, path_type=Path), help="Path to the resume PDF file") def main(resume: Path = None): try: data_folder = Path("data_folder") secrets_file, config_file, plain_text_resume_file, output_folder = FileManager.validate_data_folder(data_folder) - + parameters = ConfigValidator.validate_config(config_file) email, password, llm_api_key = ConfigValidator.validate_secrets(secrets_file) - + parameters['uploads'] = FileManager.file_paths_to_dict(resume, plain_text_resume_file) parameters['outputFileDirectory'] = output_folder - + create_and_run_bot(email, password, parameters, llm_api_key) except ConfigError as ce: print(f"Configuration error: {str(ce)}") - print( - "Refer to the configuration guide for troubleshooting: https://github.com/feder-cr/LinkedIn_AIHawk_automatic_job_application/blob/main/readme.md#configuration") + print("Refer to the configuration guide for troubleshooting: https://github.com/feder-cr/LinkedIn_AIHawk_automatic_job_application/blob/main/readme.md#configuration") except FileNotFoundError as fnf: print(f"File not found: {str(fnf)}") print("Ensure all required files are present in the data folder.") - print( - "Refer to the file setup guide: https://github.com/feder-cr/LinkedIn_AIHawk_automatic_job_application/blob/main/readme.md#configuration") + print("Refer to the file setup guide: https://github.com/feder-cr/LinkedIn_AIHawk_automatic_job_application/blob/main/readme.md#configuration") except RuntimeError as re: print(f"Runtime error: {str(re)}") - print( - "Refer to the configuration and troubleshooting guide: https://github.com/feder-cr/LinkedIn_AIHawk_automatic_job_application/blob/main/readme.md#configuration") + print("Refer to the configuration and troubleshooting guide: https://github.com/feder-cr/LinkedIn_AIHawk_automatic_job_application/blob/main/readme.md#configuration") except Exception as e: print(f"An unexpected error occurred: {str(e)}") - print( - "Refer to the general troubleshooting guide: https://github.com/feder-cr/LinkedIn_AIHawk_automatic_job_application/blob/main/readme.md#configuration") - + print("Refer to the general troubleshooting guide: https://github.com/feder-cr/LinkedIn_AIHawk_automatic_job_application/blob/main/readme.md#configuration") if __name__ == "__main__": main() diff --git a/requirements.txt b/requirements.txt index 7e3d816..03290b7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,9 +13,4 @@ webdriver-manager==4.0.2 click git+https://github.com/feder-cr/lib_resume_builder_AIHawk.git linkedin-api -pdfminer.six==20221105 -inputimeout==1.0.4 -langchain-ollama==0.1.3 -langchain-anthropic==0.1.3 -jsonschema==4.23.0 -jsonschema-specifications==2023.12.1 \ No newline at end of file +pdfminer.six==20221105 \ No newline at end of file diff --git a/resume_yaml_generator.py b/resume_yaml_generator.py index 053245f..336a23d 100644 --- a/resume_yaml_generator.py +++ b/resume_yaml_generator.py @@ -7,22 +7,19 @@ import re from jsonschema import validate, ValidationError from pdfminer.high_level import extract_text - def load_yaml(file_path: str) -> Dict[str, Any]: with open(file_path, 'r') as file: return yaml.safe_load(file) - def load_resume_text(file_path: str) -> str: with open(file_path, 'r') as file: return file.read() - def get_api_key() -> str: secrets_path = os.path.join('data_folder', 'secrets.yaml') if not os.path.exists(secrets_path): raise FileNotFoundError(f"Secrets file not found at {secrets_path}") - + secrets = load_yaml(secrets_path) if not 'llm_api_key' in secrets: @@ -31,10 +28,9 @@ def get_api_key() -> str: api_key = secrets.get('llm_api_key') if not api_key: raise ValueError("LLM API key not found in secrets.yaml") - + return api_key - def generate_yaml_from_resume(resume_text: str, schema: Dict[str, Any], api_key: str) -> str: client = OpenAI(api_key=api_key) @@ -87,15 +83,14 @@ def generate_yaml_from_resume(resume_text: str, schema: Dict[str, Any], api_key: response = client.chat.completions.create( model="gpt-4o-mini", messages=[ - {"role": "system", - "content": "You are a helpful assistant that generates structured YAML content from resume files, paying close attention to format requirements and schema structure."}, + {"role": "system", "content": "You are a helpful assistant that generates structured YAML content from resume files, paying close attention to format requirements and schema structure."}, {"role": "user", "content": prompt} ], temperature=0.5, ) yaml_content = response.choices[0].message.content.strip() - + # Extract YAML content from between the tags match = re.search(r'(.*?)', yaml_content, re.DOTALL) if match: @@ -103,12 +98,10 @@ def generate_yaml_from_resume(resume_text: str, schema: Dict[str, Any], api_key: else: raise ValueError("YAML content not found in the expected format") - def save_yaml(data: str, output_file: str): with open(output_file, 'w') as file: file.write(data) - def validate_yaml(yaml_content: str, schema: Dict[str, Any]) -> Dict[str, Any]: try: yaml_dict = yaml.safe_load(yaml_content) @@ -117,7 +110,6 @@ def validate_yaml(yaml_content: str, schema: Dict[str, Any]) -> Dict[str, Any]: except ValidationError as e: return {"valid": False, "errors": str(e)} - def generate_report(validation_result: Dict[str, Any], output_file: str): report = f"Validation Report for {output_file}\n" report += "=" * 40 + "\n" @@ -126,17 +118,14 @@ def generate_report(validation_result: Dict[str, Any], output_file: str): else: report += "YAML is not valid. Errors:\n" report += validation_result["errors"] + "\n" - + print(report) - def pdf_to_text(pdf_path: str) -> str: return extract_text(pdf_path) - def main(): - parser = argparse.ArgumentParser( - description="Generate a resume YAML file from a PDF or text resume using OpenAI API") + parser = argparse.ArgumentParser(description="Generate a resume YAML file from a PDF or text resume using OpenAI API") parser.add_argument("--input", required=True, help="Path to the input resume file (PDF or TXT)") parser.add_argument("--output", default="data_folder/plain_text_resume.yaml", help="Path to the output YAML file") args = parser.parse_args() @@ -167,6 +156,5 @@ def main(): except Exception as e: print(f"An error occurred: {e}") - if __name__ == "__main__": main() diff --git a/src/gpt.py b/src/gpt.py index d5f78ad..4107797 100644 --- a/src/gpt.py +++ b/src/gpt.py @@ -6,7 +6,8 @@ import time from abc import ABC, abstractmethod from datetime import datetime from pathlib import Path -from typing import Dict, List, Union +from typing import Dict, List +from typing import Union import httpx from Levenshtein import distance @@ -37,7 +38,7 @@ class OpenAIModel(AIModel): def invoke(self, prompt: str) -> str: print("invoke in openai") response = self.model.invoke(prompt) - return response.content + return response class ClaudeModel(AIModel): @@ -48,7 +49,7 @@ class ClaudeModel(AIModel): def invoke(self, prompt: str) -> str: response = self.model.invoke(prompt) - return response.content + return response class OllamaModel(AIModel): @@ -58,14 +59,14 @@ class OllamaModel(AIModel): def invoke(self, prompt: str) -> str: response = self.model.invoke(prompt) - return response.content + 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) -> Union[OpenAIModel, OllamaModel, ClaudeModel]: + 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'] @@ -78,7 +79,7 @@ class AIAdapter: elif llm_model_type == "ollama": return OllamaModel(api_key, llm_model, llm_api_url) else: - raise ValueError(f"Unsupported model type: {llm_model_type}") + raise ValueError(f"Unsupported model type: {model_type}") def invoke(self, prompt: str) -> str: return self.model.invoke(prompt) @@ -108,34 +109,25 @@ class LLMLogger: logger.debug("Prompts are of type StringPromptValue") prompts = prompts.text logger.debug("Prompts converted to text: %s", prompts) - elif isinstance(prompts, dict): - logger.debug("Prompts are of type dict") + elif isinstance(prompts, Dict): + logger.debug("Prompts are of type Dict") try: - if "messages" in prompts: - logger.debug("Prompts contain 'messages' key") - prompts = { - f"prompt_{i + 1}": prompt["content"] - for i, prompt in enumerate(prompts["messages"]) - } - logger.debug("Prompts converted to dictionary: %s", prompts) - else: - logger.debug("Prompts dictionary does not contain 'messages' key") + prompts = { + f"prompt_{i + 1}": prompt.content + for i, prompt in enumerate(prompts.messages) + } + logger.debug("Prompts converted to dictionary: %s", prompts) except Exception as e: logger.error("Error converting prompts to dictionary: %s", str(e)) raise else: logger.debug("Prompts are of unknown type, attempting default conversion") try: - if hasattr(prompts, "messages"): - logger.debug("Prompts have 'messages' attribute") - prompts = { - f"prompt_{i + 1}": prompt.content - for i, prompt in enumerate(prompts.messages) - } - logger.debug("Prompts converted to dictionary using default method: %s", prompts) - else: - logger.error("Prompts do not have 'messages' attribute, and default conversion failed") - raise ValueError("Prompts structure is not supported.") + prompts = { + f"prompt_{i + 1}": prompt.content + for i, prompt in enumerate(prompts.messages) + } + logger.debug("Prompts converted to dictionary using default method: %s", prompts) except Exception as e: logger.error("Error converting prompts using default method: %s", str(e)) raise @@ -299,7 +291,7 @@ class GPTAnswerer: def __init__(self, config, llm_api_key): self.ai_adapter = AIAdapter(config, llm_api_key) - self.llm_cheap = LoggerChatModel(self.ai_adapter.model) + self.llm_cheap = LoggerChatModel(self.ai_adapter) @property def job_description(self): diff --git a/src/linkedIn_easy_applier.py b/src/linkedIn_easy_applier.py index 9363ac5..cf64245 100644 --- a/src/linkedIn_easy_applier.py +++ b/src/linkedIn_easy_applier.py @@ -5,8 +5,7 @@ import random import re import time import traceback -from pathlib import Path -from typing import List, Optional, Any, Tuple, Set +from typing import List, Optional, Any, Tuple from httpx import HTTPStatusError from reportlab.lib.pagesizes import A4 @@ -24,13 +23,11 @@ from src.utils import logger class LinkedInEasyApplier: - def __init__(self, driver: Any, resume_dir: Optional[str], set_old_answers: Set[Tuple[str, str, str]], + 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 - else: - resume_dir = Path(resume_dir) self.driver = driver self.resume_path = resume_dir self.set_old_answers = set_old_answers @@ -541,19 +538,17 @@ class LinkedInEasyApplier: lines = split_text_by_width(cover_letter_text, "Helvetica", 12, max_width) - line_height = 14 - max_lines_per_page = int(available_height // line_height) - for line in lines: text_height = text_object.getY() + if text_height > bottom_margin: + text_object.textLine(line) + else: - if text_height - line_height < bottom_margin: c.drawText(text_object) c.showPage() text_object = c.beginText(50, page_height - 50) text_object.setFont("Helvetica", 12) - - text_object.textLine(line) + text_object.textLine(line) c.drawText(text_object) c.save() diff --git a/src/linkedIn_job_manager.py b/src/linkedIn_job_manager.py index 8be9f02..9308708 100644 --- a/src/linkedIn_job_manager.py +++ b/src/linkedIn_job_manager.py @@ -47,10 +47,10 @@ class LinkedInJobManager: def set_parameters(self, parameters): logger.debug("Setting parameters for LinkedInJobManager") self.company_blacklist = parameters.get('company_blacklist', []) or [] - self.title_blacklist = parameters.get('title_blacklist', []) or [] + self.title_blacklist = parameters.get('titleBlacklist', []) or [] self.positions = parameters.get('positions', []) self.locations = parameters.get('locations', []) - self.apply_once_at_company = parameters.get('apply_once_at_company', False) + self.apply_once_at_company = parameters.get('applyOnceAtCompany', False) self.base_search_url = self.get_base_search_url(parameters) self.seen_jobs = [] @@ -272,7 +272,7 @@ class LinkedInJobManager: logger.debug(f"Applicants text found: {applicants_text}") # Extract numeric digits from the text (e.g., "70 applicants" -> "70") - applicants_count = ''.join([char for char in str(applicants_text) if char.isdigit()]) + applicants_count = ''.join(filter(str.isdigit, applicants_text)) logger.debug(f"Extracted applicants count: {applicants_count}") if applicants_count: @@ -370,7 +370,7 @@ 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('experience_level', {}).items()) if + 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)}") @@ -429,6 +429,7 @@ class LinkedInJobManager: 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 return title_blacklisted or company_blacklisted or link_seen diff --git a/src/utils.py b/src/utils.py index e8b8429..f4e4d4a 100644 --- a/src/utils.py +++ b/src/utils.py @@ -179,8 +179,3 @@ def printyellow(text): reset = "\033[0m" logger.debug("Printing text in yellow: %s", text) print(f"{yellow}{text}{reset}") - - -def stringWidth(text, font, font_size): - bbox = font.getbbox(text) - return bbox[2] - bbox[0] From 3f13d9bf1a67cae871bc44d92a0e910b98ff407f Mon Sep 17 00:00:00 2001 From: queukat Date: Mon, 9 Sep 2024 23:44:16 +0300 Subject: [PATCH 18/21] fixed names and llm problems --- data_folder/config.yaml | 6 +++--- data_folder_example/config.yaml | 14 ++++++++++---- src/gpt.py | 4 ++-- src/linkedIn_job_manager.py | 21 ++++++++++----------- 4 files changed, 25 insertions(+), 20 deletions(-) diff --git a/data_folder/config.yaml b/data_folder/config.yaml index 1cbe9ed..2051ec8 100644 --- a/data_folder/config.yaml +++ b/data_folder/config.yaml @@ -1,6 +1,6 @@ remote: [true/false] -experienceLevel: +experience_level: internship: [true/false] entry: [true/false] associate: [true/false] @@ -31,7 +31,7 @@ locations: - Country1 - Country2 -applyOnceAtCompany: [true/false] +apply_once_at_company: [true/false] distance: 100 @@ -39,7 +39,7 @@ company_blacklist: - Company1 - Company2 -titleBlacklist: +title_blacklist: - word1 - word2 diff --git a/data_folder_example/config.yaml b/data_folder_example/config.yaml index b9ccefa..6b2da50 100644 --- a/data_folder_example/config.yaml +++ b/data_folder_example/config.yaml @@ -1,6 +1,6 @@ remote: true -experienceLevel: +experience_level: internship: true entry: true associate: true @@ -29,15 +29,21 @@ positions: locations: - USA -applyOnceAtCompany: [true/false] +apply_once_at_company: [true/false] distance: 100 -companyBlacklist: +company_blacklist: - Noir - Crossover -titleBlacklist: +title_blacklist: + - word1 + - word2 + +job_applicants_threshold: + min_applicants: 0 + max_applicants: 100 llm_model_type: openai llm_model: 'gpt-4o' diff --git a/src/gpt.py b/src/gpt.py index 4107797..5871e9e 100644 --- a/src/gpt.py +++ b/src/gpt.py @@ -79,7 +79,7 @@ class AIAdapter: elif llm_model_type == "ollama": return OllamaModel(api_key, llm_model, llm_api_url) else: - raise ValueError(f"Unsupported model type: {model_type}") + raise ValueError(f"Unsupported model type: {llm_model_type}") def invoke(self, prompt: str) -> str: return self.model.invoke(prompt) @@ -204,7 +204,7 @@ class LoggerChatModel: while True: try: logger.debug("Attempting to call the LLM with messages") - reply = self.llm(messages) + reply = self.llm.invoke(messages) logger.debug("LLM response received: %s", reply) parsed_reply = self.parse_llmresult(reply) diff --git a/src/linkedIn_job_manager.py b/src/linkedIn_job_manager.py index 9308708..1e1db0a 100644 --- a/src/linkedIn_job_manager.py +++ b/src/linkedIn_job_manager.py @@ -47,10 +47,10 @@ class LinkedInJobManager: def set_parameters(self, parameters): logger.debug("Setting parameters for LinkedInJobManager") self.company_blacklist = parameters.get('company_blacklist', []) or [] - self.title_blacklist = parameters.get('titleBlacklist', []) or [] + self.title_blacklist = parameters.get('title_blacklist', []) or [] self.positions = parameters.get('positions', []) self.locations = parameters.get('locations', []) - self.apply_once_at_company = parameters.get('applyOnceAtCompany', False) + self.apply_once_at_company = parameters.get('apply_once_at_company', False) self.base_search_url = self.get_base_search_url(parameters) self.seen_jobs = [] @@ -120,8 +120,8 @@ class LinkedInJobManager: if time_left > 0: try: user_input = inputimeout( - prompt=f"Sleeping for {time_left} seconds. Press 'y' to skip waiting. Timeout 10 seconds : ", - timeout=10).strip().lower() + prompt=f"Sleeping for {time_left} seconds. Press 'y' to skip waiting. Timeout 60 seconds : ", + timeout=60).strip().lower() except TimeoutOccurred: user_input = '' # No input after timeout if user_input == 'y': @@ -138,8 +138,8 @@ class LinkedInJobManager: sleep_time = random.randint(5, 34) try: user_input = inputimeout( - prompt=f"Sleeping for {sleep_time / 60} minutes. Press 'y' to skip waiting. Timeout 10 seconds : ", - timeout=10).strip().lower() + prompt=f"Sleeping for {sleep_time / 60} minutes. Press 'y' to skip waiting. Timeout 60 seconds : ", + timeout=60).strip().lower() except TimeoutOccurred: user_input = '' # No input after timeout if user_input == 'y': @@ -160,8 +160,8 @@ class LinkedInJobManager: if time_left > 0: try: user_input = inputimeout( - prompt=f"Sleeping for {time_left} seconds. Press 'y' to skip waiting. Timeout 10 seconds : ", - timeout=10).strip().lower() + prompt=f"Sleeping for {time_left} seconds. Press 'y' to skip waiting. Timeout 60 seconds : ", + timeout=60).strip().lower() except TimeoutOccurred: user_input = '' # No input after timeout if user_input == 'y': @@ -179,7 +179,7 @@ class LinkedInJobManager: try: user_input = inputimeout( prompt=f"Sleeping for {sleep_time / 60} minutes. Press 'y' to skip waiting: ", - timeout=10).strip().lower() + timeout=60).strip().lower() except TimeoutOccurred: user_input = '' # No input after timeout if user_input == 'y': @@ -370,7 +370,7 @@ 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 + experience_levels = [str(i + 1) for i, (level, v) in enumerate(parameters.get('experience_level', {}).items()) if v] if experience_levels: url_parts.append(f"f_E={','.join(experience_levels)}") @@ -429,7 +429,6 @@ class LinkedInJobManager: 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 return title_blacklisted or company_blacklisted or link_seen From 74f0f13de4e261519af1f9b9a0b6047789426f72 Mon Sep 17 00:00:00 2001 From: queukat Date: Mon, 9 Sep 2024 23:49:51 +0300 Subject: [PATCH 19/21] fixed logs and requirements --- main.py | 4 ++-- requirements.txt | 10 +++++++++- src/utils.py | 4 ++-- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/main.py b/main.py index afa9044..047724b 100644 --- a/main.py +++ b/main.py @@ -9,7 +9,7 @@ from selenium.webdriver.chrome.service import Service as ChromeService from webdriver_manager.chrome import ChromeDriverManager from selenium.common.exceptions import WebDriverException, TimeoutException from lib_resume_builder_AIHawk import Resume,StyleManager,FacadeManager,ResumeGenerator -from src.utils import chromeBrowserOptions +from src.utils import chrome_browser_options from src.gpt import GPTAnswerer from src.linkedIn_authenticator import LinkedInAuthenticator from src.linkedIn_bot_facade import LinkedInBotFacade @@ -149,7 +149,7 @@ class FileManager: def init_browser() -> webdriver.Chrome: try: - options = chromeBrowserOptions() + options = chrome_browser_options() service = ChromeService(ChromeDriverManager().install()) return webdriver.Chrome(service=service, options=options) except Exception as e: diff --git a/requirements.txt b/requirements.txt index 03290b7..de21428 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,4 +13,12 @@ webdriver-manager==4.0.2 click git+https://github.com/feder-cr/lib_resume_builder_AIHawk.git linkedin-api -pdfminer.six==20221105 \ No newline at end of file +pdfminer.six==20221105 +inputimeout==1.0.4 +langchain-ollama==0.1.3 +langchain-anthropic==0.1.3 +jsonschema==4.23.0 +jsonschema-specifications==2023.12.1 +httpx~=0.27.2 +python-dotenv~=1.0.1 +PyYAML~=6.0.2 diff --git a/src/utils.py b/src/utils.py index f4e4d4a..0cd2c87 100644 --- a/src/utils.py +++ b/src/utils.py @@ -8,7 +8,7 @@ from selenium import webdriver log_file = "app_log.log" logging.basicConfig( - level=logging.DEBUG, + level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', handlers=[ logging.FileHandler(log_file, mode='a', encoding='utf-8'), @@ -22,7 +22,7 @@ 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) +logger.setLevel(logging.INFO) chromeProfilePath = os.path.join(os.getcwd(), "chrome_profile", "linkedin_profile") From 0d036f6a6eb0221ad5fce9f85f868c51322a4492 Mon Sep 17 00:00:00 2001 From: "Khalid F. Ahmed" Date: Tue, 10 Sep 2024 09:16:04 +0300 Subject: [PATCH 20/21] Adding Google Gemini --- requirements.txt | 1 + src/gpt.py | 19 +++++++++++++++---- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/requirements.txt b/requirements.txt index de21428..11127a8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,6 +17,7 @@ pdfminer.six==20221105 inputimeout==1.0.4 langchain-ollama==0.1.3 langchain-anthropic==0.1.3 +langchain-google-genai==1.0.10 jsonschema==4.23.0 jsonschema-specifications==2023.12.1 httpx~=0.27.2 diff --git a/src/gpt.py b/src/gpt.py index e87c6f6..c82e02e 100644 --- a/src/gpt.py +++ b/src/gpt.py @@ -62,6 +62,16 @@ class OllamaModel(AIModel): return response +class GeminiModel(AIModel): + def __init__(self, api_key:str, llm_model: str, llm_api_url: str): + from langchain_google_genai import ChatGoogleGenerativeAI + self.model = ChatGoogleGenerativeAI(model=llm_model, google_api_key=api_key) + + 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) @@ -79,6 +89,8 @@ class AIAdapter: return ClaudeModel(api_key, llm_model, llm_api_url) elif llm_model_type == "ollama": return OllamaModel(api_key, llm_model, llm_api_url) + elif llm_model_type == "gemini": + return GeminiModel(api_key, llm_model, llm_api_url) else: raise ValueError(f"Unsupported model type: {llm_model_type}") @@ -88,7 +100,7 @@ class AIAdapter: class LLMLogger: - def __init__(self, llm: Union[OpenAIModel, OllamaModel, ClaudeModel]): + def __init__(self, llm: Union[OpenAIModel, OllamaModel, ClaudeModel, GeminiModel]): self.llm = llm logger.debug("LLMLogger successfully initialized with LLM: %s", llm) @@ -203,7 +215,7 @@ class LLMLogger: class LoggerChatModel: - def __init__(self, llm: Union[OpenAIModel, OllamaModel, ClaudeModel]): + def __init__(self, llm: Union[OpenAIModel, OllamaModel, ClaudeModel, GeminiModel]): self.llm = llm logger.debug( "LoggerChatModel successfully initialized with LLM: %s", llm) @@ -494,8 +506,7 @@ class GPTAnswerer: 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.") + raise ValueError(f"Section '{section_name}' not found in either resume or job_application_profile.") chain = chains.get(section_name) if chain is None: logger.error("Chain not defined for section '%s'", section_name) From 67ad78ba7b16380a954140b816ec227ed2bfaca2 Mon Sep 17 00:00:00 2001 From: "Khalid F. Ahmed" Date: Tue, 10 Sep 2024 09:31:21 +0300 Subject: [PATCH 21/21] updating README.md --- README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 64cf229..64405f0 100644 --- a/README.md +++ b/README.md @@ -153,7 +153,7 @@ This file contains sensitive information. Never share or commit this file to ver - Replace with your LinkedIn account email address - `password: [Your LinkedIn password]` - Replace with your LinkedIn account password -- `llm_api_key: [Your OpenAI or Ollama API key]` +- `llm_api_key: [Your OpenAI or Ollama API key or Gemini API key]` - Replace with your OpenAI API key for GPT integration - To obtain an API key, follow the tutorial at: https://medium.com/@lorenzozar/how-to-get-your-own-openai-api-key-f4d44e60c327 - Note: You need to add credit to your OpenAI account to use the API. You can add credit by visiting the [OpenAI billing dashboard](https://platform.openai.com/account/billing). @@ -162,6 +162,7 @@ This file contains sensitive information. Never share or commit this file to ver `{'error': {'message': 'Rate limit reached for gpt-4o-mini in organization on requests per day (RPD): Limit 200, Used 200, Requested 1.}}` OpenAI will update your account automatically, but it might take some time, ranging from a couple of hours to a few days. You can find more about your organization limits on the [official page](https://platform.openai.com/settings/organization/limits). + - For obtaining Gemini API key visit [Google AI for Devs](https://ai.google.dev/gemini-api/docs/api-key) ### 2. config.yaml @@ -225,17 +226,19 @@ This file defines your job search parameters and bot behavior. Each section cont #### 2.1 config.yaml - Customize LLM model endpoint - `llm_model_type`: - - Choose the model type, supported: openai / ollama / claude + - Choose the model type, supported: openai / ollama / claude / gemini - `llm_model`: - Choose the LLM model, currently supported: - openai: gpt-4o - ollama: llama2, mistral:v0.3 - claude: any model + - gemini: any model - `llm_api_url`: - Link of the API endpoint for the LLM model - openai: https://api.pawan.krd/cosmosrp/v1 - ollama: http://127.0.0.1:11434/ - claude: https://api.anthropic.com/v1 + - gemini: no api_url - Note: To run local Ollama, follow the guidelines here: [Guide to Ollama deployment](https://github.com/ollama/ollama) ### 3. plain_text_resume.yaml