From f948443a3975243edf5c8922346fa6c7a43cfa5b Mon Sep 17 00:00:00 2001 From: Manu Altieri Date: Thu, 29 Aug 2024 17:08:26 +0200 Subject: [PATCH 01/97] Make the function better, because is not needed check selectors. when a user is logged on linkedin will be redirected already on /feed/ endpoint, otherwise url will remain https://linkedin.com, so is possible check the state of login just using this approach --- src/linkedIn_authenticator.py | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/src/linkedIn_authenticator.py b/src/linkedIn_authenticator.py index c953e5a..0153504 100644 --- a/src/linkedIn_authenticator.py +++ b/src/linkedIn_authenticator.py @@ -65,18 +65,8 @@ class LinkedInAuthenticator: print("Security check not completed. Please try again later.") def is_logged_in(self): - self.driver.get('https://www.linkedin.com/feed') - try: - WebDriverWait(self.driver, 10).until( - EC.presence_of_element_located((By.CLASS_NAME, 'share-box-feed-entry__trigger')) - ) - buttons = self.driver.find_elements(By.CLASS_NAME, 'share-box-feed-entry__trigger') - if any(button.text.strip() == 'Start a post' for button in buttons): - print("User is already logged in.") - return True - except TimeoutException: - pass - return False + self.driver.get('https://www.linkedin.com/') + return self.driver.current_url == 'https://www.linkedin.com/feed/' def wait_for_page_load(self, timeout=10): try: From e898df98808d44fc36abb9eecd78f52ad309ed38 Mon Sep 17 00:00:00 2001 From: feder-cr <85809106+feder-cr@users.noreply.github.com> Date: Thu, 29 Aug 2024 17:11:38 +0200 Subject: [PATCH 02/97] v3 lib --- requirements.txt | Bin 670 -> 680 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/requirements.txt b/requirements.txt index f74a689c00362ab6724bec113a729791b031cce0..ac9bc87c6e40b017241fe7176e70bd89834eb044 100644 GIT binary patch delta 18 ZcmbQox`K7XJSHv&hB5|Y23`g(1^_T51FHZ4 delta 7 OcmZ3%I*)b3JSG4OH3Fdk From 0e90d4be7dbcf67e61b1935ce8d30251d7c7652a Mon Sep 17 00:00:00 2001 From: feder-cr <85809106+feder-cr@users.noreply.github.com> Date: Thu, 29 Aug 2024 19:01:42 +0200 Subject: [PATCH 03/97] now we use pydantic for yaml validator --- requirements.txt | Bin 680 -> 674 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/requirements.txt b/requirements.txt index ac9bc87c6e40b017241fe7176e70bd89834eb044..aef4baed9ae147969488a70f26fd5aae9edacb36 100644 GIT binary patch delta 12 TcmZ3%x`=hdJSG-i1}+8w7~TTF delta 18 ZcmZ3)x`K7XJSHv&hB5|Y23`g(1^_T@1Frx8 From 761bb91e96c6fd35c64151743e510db0c85d7855 Mon Sep 17 00:00:00 2001 From: 1 Date: Sat, 31 Aug 2024 12:41:05 +0300 Subject: [PATCH 04/97] Readme upd to ease troubleshooting and fixing --- README.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 3258335..15fe7b8 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,7 @@ LinkedIn_AIHawk steps in as a game-changing solution to these challenges. It's n ## Installation **Please watch this video to set up your LinkedIn_AIHawk: [How to set up LinkedIn_AIHawk](https://youtu.be/gdW9wogHEUM) - https://youtu.be/gdW9wogHEUM** - +0. **Confirmed succesfull runs OSs & Python**: Python 3.10, 3.11.9(64b), 3.12.5(64b) . Windows 10, Ubuntu 22 1. **Download and Install Python:** Ensure you have the last Python version installed. If not, download and install it from Python's official website. For detailed instructions, refer to the tutorials: @@ -507,11 +507,16 @@ TODO ): ## Troubleshooting +- **Carefully read logs and output :** Most of the errors are verbosely reflected just watch the output and try to find the root couse. +- **If nothing works by unknown reason:** Use tested OS. Reboot and/or update OS. Use new clean venv. Try update Python to the tested version. - **ChromeDriver Issues:** Ensure ChromeDriver is compatible with your installed Chrome version. - **Missing Files:** Verify that all necessary files are present in the data folder. -- **Invalid YAML:** Check your YAML files for syntax errors. - - If you encounter any issues, you can open an issue on [GitHub](https://github.com/feder-cr/linkedIn_auto_jobs_applier_with_AI/issues). I'll be more than happy to assist you! +- **Invalid YAML:** Check your YAML files for syntax errors . Try to use external YAML validators e.g. https://www.yamllint.com/ +- **OpenAI endpoint isues**: Try to check possible limits\blocking at their side + +If you encounter any issues, you can open an issue on [GitHub](https://github.com/feder-cr/linkedIn_auto_jobs_applier_with_AI/issues). + Please add valuable details to the subject and to the description. If you need new feature then please reflect this. + I'll be more than happy to assist you! ## Conclusion From 9fc3274b9afe23dad1294612db8226b236e8bf26 Mon Sep 17 00:00:00 2001 From: Manu Altieri Date: Sat, 31 Aug 2024 19:31:31 +0200 Subject: [PATCH 05/97] Adding linkedin-api.py to search jobs without use selenium. --- src/linkedin-api.py | 168 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 src/linkedin-api.py diff --git a/src/linkedin-api.py b/src/linkedin-api.py new file mode 100644 index 0000000..f1395f8 --- /dev/null +++ b/src/linkedin-api.py @@ -0,0 +1,168 @@ +from typing import Dict, List +from linkedin_api import Linkedin +from typing import Optional, Union, Literal +from urllib.parse import urlencode + +class LinkedInEvolvedAPI(Linkedin): + 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"], + ] + ] + ] = 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=24 * 60 * 60, + distance: Optional[int] = None, + easy_apply: Optional[bool] = True, + limit=-1, + offset=0, + **kwargs, + ) -> List[Dict]: + """Perform a LinkedIn search for jobs. + + :param keywords: Search keywords (str) + :type keywords: str, optional + :param companies: A list of company URN IDs (str) + :type companies: list, optional + :param experience: A list of experience levels, one or many of "1", "2", "3", "4", "5" and "6" (internship, entry level, associate, mid-senior level, director and executive, respectively) + :type experience: list, optional + :param job_type: A list of job types , one or many of "F", "C", "P", "T", "I", "V", "O" (full-time, contract, part-time, temporary, internship, volunteer and "other", respectively) + :type job_type: list, optional + :param job_title: A list of title URN IDs (str) + :type job_title: list, optional + :param industries: A list of industry URN IDs (str) + :type industries: list, optional + :param location_name: Name of the location to search within. Example: "Kyiv City, Ukraine" + :type location_name: str, optional + :param remote: Filter for remote jobs, onsite or hybrid. onsite:"1", remote:"2", hybrid:"3" + :type remote: list, optional + :param listed_at: maximum number of seconds passed since job posting. 86400 will filter job postings posted in last 24 hours. + :type listed_at: int/str, optional. Default value is equal to 24 hours. + :param distance: maximum distance from location in miles + :type distance: int/str, optional. If not specified, None or 0, the default value of 25 miles applied. + :param easy_apply: filter for jobs that are easy to apply to + :type easy_apply: bool, optional. Default value is True. + :param limit: maximum number of results obtained from API queries. -1 means maximum which is defined by constants and is equal to 1000 now. + :type limit: int, optional, default -1 + :param offset: indicates how many search results shall be skipped + :type offset: int, optional + :return: List of jobs + :rtype: list + """ + count = Linkedin._MAX_SEARCH_COUNT + if limit is None: + limit = -1 + + query: Dict[str, Union[str, Dict[str, str]]] = { + "origin": "JOB_SEARCH_PAGE_QUERY_EXPANSION" + } + if keywords: + query["keywords"] = "KEYWORD_PLACEHOLDER" + if location_name: + query["locationFallback"] = "LOCATION_PLACEHOLDER" + + query["selectedFilters"] = {} + if companies: + query["selectedFilters"]["company"] = f"List({','.join(companies)})" + if experience: + query["selectedFilters"]["experience"] = f"List({','.join(experience)})" + if job_type: + query["selectedFilters"]["jobType"] = f"List({','.join(job_type)})" + if job_title: + query["selectedFilters"]["title"] = f"List({','.join(job_title)})" + if industries: + query["selectedFilters"]["industry"] = f"List({','.join(industries)})" + if distance: + query["selectedFilters"]["distance"] = f"List({distance})" + if remote: + query["selectedFilters"]["workplaceType"] = f"List({','.join(remote)})" + if easy_apply: + query["selectedFilters"]["easyApply"] = "List(true)" + + query["selectedFilters"]["timePostedRange"] = f"List(r{listed_at})" + query["spellCorrectionEnabled"] = "true" + + query_string = ( + str(query) + .replace(" ", "") + .replace("'", "") + .replace("KEYWORD_PLACEHOLDER", keywords or "") + .replace("LOCATION_PLACEHOLDER", location_name or "") + .replace("{", "(") + .replace("}", ")") + ) + results = [] + while True: + if limit > -1 and limit - len(results) < count: + count = limit - len(results) + default_params = { + "decorationId": "com.linkedin.voyager.dash.deco.jobs.search.JobSearchCardsCollection-174", + "count": count, + "q": "jobSearch", + "query": query_string, + "start": len(results) + offset, + } + + res = self._fetch( + f"/voyagerJobsDashJobCards?{urlencode(default_params, safe='(),:')}", + headers={"accept": "application/vnd.linkedin.normalized+json+2.1"}, + ) + data = res.json() + + elements = data.get("included", []) + new_data = [] + for e in elements: + trackingUrn = e.get("trackingUrn") + if trackingUrn: + trackingUrn = trackingUrn.split(":")[-1] + 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 + ) or len(elements) == 0: + break + + self.logger.debug(f"results grew to {len(results)}") + + return results + + + + + + \ No newline at end of file From bac05ad04cbae9bfe9429aa8b2b0e403cc18b08d Mon Sep 17 00:00:00 2001 From: Manu Altieri Date: Sat, 31 Aug 2024 19:39:44 +0200 Subject: [PATCH 06/97] Adding linkedin-api.py to search jobs without use selenium (requirements) --- requirements.txt | Bin 674 -> 698 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/requirements.txt b/requirements.txt index aef4baed9ae147969488a70f26fd5aae9edacb36..341ed8e0d589527317d1b1884894f4ad7e2a7733 100644 GIT binary patch delta 32 jcmZ3)x{GzgA|{C(hD?S$hHQpZh7>SMmm!g%0LTIWkiG|q delta 7 OcmdnRx`=hdA|?O}&jPyu From d964f599bed339bee2404898516fd20ba581df6c Mon Sep 17 00:00:00 2001 From: user Date: Sat, 31 Aug 2024 20:19:33 +0200 Subject: [PATCH 07/97] Added support for Ollama running locally or publicly hosted api --- README.md | 8 +++++++- data_folder/secrets.yaml | 3 ++- data_folder_example/secrets.yaml | 3 ++- main.py | 12 ++++++------ src/gpt.py | 25 +++++++++++++++++-------- 5 files changed, 34 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 15fe7b8..de60c0e 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,13 @@ This file contains sensitive information. Never share or commit this file to ver - 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). - +- `openai_api_free_hosted_url`: + - Optional paramter, if you want to use freely hosted GPT model, set `openai_api_key: "freehosted"` and `openai_api_free_hosted_url` with the URL of the endpoint +- Ollama local support + - If you want to use Ollama which is deployed locally, leave `openai_api_key` blank. + - To setup Ollama to run locally follow the instructions here: [Ollama installation](https://github.com/ollama/ollama). + - Download mistral model by pulling mistral:v0.3 + ### 2. config.yaml diff --git a/data_folder/secrets.yaml b/data_folder/secrets.yaml index ad24cd8..9d0bfb3 100644 --- a/data_folder/secrets.yaml +++ b/data_folder/secrets.yaml @@ -1,3 +1,4 @@ email: myemaillinkedin@gmail.com password: ImpossiblePassowrd10 -openai_api_key: sk-11KRr4uuTwpRGfeRTfj1T9BlbkFJjP8QTrswHU1yGruru2FR \ No newline at end of file +openai_api_key: sk-11KRr4uuTwpRGfeRTfj1T9BlbkFJjP8QTrswHU1yGruru2FR +openai_api_free_hosted_url: https://api.pawan.krd/cosmosrp/v1 \ No newline at end of file diff --git a/data_folder_example/secrets.yaml b/data_folder_example/secrets.yaml index ad24cd8..9d0bfb3 100644 --- a/data_folder_example/secrets.yaml +++ b/data_folder_example/secrets.yaml @@ -1,3 +1,4 @@ email: myemaillinkedin@gmail.com password: ImpossiblePassowrd10 -openai_api_key: sk-11KRr4uuTwpRGfeRTfj1T9BlbkFJjP8QTrswHU1yGruru2FR \ No newline at end of file +openai_api_key: sk-11KRr4uuTwpRGfeRTfj1T9BlbkFJjP8QTrswHU1yGruru2FR +openai_api_free_hosted_url: https://api.pawan.krd/cosmosrp/v1 \ No newline at end of file diff --git a/main.py b/main.py index 9685677..82ed33f 100644 --- a/main.py +++ b/main.py @@ -101,7 +101,7 @@ class ConfigValidator: @staticmethod def validate_secrets(secrets_yaml_path: Path) -> tuple: secrets = ConfigValidator.validate_yaml_file(secrets_yaml_path) - mandatory_secrets = ['email', 'password', 'openai_api_key'] + mandatory_secrets = ['email', 'password'] for secret in mandatory_secrets: if secret not in secrets: @@ -114,7 +114,7 @@ class ConfigValidator: if not secrets['openai_api_key']: raise ConfigError(f"OpenAI API key cannot be empty in secrets file {secrets_yaml_path}.") - return secrets['email'], str(secrets['password']), secrets['openai_api_key'] + return secrets['email'], str(secrets['password']), secrets['openai_api_key'], secrets['openai_api_free_hosted_url'] class FileManager: @staticmethod @@ -158,7 +158,7 @@ def init_browser() -> webdriver.Chrome: except Exception as e: raise RuntimeError(f"Failed to initialize browser: {str(e)}") -def create_and_run_bot(email: str, password: str, parameters: dict, openai_api_key: str): +def create_and_run_bot(email, password, parameters, openai_api_key, openai_api_free_hosted_url): try: style_manager = StyleManager() resume_generator = ResumeGenerator() @@ -175,7 +175,7 @@ def create_and_run_bot(email: str, password: str, parameters: dict, openai_api_k browser = init_browser() login_component = LinkedInAuthenticator(browser) apply_component = LinkedInJobManager(browser) - gpt_answerer_component = GPTAnswerer(openai_api_key) + gpt_answerer_component = GPTAnswerer(openai_api_key, openai_api_free_hosted_url) bot = LinkedInBotFacade(login_component, apply_component) bot.set_secrets(email, password) bot.set_job_application_profile_and_resume(job_application_profile_object, resume_object) @@ -197,12 +197,12 @@ def main(resume: Path = None): secrets_file, config_file, plain_text_resume_file, output_folder = FileManager.validate_data_folder(data_folder) parameters = ConfigValidator.validate_config(config_file) - email, password, openai_api_key = ConfigValidator.validate_secrets(secrets_file) + email, password, openai_api_key, openai_api_free_hosted_url = 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, openai_api_key) + create_and_run_bot(email, password, parameters, openai_api_key, openai_api_free_hosted_url) 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") diff --git a/src/gpt.py b/src/gpt.py index 371c0c2..684a4de 100644 --- a/src/gpt.py +++ b/src/gpt.py @@ -3,7 +3,7 @@ import os import re import textwrap from datetime import datetime -from typing import Dict, List +from typing import Dict, List, Union from pathlib import Path from dotenv import load_dotenv from langchain_core.messages.ai import AIMessage @@ -11,6 +11,7 @@ from langchain_core.output_parsers import StrOutputParser from langchain_core.prompt_values import StringPromptValue from langchain_core.prompts import ChatPromptTemplate from langchain_openai import ChatOpenAI +from langchain_ollama import ChatOllama from Levenshtein import distance import src.strings as strings @@ -20,7 +21,7 @@ load_dotenv() class LLMLogger: - def __init__(self, llm: ChatOpenAI): + def __init__(self, llm: Union[ChatOpenAI, ChatOllama]): self.llm = llm @staticmethod @@ -78,12 +79,12 @@ class LLMLogger: class LoggerChatModel: - def __init__(self, llm: ChatOpenAI): + def __init__(self, llm: Union[ChatOpenAI, ChatOllama]): self.llm = llm def __call__(self, messages: List[Dict[str, str]]) -> str: # Call the LLM with the provided messages and log the response. - reply = self.llm(messages) + reply = self.llm.invoke(messages) parsed_reply = self.parse_llmresult(reply) LLMLogger.log_request(prompts=messages, parsed_reply=parsed_reply) return reply @@ -113,10 +114,18 @@ class LoggerChatModel: class GPTAnswerer: - def __init__(self, openai_api_key): - self.llm_cheap = LoggerChatModel( - ChatOpenAI(model_name="gpt-4o-mini", openai_api_key=openai_api_key, temperature=0.4) - ) + def __init__(self, openai_api_key, openai_api_free_hosted_url): + if openai_api_key == "": + print('Using locally hosted mistral:v0.3') + self.llm_model = ChatOllama(model = "mistral:v0.3", temperature = 0.4, num_predict = 256) + elif openai_api_key == "freehosted": + print('Using free hosted gpt-4o-mini') + self.llm_model = ChatOpenAI(model_name="gpt-4o-mini", openai_api_key="anything", temperature=0.4, + base_url=openai_api_free_hosted_url) + else: + print("Using gpt-4o-mini") + self.llm_model = ChatOpenAI(model_name="gpt-4o-mini", openai_api_key=openai_api_key, temperature=0.4) + self.llm_cheap = LoggerChatModel(self.llm_model) @property def job_description(self): return self.job.description From 966e610fee0d6536e31387d32b459e15f36e5808 Mon Sep 17 00:00:00 2001 From: queukat Date: Sat, 31 Aug 2024 23:43:39 +0300 Subject: [PATCH 08/97] add logs and some bugs fixes --- src/gpt.py | 147 ++++++++++++++++++++++++--- src/job.py | 11 +- src/job_application_profile.py | 55 ++++++++-- src/linkedIn_authenticator.py | 81 +++++++++++++-- src/linkedIn_bot_facade.py | 27 +++++ src/linkedIn_easy_applier.py | 177 +++++++++++++++++++++++---------- src/linkedIn_job_manager.py | 105 ++++++++++++------- src/utils.py | 80 +++++++++------ 8 files changed, 530 insertions(+), 153 deletions(-) diff --git a/src/gpt.py b/src/gpt.py index 371c0c2..63bbdf2 100644 --- a/src/gpt.py +++ b/src/gpt.py @@ -12,20 +12,65 @@ from langchain_core.prompt_values import StringPromptValue from langchain_core.prompts import ChatPromptTemplate from langchain_openai import ChatOpenAI from Levenshtein import distance +import time +from functools import wraps +from openai import RateLimitError, OpenAIError, APIError + import src.strings as strings +from src.utils import logger load_dotenv() +# Global timestamp for rate limiting +last_call_time = 0 + + +def global_rate_limiter(min_interval): + def decorator(func): + @wraps(func) + def wrapper(*args, **kwargs): + global last_call_time + elapsed = time.time() - last_call_time + if elapsed < min_interval: + logger.debug("Rate limit hit, sleeping for %s seconds", min_interval - elapsed) + time.sleep(min_interval - elapsed) + last_call_time = time.time() + return func(*args, **kwargs) + + return wrapper + + return decorator + +def parse_wait_time_from_error_message(error_message: str) -> int: + logger.debug("Parsing wait time from error message: %s", error_message) + match = re.search(r"Please try again in (\d+)([smhd])", error_message) + if match: + value, unit = int(match.group(1)), match.group(2) + logger.debug("Extracted wait time: %d %s", value, unit) + if unit == 's': + return value + elif unit == 'm': + return value * 60 + elif unit == 'h': + return value * 3600 + elif unit == 'd': + return value * 86400 + logger.debug("Default wait time applied: 30 seconds") + return 30 # По умолчанию ждать 30 секунд, если не удалось разобрать время + class LLMLogger: def __init__(self, llm: ChatOpenAI): self.llm = llm + logger.debug("LLMLogger initialized with LLM: %s", llm) @staticmethod def log_request(prompts, parsed_reply: Dict[str, Dict]): + logger.debug("Logging request with prompts: %s", prompts) calls_log = os.path.join(Path("data_folder/output"), "open_ai_calls.json") + if isinstance(prompts, StringPromptValue): prompts = prompts.text elif isinstance(prompts, Dict): @@ -41,6 +86,7 @@ class LLMLogger: } current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + logger.debug("Current time: %s", current_time) # Extract token usage details from the response token_usage = parsed_reply["usage_metadata"] @@ -48,7 +94,8 @@ class LLMLogger: input_tokens = token_usage["input_tokens"] total_tokens = token_usage["total_tokens"] - # Extract model details from the response + logger.debug("Token usage - Input: %d, Output: %d, Total: %d", input_tokens, output_tokens, total_tokens) + model_name = parsed_reply["response_metadata"]["model_name"] prompt_price_per_token = 0.00000015 completion_price_per_token = 0.0000006 @@ -58,7 +105,8 @@ class LLMLogger: output_tokens * completion_price_per_token ) - # Create a log entry with all relevant information + logger.debug("Total cost calculated: %f", total_cost) + log_entry = { "model": model_name, "time": current_time, @@ -70,26 +118,41 @@ class LLMLogger: "total_cost": total_cost, } - # Write the log entry to the log file in JSON format + logger.debug("Log entry created: %s", log_entry) + with open(calls_log, "a", encoding="utf-8") as f: 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) class LoggerChatModel: def __init__(self, llm: ChatOpenAI): self.llm = llm + logger.debug("LoggerChatModel initialized with LLM: %s", llm) def __call__(self, messages: List[Dict[str, str]]) -> str: - # Call the LLM with the provided messages and log the response. - reply = self.llm(messages) - parsed_reply = self.parse_llmresult(reply) - LLMLogger.log_request(prompts=messages, parsed_reply=parsed_reply) - return reply + logger.debug("Calling LoggerChatModel with messages: %s", messages) + while True: + try: + # Попытка вызвать модель + reply = self.llm(messages) + logger.debug("Model reply received: %s", reply) + parsed_reply = self.parse_llmresult(reply) + LLMLogger.log_request(prompts=messages, parsed_reply=parsed_reply) + return reply + except RateLimitError as err: + # Handle RateLimitError + wait_time = self.parse_wait_time_from_error_message(str(err)) + logger.warning("Rate limit exceeded. Waiting for %d seconds before retrying...", wait_time) + time.sleep(wait_time) + except Exception as e: + logger.error("Unexpected error occurred: %s", str(e)) + raise def parse_llmresult(self, llmresult: AIMessage) -> Dict[str, Dict]: - # Parse the LLM result into a structured format. + logger.debug("Parsing LLM result: %s", llmresult) content = llmresult.content response_metadata = llmresult.response_metadata id_ = llmresult.id @@ -109,61 +172,93 @@ class LoggerChatModel: "total_tokens": usage_metadata.get("total_tokens", 0), }, } + logger.debug("Parsed LLM result: %s", parsed_result) return parsed_result + def parse_wait_time_from_error_message(self, error_message: str) -> int: + logger.debug("Parsing wait time from error message: %s", error_message) + match = re.search(r"Please try again in (\d+)([smhd])", error_message) + if match: + value, unit = match.groups() + value = int(value) + logger.debug("Extracted wait time: %d %s", value, unit) + if unit == "s": + return value + elif unit == "m": + return value * 60 + elif unit == "h": + return value * 3600 + elif unit == "d": + return value * 86400 + logger.debug("Default wait time applied: 30 seconds") + return 30 + class GPTAnswerer: def __init__(self, openai_api_key): self.llm_cheap = LoggerChatModel( ChatOpenAI(model_name="gpt-4o-mini", openai_api_key=openai_api_key, temperature=0.4) ) + logger.debug("GPTAnswerer initialized with API key") + @property def job_description(self): return self.job.description @staticmethod def find_best_match(text: str, options: list[str]) -> str: + logger.debug("Finding best match for text: '%s' in options: %s", text, options) distances = [ (option, distance(text.lower(), option.lower())) for option in options ] best_option = min(distances, key=lambda x: x[1])[0] + logger.debug("Best match found: %s", best_option) return best_option @staticmethod def _remove_placeholders(text: str) -> str: + logger.debug("Removing placeholders from text: %s", text) text = text.replace("PLACEHOLDER", "") return text.strip() @staticmethod def _preprocess_template_string(template: str) -> str: - # Preprocess a template string to remove unnecessary indentation. + logger.debug("Preprocessing template string") return textwrap.dedent(template) def set_resume(self, resume): + logger.debug("Setting resume: %s", resume) self.resume = resume 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)) def set_job_application_profile(self, job_application_profile): + logger.debug("Setting job application profile: %s", job_application_profile) self.job_application_profile = job_application_profile - + + @global_rate_limiter(25) def summarize_job_description(self, text: str) -> str: + logger.debug("Summarizing job description: %s", text) strings.summarize_prompt_template = self._preprocess_template_string( 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) return output def _create_chain(self, template: str): + logger.debug("Creating chain with template: %s", template) prompt = ChatPromptTemplate.from_template(template) return prompt | self.llm_cheap | StrOutputParser() - + + @global_rate_limiter(25) def answer_question_textual_wide_range(self, question: str) -> str: - # Define chains for each section of the resume + logger.debug("Answering textual question: %s", question) chains = { "personal_information": self._create_chain(strings.personal_information_template), "self_identification": self._create_chain(strings.self_identification_template), @@ -270,47 +365,66 @@ class GPTAnswerer: prompt = ChatPromptTemplate.from_template(section_prompt) chain = prompt | self.llm_cheap | StrOutputParser() output = chain.invoke({"question": question}) + logger.debug("Section determined from question: %s", output) section_name = output.lower().replace(" ", "_") if section_name == "cover_letter": chain = chains.get(section_name) output = chain.invoke({"resume": self.resume, "job_description": self.job_description}) + logger.debug("Cover letter generated: %s", output) return output resume_section = getattr(self.resume, section_name, None) or getattr(self.job_application_profile, section_name, None) 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.") 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}'") - return 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 + @global_rate_limiter(25) 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) prompt = ChatPromptTemplate.from_template(func_template) chain = prompt | self.llm_cheap | StrOutputParser() output_str = chain.invoke({"resume_educations": self.resume.education_details,"resume_jobs": self.resume.experience_details,"resume_projects": self.resume.projects , "question": question}) + logger.debug("Raw output for numeric question: %s", output_str) try: 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) output = default_experience return output def extract_number_from_string(self, output_str): + logger.debug("Extracting number from string: %s", output_str) numbers = re.findall(r"\d+", output_str) if numbers: + logger.debug("Numbers found: %s", numbers) return int(numbers[0]) else: + logger.error("No numbers found in the string") raise ValueError("No numbers found in the string") + @global_rate_limiter(25) 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) prompt = ChatPromptTemplate.from_template(func_template) chain = prompt | self.llm_cheap | StrOutputParser() 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 - + + @global_rate_limiter(25) def resume_or_cover(self, phrase: str) -> str: - # Define the prompt template + logger.debug("Determining if phrase refers to resume or cover letter: %s", phrase) prompt_template = """ Given the following phrase, respond with only 'resume' if the phrase is about a resume, or 'cover' if it's about a cover letter. Do not provide any additional information or explanations. @@ -319,6 +433,7 @@ class GPTAnswerer: prompt = ChatPromptTemplate.from_template(prompt_template) chain = prompt | self.llm_cheap | StrOutputParser() response = chain.invoke({"phrase": phrase}) + logger.debug("Response for resume_or_cover: %s", response) if "resume" in response: return "resume" elif "cover" in response: diff --git a/src/job.py b/src/job.py index 31fef22..39b2371 100644 --- a/src/job.py +++ b/src/job.py @@ -1,5 +1,8 @@ from dataclasses import dataclass +from src.utils import logger + + @dataclass class Job: title: str @@ -13,18 +16,22 @@ class Job: recruiter_link: str = "" def set_summarize_job_description(self, summarize_job_description): + logger.debug("Setting summarized job description: %s", summarize_job_description) self.summarize_job_description = summarize_job_description def set_job_description(self, description): + logger.debug("Setting job description: %s", description) self.description = description def set_recruiter_link(self, recruiter_link): + logger.debug("Setting recruiter link: %s", recruiter_link) self.recruiter_link = recruiter_link def formatted_job_information(self): """ Formats the job information as a markdown string. """ + logger.debug("Formatting job information for job: %s at %s", self.title, self.company) job_information = f""" # Job Description ## Job Information @@ -36,4 +43,6 @@ class Job: ## Description {self.description or 'No description provided.'} """ - return job_information.strip() + formatted_information = job_information.strip() + logger.debug("Formatted job information: %s", formatted_information) + return formatted_information diff --git a/src/job_application_profile.py b/src/job_application_profile.py index 89bbdb2..43c4db1 100644 --- a/src/job_application_profile.py +++ b/src/job_application_profile.py @@ -2,6 +2,9 @@ from dataclasses import dataclass from typing import Dict, List import yaml +from src.utils import logger + + @dataclass class SelfIdentification: gender: str @@ -47,86 +50,122 @@ class JobApplicationProfile: salary_expectations: SalaryExpectations def __init__(self, yaml_str: str): + logger.debug("Initializing JobApplicationProfile with provided YAML string") try: data = yaml.safe_load(yaml_str) + logger.debug("YAML data successfully parsed: %s", data) except yaml.YAMLError as e: + logger.error("Error parsing YAML file: %s", e) raise ValueError("Error parsing YAML file.") from e except Exception as e: + logger.error("Unexpected error occurred while parsing the YAML file: %s", e) raise RuntimeError("An unexpected error occurred while parsing the YAML file.") from e if not isinstance(data, dict): + logger.error("YAML data must be a dictionary, received: %s", type(data)) raise TypeError("YAML data must be a dictionary.") # Process self_identification try: + logger.debug("Processing self_identification") self.self_identification = SelfIdentification(**data['self_identification']) + logger.debug("self_identification processed: %s", self.self_identification) except KeyError as e: + logger.error("Required field %s is missing in self_identification data.", e) raise KeyError(f"Required field {e} is missing in self_identification data.") from e except TypeError as e: + logger.error("Error in self_identification data: %s", e) raise TypeError(f"Error in self_identification data: {e}") from e except AttributeError as e: + logger.error("Attribute error in self_identification processing: %s", e) raise AttributeError("Attribute error in self_identification processing.") from e except Exception as e: + logger.error("An unexpected error occurred while processing self_identification: %s", e) raise RuntimeError("An unexpected error occurred while processing self_identification.") from e # Process legal_authorization try: + logger.debug("Processing legal_authorization") self.legal_authorization = LegalAuthorization(**data['legal_authorization']) + logger.debug("legal_authorization processed: %s", self.legal_authorization) except KeyError as e: + logger.error("Required field %s is missing in legal_authorization data.", e) raise KeyError(f"Required field {e} is missing in legal_authorization data.") from e except TypeError as e: + logger.error("Error in legal_authorization data: %s", e) raise TypeError(f"Error in legal_authorization data: {e}") from e except AttributeError as e: + logger.error("Attribute error in legal_authorization processing: %s", e) raise AttributeError("Attribute error in legal_authorization processing.") from e except Exception as e: + logger.error("An unexpected error occurred while processing legal_authorization: %s", e) raise RuntimeError("An unexpected error occurred while processing legal_authorization.") from e # Process work_preferences try: + logger.debug("Processing work_preferences") self.work_preferences = WorkPreferences(**data['work_preferences']) + logger.debug("work_preferences processed: %s", self.work_preferences) except KeyError as e: + logger.error("Required field %s is missing in work_preferences data.", e) raise KeyError(f"Required field {e} is missing in work_preferences data.") from e except TypeError as e: + logger.error("Error in work_preferences data: %s", e) raise TypeError(f"Error in work_preferences data: {e}") from e except AttributeError as e: + logger.error("Attribute error in work_preferences processing: %s", e) raise AttributeError("Attribute error in work_preferences processing.") from e except Exception as e: + logger.error("An unexpected error occurred while processing work_preferences: %s", e) raise RuntimeError("An unexpected error occurred while processing work_preferences.") from e # Process availability try: + logger.debug("Processing availability") self.availability = Availability(**data['availability']) + logger.debug("availability processed: %s", self.availability) except KeyError as e: + logger.error("Required field %s is missing in availability data.", e) raise KeyError(f"Required field {e} is missing in availability data.") from e except TypeError as e: + logger.error("Error in availability data: %s", e) raise TypeError(f"Error in availability data: {e}") from e except AttributeError as e: + logger.error("Attribute error in availability processing: %s", e) raise AttributeError("Attribute error in availability processing.") from e except Exception as e: + logger.error("An unexpected error occurred while processing availability: %s", e) raise RuntimeError("An unexpected error occurred while processing availability.") from e # Process salary_expectations try: + logger.debug("Processing salary_expectations") self.salary_expectations = SalaryExpectations(**data['salary_expectations']) + logger.debug("salary_expectations processed: %s", self.salary_expectations) except KeyError as e: + logger.error("Required field %s is missing in salary_expectations data.", e) raise KeyError(f"Required field {e} is missing in salary_expectations data.") from e except TypeError as e: + logger.error("Error in salary_expectations data: %s", e) raise TypeError(f"Error in salary_expectations data: {e}") from e except AttributeError as e: + logger.error("Attribute error in salary_expectations processing: %s", e) raise AttributeError("Attribute error in salary_expectations processing.") from e except Exception as e: + logger.error("An unexpected error occurred while processing salary_expectations: %s", e) raise RuntimeError("An unexpected error occurred while processing salary_expectations.") from e - # Process additional fields - - + logger.debug("JobApplicationProfile initialization completed successfully.") def __str__(self): + logger.debug("Generating string representation of JobApplicationProfile") def format_dataclass(obj): return "\n".join(f"{field.name}: {getattr(obj, field.name)}" for field in obj.__dataclass_fields__.values()) - return (f"Self Identification:\n{format_dataclass(self.self_identification)}\n\n" - f"Legal Authorization:\n{format_dataclass(self.legal_authorization)}\n\n" - f"Work Preferences:\n{format_dataclass(self.work_preferences)}\n\n" - f"Availability: {self.availability.notice_period}\n\n" - f"Salary Expectations: {self.salary_expectations.salary_range_usd}\n\n") + formatted_str = (f"Self Identification:\n{format_dataclass(self.self_identification)}\n\n" + f"Legal Authorization:\n{format_dataclass(self.legal_authorization)}\n\n" + f"Work Preferences:\n{format_dataclass(self.work_preferences)}\n\n" + f"Availability: {self.availability.notice_period}\n\n" + f"Salary Expectations: {self.salary_expectations.salary_range_usd}\n\n") + logger.debug("String representation generated: %s", formatted_str) + return formatted_str diff --git a/src/linkedIn_authenticator.py b/src/linkedIn_authenticator.py index 0153504..513fb38 100644 --- a/src/linkedIn_authenticator.py +++ b/src/linkedIn_authenticator.py @@ -1,77 +1,142 @@ +import random import time from selenium.common.exceptions import NoSuchElementException, TimeoutException from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC +from src.utils import logger + + class LinkedInAuthenticator: def __init__(self, driver=None): self.driver = driver self.email = "" self.password = "" + logger.debug("LinkedInAuthenticator initialized with driver: %s", driver) def set_secrets(self, email, password): self.email = email self.password = password + logger.debug("Secrets set with email: %s", email) def start(self): - print("Starting Chrome browser to log in to LinkedIn.") - self.driver.get('https://www.linkedin.com') + logger.info("Starting Chrome browser to log in to LinkedIn.") + self.driver.get('https://www.linkedin.com/feed') self.wait_for_page_load() if not self.is_logged_in(): self.handle_login() def handle_login(self): - print("Navigating to the LinkedIn login page...") + logger.info("Navigating to the LinkedIn login page...") self.driver.get("https://www.linkedin.com/login") try: self.enter_credentials() self.submit_login_form() - except NoSuchElementException: - print("Could not log in to LinkedIn. Please check your credentials.") - time.sleep(35) #TODO fix better + except NoSuchElementException as e: + logger.error("Could not log in to LinkedIn. Element not found: %s", e) + time.sleep(random.uniform(3, 5)) self.handle_security_check() def enter_credentials(self): try: + logger.debug("Entering credentials...") email_field = WebDriverWait(self.driver, 10).until( EC.presence_of_element_located((By.ID, "username")) ) email_field.send_keys(self.email) + logger.debug("Email entered: %s", self.email) password_field = self.driver.find_element(By.ID, "password") password_field.send_keys(self.password) + logger.debug("Password entered.") except TimeoutException: + logger.error("Login form not found. Aborting login.") print("Login form not found. Aborting login.") def submit_login_form(self): try: + logger.debug("Submitting login form...") login_button = self.driver.find_element(By.XPATH, '//button[@type="submit"]') login_button.click() + logger.debug("Login form submitted.") except NoSuchElementException: + logger.error("Login button not found. Please verify the page structure.") print("Login button not found. Please verify the page structure.") def handle_security_check(self): try: + logger.debug("Handling security check...") WebDriverWait(self.driver, 10).until( EC.url_contains('https://www.linkedin.com/checkpoint/challengesV2/') ) + logger.warning("Security checkpoint detected. Please complete the challenge.") print("Security checkpoint detected. Please complete the challenge.") WebDriverWait(self.driver, 300).until( EC.url_contains('https://www.linkedin.com/feed/') ) + logger.info("Security check completed") print("Security check completed") except TimeoutException: + logger.error("Security check not completed within the timeout.") print("Security check not completed. Please try again later.") def is_logged_in(self): - self.driver.get('https://www.linkedin.com/') - return self.driver.current_url == 'https://www.linkedin.com/feed/' + target_url = 'https://www.linkedin.com/feed' + + # Navigate to the target URL if not already there + if self.driver.current_url != target_url: + logger.debug("Navigating to target URL: %s", target_url) + self.driver.get(target_url) + + try: + # Increase the wait time for the page elements to load + logger.debug("Checking if user is logged in...") + WebDriverWait(self.driver, 10).until( + EC.presence_of_element_located((By.CLASS_NAME, 'share-box-feed-entry__trigger')) + ) + + # Check for the presence of the "Start a post" button + buttons = self.driver.find_elements(By.CLASS_NAME, 'share-box-feed-entry__trigger') + if any(button.text.strip() == 'Start a post' for button in buttons): + logger.info("User is already logged in.") + + try: + # Wait for the profile picture and name to load + profile_img = WebDriverWait(self.driver, 10).until( + EC.presence_of_element_located((By.XPATH, "//img[contains(@alt, 'Photo of')]")) + ) + profile_name = WebDriverWait(self.driver, 10).until( + EC.presence_of_element_located((By.XPATH, "//div[@class='t-16 t-black t-bold']")) + ) + + if profile_img and profile_name: + logger.info("Profile picture found for user: %s", profile_name.text) + return True + except NoSuchElementException: + logger.warning("Profile picture or name not found.") + print("Profile picture or name not found.") + return False + except TimeoutException: + logger.warning("Profile picture or name took too long to load.") + print("Profile picture or name took too long to load.") + return False + + except TimeoutException: + logger.error("Page elements took too long to load or were not found.") + print("Page elements took too long to load or were not found.") + return False + + return False + def wait_for_page_load(self, timeout=10): try: + logger.debug("Waiting for page to load with timeout: %s seconds", timeout) WebDriverWait(self.driver, timeout).until( lambda d: d.execute_script('return document.readyState') == 'complete' ) + logger.debug("Page load completed.") except TimeoutException: + logger.error("Page load timed out.") print("Page load timed out.") diff --git a/src/linkedIn_bot_facade.py b/src/linkedIn_bot_facade.py index 33dc06a..f87b9da 100644 --- a/src/linkedIn_bot_facade.py +++ b/src/linkedIn_bot_facade.py @@ -1,8 +1,13 @@ +from src.utils import logger + + class LinkedInBotState: def __init__(self): + logger.debug("Initializing LinkedInBotState") self.reset() def reset(self): + logger.debug("Resetting LinkedInBotState") self.credentials_set = False self.api_key_set = False self.job_application_profile_set = False @@ -11,12 +16,16 @@ class LinkedInBotState: self.logged_in = False def validate_state(self, required_keys): + logger.debug("Validating LinkedInBotState with required keys: %s", required_keys) for key in required_keys: if not getattr(self, key): + logger.error("State validation failed: %s is not set", key) raise ValueError(f"{key.replace('_', ' ').capitalize()} must be set before proceeding.") + logger.debug("State validation passed") class LinkedInBotFacade: def __init__(self, login_component, apply_component): + logger.debug("Initializing LinkedInBotFacade") self.login_component = login_component self.apply_component = apply_component self.state = LinkedInBotState() @@ -27,47 +36,65 @@ class LinkedInBotFacade: self.parameters = None def set_job_application_profile_and_resume(self, job_application_profile, resume): + logger.debug("Setting job application profile and resume") self._validate_non_empty(job_application_profile, "Job application profile") self._validate_non_empty(resume, "Resume") self.job_application_profile = job_application_profile self.resume = resume self.state.job_application_profile_set = True + logger.debug("Job application profile and resume set successfully") def set_secrets(self, email, password): + logger.debug("Setting secrets: email and password") self._validate_non_empty(email, "Email") self._validate_non_empty(password, "Password") self.email = email self.password = password self.state.credentials_set = True + logger.debug("Secrets set successfully") def set_gpt_answerer_and_resume_generator(self, gpt_answerer_component, resume_generator_manager): + logger.debug("Setting GPT answerer and resume generator") self._ensure_job_profile_and_resume_set() gpt_answerer_component.set_job_application_profile(self.job_application_profile) gpt_answerer_component.set_resume(self.resume) self.apply_component.set_gpt_answerer(gpt_answerer_component) self.apply_component.set_resume_generator_manager(resume_generator_manager) self.state.gpt_answerer_set = True + logger.debug("GPT answerer and resume generator set successfully") def set_parameters(self, parameters): + logger.debug("Setting parameters") self._validate_non_empty(parameters, "Parameters") self.parameters = parameters self.apply_component.set_parameters(parameters) self.state.parameters_set = True + logger.debug("Parameters set successfully") def start_login(self): + logger.debug("Starting login process") self.state.validate_state(['credentials_set']) self.login_component.set_secrets(self.email, self.password) self.login_component.start() self.state.logged_in = True + logger.debug("Login process completed successfully") def start_apply(self): + logger.debug("Starting apply process") self.state.validate_state(['logged_in', 'job_application_profile_set', 'gpt_answerer_set', 'parameters_set']) self.apply_component.start_applying() + logger.debug("Apply process started successfully") def _validate_non_empty(self, value, name): + logger.debug("Validating that %s is not empty", name) if not value: + logger.error("Validation failed: %s is empty", name) raise ValueError(f"{name} cannot be empty.") + logger.debug("Validation passed for %s", name) def _ensure_job_profile_and_resume_set(self): + logger.debug("Ensuring job profile and resume are set") if not self.state.job_application_profile_set: + logger.error("Job application profile and resume are not set") raise ValueError("Job application profile and resume must be set before proceeding.") + logger.debug("Job profile and resume are set") diff --git a/src/linkedIn_easy_applier.py b/src/linkedIn_easy_applier.py index 8c95d8c..047d99d 100644 --- a/src/linkedIn_easy_applier.py +++ b/src/linkedIn_easy_applier.py @@ -10,7 +10,7 @@ from datetime import date from typing import List, Optional, Any, Tuple from reportlab.lib.pagesizes import letter from reportlab.pdfgen import canvas -from selenium.common.exceptions import NoSuchElementException +from selenium.common.exceptions import NoSuchElementException, TimeoutException from selenium.webdriver.common.by import By from selenium.webdriver.common.keys import Keys from selenium.webdriver.remote.webelement import WebElement @@ -18,9 +18,10 @@ from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.support.ui import Select, WebDriverWait from selenium.webdriver import ActionChains import src.utils as utils - +from src.utils import logger class LinkedInEasyApplier: def __init__(self, driver: Any, resume_dir: Optional[str], set_old_answers: List[Tuple[str, str, str]], gpt_answerer: Any, resume_generator_manager): + logger.debug("Initializing LinkedInEasyApplier") if resume_dir is None or not os.path.exists(resume_dir): resume_dir = None self.driver = driver @@ -29,28 +30,33 @@ class LinkedInEasyApplier: self.gpt_answerer = gpt_answerer self.resume_generator_manager = resume_generator_manager self.all_data = self._load_questions_from_json() - + 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) try: - try: - with open(output_file, 'r') as f: - try: - data = json.load(f) - if not isinstance(data, list): - raise ValueError("JSON file format is incorrect. Expected a list of questions.") - except json.JSONDecodeError: - data = [] - except FileNotFoundError: - data = [] + with open(output_file, 'r') as f: + try: + data = json.load(f) + if not isinstance(data, list): + raise ValueError("JSON file format is incorrect. Expected a list of questions.") + except json.JSONDecodeError: + logger.error("JSON decoding failed") + data = [] + logger.debug("Questions loaded successfully from JSON") return data + except FileNotFoundError: + logger.warning("JSON file not found, returning empty list") + return [] except Exception: tb_str = traceback.format_exc() + 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 job_apply(self, job: Any): + logger.debug("Starting job application for job: %s", job) self.driver.get(job.link) time.sleep(random.uniform(3, 5)) try: @@ -61,79 +67,103 @@ class LinkedInEasyApplier: actions.move_to_element(easy_apply_button).click().perform() self.gpt_answerer.set_job(job) self._fill_application_form(job) + logger.debug("Job application process completed for job: %s", job) except Exception: tb_str = traceback.format_exc() + logger.error("Failed to apply to job: %s", tb_str) self._discard_application() raise Exception(f"Failed to apply to job! Original exception: \nTraceback:\n{tb_str}") def _find_easy_apply_button(self) -> WebElement: + logger.debug("Searching for 'Easy Apply' button") attempt = 0 while attempt < 2: self._scroll_page() - buttons = WebDriverWait(self.driver, 10).until( - EC.presence_of_all_elements_located( - (By.XPATH, '//button[contains(@class, "jobs-apply-button") and contains(., "Easy Apply")]') - ) - ) - for index, _ in enumerate(buttons): - try: - button = WebDriverWait(self.driver, 10).until( - EC.element_to_be_clickable( - (By.XPATH, f'(//button[contains(@class, "jobs-apply-button") and contains(., "Easy Apply")])[{index + 1}]') - ) + try: + buttons = WebDriverWait(self.driver, 10).until( + EC.presence_of_all_elements_located( + (By.XPATH, '//button[contains(@class, "jobs-apply-button") and contains(., "Easy Apply")]') ) - return button - except Exception as e: - pass + ) + for index, _ in enumerate(buttons): + try: + button = WebDriverWait(self.driver, 10).until( + EC.element_to_be_clickable( + (By.XPATH, f'(//button[contains(@class, "jobs-apply-button") and contains(., "Easy Apply")])[{index + 1}]') + ) + ) + logger.debug("Found and clicking 'Easy Apply' button") + return button + except Exception as e: + logger.warning("Failed to click 'Easy Apply' button on attempt %d: %s", attempt + 1, e) + except TimeoutException: + logger.warning("Timeout while searching for 'Easy Apply' button") + if attempt == 0: + logger.debug("Refreshing page to retry finding 'Easy Apply' button") self.driver.refresh() - time.sleep(3) + time.sleep(random.randint(3, 5)) attempt += 1 + logger.error("No clickable 'Easy Apply' button found after 2 attempts") raise Exception("No clickable 'Easy Apply' button found") - def _get_job_description(self) -> str: + logger.debug("Getting job description") try: - see_more_button = self.driver.find_element(By.XPATH, '//button[@aria-label="Click to see more description"]') - actions = ActionChains(self.driver) - actions.move_to_element(see_more_button).click().perform() - time.sleep(2) + try: + see_more_button = self.driver.find_element(By.XPATH, '//button[@aria-label="Click to see more description"]') + actions = ActionChains(self.driver) + actions.move_to_element(see_more_button).click().perform() + time.sleep(2) + except NoSuchElementException: + logger.debug("See more button not found, skipping") + description = self.driver.find_element(By.CLASS_NAME, 'jobs-description-content__text').text + logger.debug("Job description retrieved successfully") return description except NoSuchElementException: tb_str = traceback.format_exc() - raise Exception("Job description 'See more' button not found: \nTraceback:\n{tb_str}") + logger.error("Job description not found: %s", tb_str) + raise Exception(f"Job description not found: \nTraceback:\n{tb_str}") except Exception: tb_str = traceback.format_exc() + logger.error("Error getting Job description: %s", tb_str) raise Exception(f"Error getting Job description: \nTraceback:\n{tb_str}") - def _get_job_recruiter(self): + logger.debug("Getting job recruiter information") try: hiring_team_section = WebDriverWait(self.driver, 10).until( EC.presence_of_element_located((By.XPATH, '//h2[text()="Meet the hiring team"]')) ) recruiter_element = hiring_team_section.find_element(By.XPATH, './/following::a[contains(@href, "linkedin.com/in/")]') recruiter_link = recruiter_element.get_attribute('href') + logger.debug("Job recruiter link retrieved successfully") return recruiter_link except Exception as e: + logger.warning("Failed to retrieve recruiter information: %s", e) return "" def _scroll_page(self) -> None: + logger.debug("Scrolling the page") scrollable_element = self.driver.find_element(By.TAG_NAME, 'html') utils.scroll_slow(self.driver, scrollable_element, step=300, reverse=False) utils.scroll_slow(self.driver, scrollable_element, step=300, reverse=True) def _fill_application_form(self, job): + logger.debug("Filling out application form for job: %s", job) while True: self.fill_up(job) if self._next_or_submit(): + logger.debug("Application form submitted") break def _next_or_submit(self): + logger.debug("Clicking 'Next' or 'Submit' button") next_button = self.driver.find_element(By.CLASS_NAME, "artdeco-button--primary") button_text = next_button.text.lower() if 'submit application' in button_text: + logger.debug("Submit button found, submitting application") self._unfollow_company() time.sleep(random.uniform(1.5, 2.5)) next_button.click() @@ -146,70 +176,88 @@ class LinkedInEasyApplier: def _unfollow_company(self) -> None: try: + logger.debug("Unfollowing company") follow_checkbox = self.driver.find_element( By.XPATH, "//label[contains(.,'to stay up to date with their page.')]") follow_checkbox.click() except Exception as e: - pass + logger.warning("Failed to unfollow company: %s", e) def _check_for_errors(self) -> None: + logger.debug("Checking for form errors") error_elements = self.driver.find_elements(By.CLASS_NAME, 'artdeco-inline-feedback--error') if error_elements: + logger.error("Form submission failed with errors: %s", [e.text for e in error_elements]) raise Exception(f"Failed answering or file upload. {str([e.text for e in error_elements])}") def _discard_application(self) -> None: + logger.debug("Discarding application") try: self.driver.find_element(By.CLASS_NAME, 'artdeco-modal__dismiss').click() time.sleep(random.uniform(3, 5)) self.driver.find_elements(By.CLASS_NAME, 'artdeco-modal__confirm-dialog-btn')[0].click() time.sleep(random.uniform(3, 5)) except Exception as e: - pass + logger.warning("Failed to discard application: %s", e) def fill_up(self, job) -> None: + logger.debug("Filling up form sections for job: %s", job) easy_apply_content = self.driver.find_element(By.CLASS_NAME, 'jobs-easy-apply-content') pb4_elements = easy_apply_content.find_elements(By.CLASS_NAME, 'pb4') for element in pb4_elements: self._process_form_element(element, job) def _process_form_element(self, element: WebElement, job) -> None: + logger.debug("Processing form element") if self._is_upload_field(element): self._handle_upload_fields(element, job) else: self._fill_additional_questions() def _is_upload_field(self, element: WebElement) -> bool: - return bool(element.find_elements(By.XPATH, ".//input[@type='file']")) + is_upload = bool(element.find_elements(By.XPATH, ".//input[@type='file']")) + logger.debug("Element is upload field: %s", is_upload) + return is_upload def _handle_upload_fields(self, element: WebElement, job) -> None: + logger.debug("Handling upload fields") file_upload_elements = self.driver.find_elements(By.XPATH, "//input[@type='file']") for element in file_upload_elements: parent = element.find_element(By.XPATH, "..") self.driver.execute_script("arguments[0].classList.remove('hidden')", element) output = self.gpt_answerer.resume_or_cover(parent.text.lower()) if 'resume' in output: + logger.debug("Uploading resume") if self.resume_path is not None and self.resume_path.resolve().is_file(): element.send_keys(str(self.resume_path.resolve())) else: self._create_and_upload_resume(element, job) elif 'cover' in output: + logger.debug("Uploading cover letter") self._create_and_upload_cover_letter(element) def _create_and_upload_resume(self, element, job): + logger.debug("Creating and uploading resume") folder_path = 'generated_cv' os.makedirs(folder_path, exist_ok=True) try: - file_path_pdf = os.path.join(folder_path, f"CV_{random.randint(0, 9999)}.pdf") - with open(file_path_pdf, "xb") as f: + timestamp = int(time.time()) + file_path_pdf = os.path.join(folder_path, f"CV_{timestamp}.pdf") + + with open(file_path_pdf, "xb") as f: # gjcvjn f.write(base64.b64decode(self.resume_generator_manager.pdf_base64(job_description_text=job.description))) + element.send_keys(os.path.abspath(file_path_pdf)) job.pdf_path = os.path.abspath(file_path_pdf) time.sleep(2) + logger.debug("Resume created and uploaded successfully: %s", file_path_pdf) except Exception: tb_str = traceback.format_exc() + logger.error("Resume upload failed: %s", tb_str) raise Exception(f"Upload failed: \nTraceback:\n{tb_str}") def _create_and_upload_cover_letter(self, element: WebElement) -> None: + logger.debug("Creating and uploading cover letter") cover_letter = self.gpt_answerer.answer_question_textual_wide_range("Write a cover letter") with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as temp_pdf_file: letter_path = temp_pdf_file.name @@ -221,29 +269,38 @@ class LinkedInEasyApplier: c.drawText(text_object) c.save() element.send_keys(letter_path) + logger.debug("Cover letter created and uploaded successfully: %s", letter_path) def _fill_additional_questions(self) -> None: + logger.debug("Filling additional questions") form_sections = self.driver.find_elements(By.CLASS_NAME, 'jobs-easy-apply-form-section__grouping') for section in form_sections: self._process_form_section(section) def _process_form_section(self, section: WebElement) -> None: + logger.debug("Processing form section") if self._handle_terms_of_service(section): + logger.debug("Handled terms of service") return if self._find_and_handle_radio_question(section): + logger.debug("Handled radio question") return if self._find_and_handle_textbox_question(section): + logger.debug("Handled textbox question") return if self._find_and_handle_date_question(section): + logger.debug("Handled date question") return if self._find_and_handle_dropdown_question(section): + logger.debug("Handled dropdown question") return def _handle_terms_of_service(self, element: WebElement) -> bool: checkbox = element.find_elements(By.TAG_NAME, 'label') if checkbox and any(term in checkbox[0].text.lower() for term in ['terms of service', 'privacy policy', 'terms of use']): checkbox[0].click() + logger.debug("Clicked terms of service checkbox") return True return False @@ -261,11 +318,13 @@ class LinkedInEasyApplier: break if existing_answer: self._select_radio(radios, existing_answer['answer']) + logger.debug("Selected existing radio answer") return True answer = self.gpt_answerer.answer_question_from_options(question_text, options) self._save_questions_to_json({'type': 'radio', 'question': question_text, 'answer': answer}) self._select_radio(radios, answer) + logger.debug("Selected new radio answer") return True return False @@ -288,9 +347,11 @@ class LinkedInEasyApplier: break if existing_answer: self._enter_text(text_field, existing_answer['answer']) + logger.debug("Entered existing textbox answer") return True self._save_questions_to_json({'type': question_type, 'question': question_text, 'answer': answer}) self._enter_text(text_field, answer) + logger.debug("Entered new textbox answer") return True return False @@ -305,15 +366,17 @@ class LinkedInEasyApplier: existing_answer = None for item in self.all_data: - if self._sanitize_text(question_text) in item['question'] and item['type'] == 'date': + if self._sanitize_text(question_text) in item['question'] and item['type'] == 'date': existing_answer = item break if existing_answer: self._enter_text(date_field, existing_answer['answer']) + 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") return True return False @@ -328,32 +391,36 @@ class LinkedInEasyApplier: existing_answer = None for item in self.all_data: - if self._sanitize_text(question_text) in item['question'] and item['type'] == 'dropdown': + if self._sanitize_text(question_text) in item['question'] and item['type'] == 'dropdown': existing_answer = item break if existing_answer: self._select_dropdown_option(dropdown, existing_answer['answer']) + logger.debug("Selected existing dropdown answer") return True answer = self.gpt_answerer.answer_question_from_options(question_text, options) self._save_questions_to_json({'type': 'dropdown', 'question': question_text, 'answer': answer}) self._select_dropdown_option(dropdown, answer) + logger.debug("Selected new dropdown answer") return True - except Exception: + except Exception as e: + logger.warning("Failed to handle dropdown question: %s", e) return False def _is_numeric_field(self, field: WebElement) -> bool: field_type = field.get_attribute('type').lower() - if 'numeric' in field_type: - return True - class_attribute = field.get_attribute("id") - return class_attribute and 'numeric' in class_attribute + is_numeric = 'numeric' in field_type or ('id' in field.get_attribute("id") and 'numeric' in field.get_attribute("id")) + logger.debug("Field is numeric: %s", is_numeric) + return is_numeric def _enter_text(self, element: WebElement, text: str) -> None: + logger.debug("Entering text: %s", text) element.clear() element.send_keys(text) def _select_radio(self, radios: List[WebElement], answer: str) -> None: + logger.debug("Selecting radio option: %s", answer) for radio in radios: if answer in radio.text.lower(): radio.find_element(By.TAG_NAME, 'label').click() @@ -361,12 +428,14 @@ class LinkedInEasyApplier: radios[-1].find_element(By.TAG_NAME, 'label').click() def _select_dropdown_option(self, element: WebElement, text: str) -> None: + logger.debug("Selecting dropdown option: %s", text) select = Select(element) select.select_by_visible_text(text) def _save_questions_to_json(self, question_data: dict) -> None: output_file = 'answers.json' question_data['question'] = self._sanitize_text(question_data['question']) + logger.debug("Saving question data to JSON: %s", question_data) try: try: with open(output_file, 'r') as f: @@ -375,23 +444,23 @@ class LinkedInEasyApplier: if not isinstance(data, list): raise ValueError("JSON file format is incorrect. Expected a list of questions.") except json.JSONDecodeError: + logger.error("JSON decoding failed") data = [] except FileNotFoundError: + logger.warning("JSON file not found, creating new file") data = [] data.append(question_data) with open(output_file, 'w') as f: json.dump(data, f, indent=4) + logger.debug("Question data saved successfully to JSON") except Exception: tb_str = traceback.format_exc() + logger.error("Error saving questions data to JSON file: %s", tb_str) raise Exception(f"Error saving questions data to JSON file: \nTraceback:\n{tb_str}") def _sanitize_text(self, text: str) -> str: - sanitized_text = text.lower() - sanitized_text = sanitized_text.strip() - sanitized_text = sanitized_text.replace('"', '') - sanitized_text = sanitized_text.replace('\\', '') - sanitized_text = re.sub(r'[\x00-\x1F\x7F]', '', sanitized_text) - sanitized_text = sanitized_text.replace('\n', ' ').replace('\r', '') - sanitized_text = sanitized_text.rstrip(',') + sanitized_text = text.lower().strip().replace('"', '').replace('\\', '') + sanitized_text = re.sub(r'[\x00-\x1F\x7F]', '', sanitized_text).replace('\n', ' ').replace('\r', '').rstrip(',') + logger.debug("Sanitized text: %s", sanitized_text) return sanitized_text diff --git a/src/linkedIn_job_manager.py b/src/linkedIn_job_manager.py index d368d71..cdd4584 100644 --- a/src/linkedIn_job_manager.py +++ b/src/linkedIn_job_manager.py @@ -10,28 +10,39 @@ import src.utils as utils from src.job import Job from src.linkedIn_easy_applier import LinkedInEasyApplier import json +from src.utils import logger class EnvironmentKeys: def __init__(self): + logger.debug("Initializing EnvironmentKeys") self.skip_apply = self._read_env_key_bool("SKIP_APPLY") self.disable_description_filter = self._read_env_key_bool("DISABLE_DESCRIPTION_FILTER") + logger.debug("EnvironmentKeys initialized: skip_apply=%s, disable_description_filter=%s", + self.skip_apply, self.disable_description_filter) @staticmethod def _read_env_key(key: str) -> str: - return os.getenv(key, "") + value = os.getenv(key, "") + logger.debug("Read environment key %s: %s", key, value) + return value @staticmethod def _read_env_key_bool(key: str) -> bool: - return os.getenv(key) == "True" + value = os.getenv(key) == "True" + logger.debug("Read environment key %s as bool: %s", key, value) + return value class LinkedInJobManager: def __init__(self, driver): + logger.debug("Initializing LinkedInJobManager") self.driver = driver self.set_old_answers = set() self.easy_applier_component = None + logger.debug("LinkedInJobManager initialized successfully") def set_parameters(self, parameters): + logger.debug("Setting parameters for LinkedInJobManager") self.company_blacklist = parameters.get('companyBlacklist', []) or [] self.title_blacklist = parameters.get('titleBlacklist', []) or [] self.positions = parameters.get('positions', []) @@ -39,33 +50,21 @@ class LinkedInJobManager: self.base_search_url = self.get_base_search_url(parameters) self.seen_jobs = [] resume_path = parameters.get('uploads', {}).get('resume', None) - if resume_path is not None and Path(resume_path).exists(): - self.resume_path = Path(resume_path) - else: - self.resume_path = None + self.resume_path = Path(resume_path) if resume_path and Path(resume_path).exists() else None self.output_file_directory = Path(parameters['outputFileDirectory']) self.env_config = EnvironmentKeys() - #self.old_question() + logger.debug("Parameters set successfully") def set_gpt_answerer(self, gpt_answerer): + logger.debug("Setting GPT answerer") self.gpt_answerer = gpt_answerer def set_resume_generator_manager(self, resume_generator_manager): + logger.debug("Setting resume generator manager") self.resume_generator_manager = resume_generator_manager - """ def old_question(self): - self.set_old_answers = {} - file_path = 'data_folder/output/old_Questions.csv' - if os.path.exists(file_path): - with open(file_path, 'r', newline='', encoding='utf-8', errors='ignore') as file: - csv_reader = csv.reader(file, delimiter=',', quotechar='"') - for row in csv_reader: - if len(row) == 3: - answer_type, question_text, answer = row - self.set_old_answers[(answer_type.lower(), question_text.lower())] = answer""" - - def start_applying(self): + logger.debug("Starting job application process") self.easy_applier_component = LinkedInEasyApplier(self.driver, self.resume_path, self.set_old_answers, self.gpt_answerer, self.resume_generator_manager) searches = list(product(self.positions, self.locations)) random.shuffle(searches) @@ -86,30 +85,40 @@ class LinkedInJobManager: self.next_job_page(position, location_url, job_page_number) time.sleep(random.uniform(1.5, 3.5)) utils.printyellow("Starting the application process for this page...") - self.apply_jobs() + try: + self.apply_jobs() + except Exception as e: + logger.error("Error during job application: %s", e) + utils.printred(f"Error during job application: {e}") + continue utils.printyellow("Applying to jobs on this page has been completed!") 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 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) page_sleep += 1 - except Exception: - traceback.format_exc() - pass + 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 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) page_sleep += 1 @@ -117,32 +126,40 @@ class LinkedInJobManager: try: no_jobs_element = self.driver.find_element(By.CLASS_NAME, 'jobs-search-two-pane__no-results-banner--expand') if 'No matching jobs found' in no_jobs_element.text or 'unfortunately, things aren' in self.driver.page_source.lower(): - raise Exception("No more jobs on this page") + utils.printyellow("No matching jobs found on this page, moving to next.") + logger.debug("No matching jobs found on this page, skipping") + return # Выход из метода, если нет больше подходящих вакансий except NoSuchElementException: - pass - + 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) 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: - raise Exception("No job class elements found on page") + 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: if self.is_blacklisted(job.title, job.company, job.link): utils.printyellow(f"Blacklisted {job.title} at {job.company}, skipping...") + logger.debug("Job blacklisted: %s at %s", job.title, job.company) self.write_to_file(job, "skipped") continue try: if job.apply_method not in {"Continue", "Applied", "Apply"}: self.easy_applier_component.job_apply(job) self.write_to_file(job, "success") + logger.debug("Applied to job: %s at %s", job.title, job.company) except Exception as e: - utils.printred(traceback.format_exc()) + logger.error("Failed to apply for %s at %s: %s", job.title, job.company, e) + utils.printred(f"Failed to apply for {job.title} at {job.company}: {e}") self.write_to_file(job, "failed") continue def write_to_file(self, job, file_name): + logger.debug("Writing job application result to file: %s", file_name) pdf_path = Path(job.pdf_path).resolve() pdf_path = pdf_path.as_uri() data = { @@ -157,18 +174,22 @@ class LinkedInJobManager: if not file_path.exists(): with open(file_path, 'w', encoding='utf-8') as f: json.dump([data], f, indent=4) + logger.debug("Job data written to new file: %s", file_path) else: with open(file_path, 'r+', encoding='utf-8') as f: try: existing_data = json.load(f) except json.JSONDecodeError: + logger.error("JSON decode error in file: %s", file_path) existing_data = [] existing_data.append(data) f.seek(0) json.dump(existing_data, f, indent=4) f.truncate() + logger.debug("Job data appended to existing file: %s", file_path) def get_base_search_url(self, parameters): + logger.debug("Constructing base search URL") url_parts = [] if parameters['remote']: url_parts.append("f_CF=f_WRA") @@ -188,33 +209,45 @@ class LinkedInJobManager: date_param = next((v for k, v in date_mapping.items() if parameters.get('date', {}).get(k)), "") url_parts.append("f_LF=f_AL") # Easy Apply base_url = "&".join(url_parts) - return f"?{base_url}{date_param}" + full_url = f"?{base_url}{date_param}" + logger.debug("Base search URL constructed: %s", full_url) + return full_url def next_job_page(self, position, location, job_page): + logger.debug("Navigating to next job page: %s in %s, page %d", position, location, job_page) self.driver.get(f"https://www.linkedin.com/jobs/search/{self.base_search_url}&keywords={position}{location}&start={job_page * 25}") def extract_job_information_from_tile(self, job_tile): + logger.debug("Extracting job information from tile") job_title, company, job_location, apply_method, link = "", "", "", "", "" try: job_title = job_tile.find_element(By.CLASS_NAME, 'job-card-list__title').text link = job_tile.find_element(By.CLASS_NAME, 'job-card-list__title').get_attribute('href').split('?')[0] company = job_tile.find_element(By.CLASS_NAME, 'job-card-container__primary-description').text - except: - pass + logger.debug("Job information extracted: %s at %s", job_title, company) + except NoSuchElementException: + utils.printyellow("Some job information (title, link, or company) is missing.") + logger.warning("Some job information (title, link, or company) is missing.") try: job_location = job_tile.find_element(By.CLASS_NAME, 'job-card-container__metadata-item').text - except: - pass + except NoSuchElementException: + utils.printyellow("Job location is missing.") + logger.warning("Job location is missing.") try: apply_method = job_tile.find_element(By.CLASS_NAME, 'job-card-container__apply-method').text - except: - apply_method = "Applied" + except NoSuchElementException: + apply_method = "Applied" # Подразумеваем, что вакансия уже подана + utils.printyellow("Apply method not found, assuming 'Applied'.") + logger.warning("Apply method not found, assuming 'Applied'.") return job_title, company, job_location, link, apply_method def is_blacklisted(self, job_title, company, link): + logger.debug("Checking if job is blacklisted: %s at %s", job_title, company) job_title_words = job_title.lower().split(' ') title_blacklisted = any(word in job_title_words for word in self.title_blacklist) company_blacklisted = company.strip().lower() in (word.strip().lower() for word in self.company_blacklist) link_seen = link in self.seen_jobs - return title_blacklisted or company_blacklisted or link_seen + is_blacklisted = title_blacklisted or company_blacklisted or link_seen + logger.debug("Job blacklisted status: %s", is_blacklisted) + return is_blacklisted diff --git a/src/utils.py b/src/utils.py index ea7c07b..71e03e3 100644 --- a/src/utils.py +++ b/src/utils.py @@ -4,76 +4,97 @@ import time from selenium import webdriver +import logging + +# Настройка логирования +logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') +logger = logging.getLogger(__name__) + + chromeProfilePath = os.path.join(os.getcwd(), "chrome_profile", "linkedin_profile") def ensure_chrome_profile(): + logger.debug("Ensuring Chrome profile exists at path: %s", chromeProfilePath) profile_dir = os.path.dirname(chromeProfilePath) if not os.path.exists(profile_dir): os.makedirs(profile_dir) + logger.debug("Created directory for Chrome profile: %s", profile_dir) if not os.path.exists(chromeProfilePath): os.makedirs(chromeProfilePath) + logger.debug("Created Chrome profile directory: %s", chromeProfilePath) return chromeProfilePath def is_scrollable(element): scroll_height = element.get_attribute("scrollHeight") client_height = element.get_attribute("clientHeight") - return int(scroll_height) > int(client_height) + scrollable = int(scroll_height) > int(client_height) + logger.debug("Element scrollable check: scrollHeight=%s, clientHeight=%s, scrollable=%s", scroll_height, client_height, scrollable) + return scrollable def scroll_slow(driver, scrollable_element, start=0, end=3600, step=100, reverse=False): + logger.debug("Starting slow scroll: start=%d, end=%d, step=%d, reverse=%s", start, end, step, reverse) if reverse: start, end = end, start step = -step if step == 0: + logger.error("Step value cannot be zero.") raise ValueError("Step cannot be zero.") script_scroll_to = "arguments[0].scrollTop = arguments[1];" try: if scrollable_element.is_displayed(): if not is_scrollable(scrollable_element): + logger.warning("The element is not scrollable.") print("The element is not scrollable.") return if (step > 0 and start >= end) or (step < 0 and start <= end): + logger.warning("No scrolling will occur due to incorrect start/end values.") print("No scrolling will occur due to incorrect start/end values.") return for position in range(start, end, step): try: driver.execute_script(script_scroll_to, scrollable_element, position) + logger.debug("Scrolled to position: %d", position) except Exception as e: + logger.error("Error during scrolling: %s", e) print(f"Error during scrolling: {e}") - time.sleep(random.uniform(1.0, 2.6)) + time.sleep(random.uniform(1.0, 1.6)) driver.execute_script(script_scroll_to, scrollable_element, end) + logger.debug("Scrolled to final position: %d", end) time.sleep(1) else: + logger.warning("The element is not visible.") print("The element is not visible.") except Exception as e: + logger.error("Exception occurred during scrolling: %s", e) print(f"Exception occurred: {e}") def chromeBrowserOptions(): + logger.debug("Setting Chrome browser options") ensure_chrome_profile() options = webdriver.ChromeOptions() - options.add_argument("--start-maximized") # Avvia il browser a schermo intero - options.add_argument("--no-sandbox") # Disabilita la sandboxing per migliorare le prestazioni - options.add_argument("--disable-dev-shm-usage") # Utilizza una directory temporanea per la memoria condivisa - options.add_argument("--ignore-certificate-errors") # Ignora gli errori dei certificati SSL - options.add_argument("--disable-extensions") # Disabilita le estensioni del browser - options.add_argument("--disable-gpu") # Disabilita l'accelerazione GPU - options.add_argument("window-size=1200x800") # Imposta la dimensione della finestra del browser - options.add_argument("--disable-background-timer-throttling") # Disabilita il throttling dei timer in background - options.add_argument("--disable-backgrounding-occluded-windows") # Disabilita la sospensione delle finestre occluse - options.add_argument("--disable-translate") # Disabilita il traduttore automatico - options.add_argument("--disable-popup-blocking") # Disabilita il blocco dei popup - options.add_argument("--no-first-run") # Disabilita la configurazione iniziale del browser - options.add_argument("--no-default-browser-check") # Disabilita il controllo del browser predefinito - options.add_argument("--disable-logging") # Disabilita il logging - options.add_argument("--disable-autofill") # Disabilita l'autocompletamento dei moduli - options.add_argument("--disable-plugins") # Disabilita i plugin del browser - options.add_argument("--disable-animations") # Disabilita le animazioni - options.add_argument("--disable-cache") # Disabilita la cache - options.add_experimental_option("excludeSwitches", ["enable-automation", "enable-logging"]) # Esclude switch della modalità automatica e logging + options.add_argument("--start-maximized") + options.add_argument("--no-sandbox") + options.add_argument("--disable-dev-shm-usage") + options.add_argument("--ignore-certificate-errors") + options.add_argument("--disable-extensions") + options.add_argument("--disable-gpu") + options.add_argument("window-size=1200x800") + options.add_argument("--disable-background-timer-throttling") + options.add_argument("--disable-backgrounding-occluded-windows") + options.add_argument("--disable-translate") + options.add_argument("--disable-popup-blocking") + options.add_argument("--no-first-run") + options.add_argument("--no-default-browser-check") + options.add_argument("--disable-logging") + options.add_argument("--disable-autofill") + options.add_argument("--disable-plugins") + options.add_argument("--disable-animations") + options.add_argument("--disable-cache") + options.add_experimental_option("excludeSwitches", ["enable-automation", "enable-logging"]) - # Preferenze per contenuti prefs = { - "profile.default_content_setting_values.images": 2, # Disabilita il caricamento delle immagini - "profile.managed_default_content_settings.stylesheets": 2, # Disabilita il caricamento dei fogli di stile + "profile.default_content_setting_values.images": 2, + "profile.managed_default_content_settings.stylesheets": 2, } options.add_experimental_option("prefs", prefs) @@ -82,22 +103,21 @@ def chromeBrowserOptions(): profileDir = os.path.basename(chromeProfilePath) options.add_argument('--user-data-dir=' + initialPath) options.add_argument("--profile-directory=" + profileDir) + logger.debug("Using Chrome profile directory: %s", chromeProfilePath) else: options.add_argument("--incognito") + logger.debug("Using Chrome in incognito mode") return options - def printred(text): - # Codice colore ANSI per il rosso RED = "\033[91m" RESET = "\033[0m" - # Stampa il testo in rosso + logger.debug("Printing text in red: %s", text) print(f"{RED}{text}{RESET}") def printyellow(text): - # Codice colore ANSI per il giallo YELLOW = "\033[93m" RESET = "\033[0m" - # Stampa il testo in giallo - print(f"{YELLOW}{text}{RESET}") \ No newline at end of file + logger.debug("Printing text in yellow: %s", text) + print(f"{YELLOW}{text}{RESET}") From 0c4ae18064ef942dbeac107153d0ede1d6cd4513 Mon Sep 17 00:00:00 2001 From: queukat Date: Sat, 31 Aug 2024 23:45:10 +0300 Subject: [PATCH 09/97] add logs and some bugs fixes --- src/gpt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gpt.py b/src/gpt.py index 63bbdf2..62f362a 100644 --- a/src/gpt.py +++ b/src/gpt.py @@ -57,7 +57,7 @@ def parse_wait_time_from_error_message(error_message: str) -> int: elif unit == 'd': return value * 86400 logger.debug("Default wait time applied: 30 seconds") - return 30 # По умолчанию ждать 30 секунд, если не удалось разобрать время + return 30 class LLMLogger: From ca4f56833a2834d068dc8cf0cc62ebd9f80d0873 Mon Sep 17 00:00:00 2001 From: user Date: Sat, 31 Aug 2024 22:58:40 +0200 Subject: [PATCH 10/97] Added support for ollama endpoint and defining the LLM model in the config --- README.md | 17 +++++++++-------- data_folder/config.yaml | 6 +++++- data_folder/secrets.yaml | 3 +-- data_folder_example/config.yaml | 4 ++++ data_folder_example/secrets.yaml | 3 +-- main.py | 15 ++++++--------- src/gpt.py | 23 ++++++++++++----------- 7 files changed, 38 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index de60c0e..b396b3f 100644 --- a/README.md +++ b/README.md @@ -148,17 +148,10 @@ 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 -- `openai_api_key: [Your OpenAI API key]` +- `llm_api_key: [Your OpenAI or Ollama 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). -- `openai_api_free_hosted_url`: - - Optional paramter, if you want to use freely hosted GPT model, set `openai_api_key: "freehosted"` and `openai_api_free_hosted_url` with the URL of the endpoint -- Ollama local support - - If you want to use Ollama which is deployed locally, leave `openai_api_key` blank. - - To setup Ollama to run locally follow the instructions here: [Ollama installation](https://github.com/ollama/ollama). - - Download mistral model by pulling mistral:v0.3 - ### 2. config.yaml @@ -217,6 +210,14 @@ This file defines your job search parameters and bot behavior. Each section cont - Sales - Marketing ``` +- `llm_model_type`: + - Choose the model type, supported: openai / ollama +- `llm_model`: + - Choose the LLM model, currently supported: + - openai: gpt-4o + - ollama: llama2, mistral:v0.3 +- `llm_api_url`: + - Link of the API endpoint for the LLM model ### 3. plain_text_resume.yaml diff --git a/data_folder/config.yaml b/data_folder/config.yaml index 58a6f1c..e28e58a 100644 --- a/data_folder/config.yaml +++ b/data_folder/config.yaml @@ -39,4 +39,8 @@ companyBlacklist: titleBlacklist: - word1 - - word2 \ No newline at end of file + - word2 + +llm_model_type: [openai / ollama] +llm_model: ['gpt-4o' / 'mistral:v0.3'] +llm_api_url: [https://api.pawan.krd/cosmosrp/v1', http://127.0.0.1:11434/] \ No newline at end of file diff --git a/data_folder/secrets.yaml b/data_folder/secrets.yaml index 9d0bfb3..c218803 100644 --- a/data_folder/secrets.yaml +++ b/data_folder/secrets.yaml @@ -1,4 +1,3 @@ email: myemaillinkedin@gmail.com password: ImpossiblePassowrd10 -openai_api_key: sk-11KRr4uuTwpRGfeRTfj1T9BlbkFJjP8QTrswHU1yGruru2FR -openai_api_free_hosted_url: https://api.pawan.krd/cosmosrp/v1 \ No newline at end of file +llm_api_key: 'sk-11KRr4uuTwpRGfeRTfj1T9BlbkFJjP8QTrswHU1yGruru2FR' \ No newline at end of file diff --git a/data_folder_example/config.yaml b/data_folder_example/config.yaml index 6e362be..5f0a83f 100644 --- a/data_folder_example/config.yaml +++ b/data_folder_example/config.yaml @@ -37,3 +37,7 @@ companyBlacklist: - Crossover titleBlacklist: + +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_example/secrets.yaml b/data_folder_example/secrets.yaml index 9d0bfb3..c218803 100644 --- a/data_folder_example/secrets.yaml +++ b/data_folder_example/secrets.yaml @@ -1,4 +1,3 @@ email: myemaillinkedin@gmail.com password: ImpossiblePassowrd10 -openai_api_key: sk-11KRr4uuTwpRGfeRTfj1T9BlbkFJjP8QTrswHU1yGruru2FR -openai_api_free_hosted_url: https://api.pawan.krd/cosmosrp/v1 \ No newline at end of file +llm_api_key: 'sk-11KRr4uuTwpRGfeRTfj1T9BlbkFJjP8QTrswHU1yGruru2FR' \ No newline at end of file diff --git a/main.py b/main.py index 82ed33f..af1f5b7 100644 --- a/main.py +++ b/main.py @@ -111,10 +111,7 @@ class ConfigValidator: raise ConfigError(f"Invalid email format in secrets file {secrets_yaml_path}.") if not secrets['password']: raise ConfigError(f"Password cannot be empty in secrets file {secrets_yaml_path}.") - if not secrets['openai_api_key']: - raise ConfigError(f"OpenAI API key cannot be empty in secrets file {secrets_yaml_path}.") - - return secrets['email'], str(secrets['password']), secrets['openai_api_key'], secrets['openai_api_free_hosted_url'] + return secrets['email'], str(secrets['password']), secrets['llm_api_key'] class FileManager: @staticmethod @@ -158,14 +155,14 @@ def init_browser() -> webdriver.Chrome: except Exception as e: raise RuntimeError(f"Failed to initialize browser: {str(e)}") -def create_and_run_bot(email, password, parameters, openai_api_key, openai_api_free_hosted_url): +def create_and_run_bot(email, password, parameters, llm_api_key): try: style_manager = StyleManager() resume_generator = ResumeGenerator() with open(parameters['uploads']['plainTextResume'], "r") as file: plain_text_resume = file.read() resume_object = Resume(plain_text_resume) - resume_generator_manager = FacadeManager(openai_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') @@ -175,7 +172,7 @@ def create_and_run_bot(email, password, parameters, openai_api_key, openai_api_f browser = init_browser() login_component = LinkedInAuthenticator(browser) apply_component = LinkedInJobManager(browser) - gpt_answerer_component = GPTAnswerer(openai_api_key, openai_api_free_hosted_url) + gpt_answerer_component = GPTAnswerer(parameters, llm_api_key) bot = LinkedInBotFacade(login_component, apply_component) bot.set_secrets(email, password) bot.set_job_application_profile_and_resume(job_application_profile_object, resume_object) @@ -197,12 +194,12 @@ def main(resume: Path = None): secrets_file, config_file, plain_text_resume_file, output_folder = FileManager.validate_data_folder(data_folder) parameters = ConfigValidator.validate_config(config_file) - email, password, openai_api_key, openai_api_free_hosted_url = ConfigValidator.validate_secrets(secrets_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, openai_api_key, openai_api_free_hosted_url) + 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") diff --git a/src/gpt.py b/src/gpt.py index 684a4de..bc71e8b 100644 --- a/src/gpt.py +++ b/src/gpt.py @@ -114,17 +114,18 @@ class LoggerChatModel: class GPTAnswerer: - def __init__(self, openai_api_key, openai_api_free_hosted_url): - if openai_api_key == "": - print('Using locally hosted mistral:v0.3') - self.llm_model = ChatOllama(model = "mistral:v0.3", temperature = 0.4, num_predict = 256) - elif openai_api_key == "freehosted": - print('Using free hosted gpt-4o-mini') - self.llm_model = ChatOpenAI(model_name="gpt-4o-mini", openai_api_key="anything", temperature=0.4, - base_url=openai_api_free_hosted_url) - else: - print("Using gpt-4o-mini") - self.llm_model = ChatOpenAI(model_name="gpt-4o-mini", openai_api_key=openai_api_key, temperature=0.4) + def __init__(self, config, llm_api_key): + llm_model_type = config['llm_model_type'] + llm_model = config['llm_model'] + llm_api_url = config['llm_api_url'] + + print('Using {0} with {1} from {2}'.format(llm_model_type, llm_model, llm_api_url)) + + if llm_model_type == "ollama": + self.llm_model = ChatOllama(model=llm_model, temperature = 0.4, base_url=llm_api_url) + elif llm_model_type == "openai": + self.llm_model = ChatOpenAI(model_name=llm_model, openai_api_key=llm_api_key, temperature=0.4, + base_url=llm_api_url) self.llm_cheap = LoggerChatModel(self.llm_model) @property def job_description(self): From be8ddb8241bc0ca960fa7fec7f5376f4a7818c9b Mon Sep 17 00:00:00 2001 From: Maurice McCabe Date: Sat, 31 Aug 2024 14:56:37 -0700 Subject: [PATCH 11/97] resume generator cmdline utility --- .gitignore | 2 + README.md | 21 +++++ assets/resume_liam_murphy.txt | 55 ++++++++++++ assets/resume_schema.yaml | 132 +++++++++++++++++++++++++++ requirements.txt | Bin 670 -> 710 bytes resume_yaml_generator.py | 164 ++++++++++++++++++++++++++++++++++ 6 files changed, 374 insertions(+) create mode 100644 assets/resume_liam_murphy.txt create mode 100644 assets/resume_schema.yaml create mode 100644 resume_yaml_generator.py diff --git a/.gitignore b/.gitignore index 4e73720..1e8e081 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,5 @@ generated_cv* .vscode chrome_profile answers.json +resume.yaml +resume.yaml_validation_report.txt diff --git a/README.md b/README.md index 3258335..78c669e 100644 --- a/README.md +++ b/README.md @@ -452,6 +452,27 @@ Each section has specific fields to fill out: willing_to_undergo_drug_tests: "No" willing_to_undergo_background_checks: "Yes" ``` +### 4. Generating plain_text_resume.yaml from a Text Resume + +To simplify the process of creating your `plain_text_resume.yaml` file, you can use the provided script to generate it from a text-based resume. Follow these steps: + +1. Prepare your resume in a plain text format (.txt file). + +2. Place your text resume in the `data_folder` directory. + +3. Run the following command: + + ```bash + python generate_resume_yaml.py --input data_folder/your_resume.txt --output data_folder/plain_text_resume.yaml + ``` + + Replace `your_resume.txt` with the actual name of your text resume file. + +4. The script will generate a `plain_text_resume.yaml` file in the `data_folder` directory. + +5. Review the generated YAML file and make any necessary adjustments to ensure all information is correct and complete. + +This automated process helps in creating a structured YAML file from your existing resume, saving time and reducing the chance of errors in manual data entry. ### PLUS. data_folder_example diff --git a/assets/resume_liam_murphy.txt b/assets/resume_liam_murphy.txt new file mode 100644 index 0000000..30b5402 --- /dev/null +++ b/assets/resume_liam_murphy.txt @@ -0,0 +1,55 @@ +Liam Murphy +Galway, Ireland +Email: liam.murphy@gmail.com | LinkedIn: liam-murphy +GitHub: liam-murphy | Phone: +353 871234567 + +Education +Bachelor's Degree in Computer Science +National University of Ireland, Galway (GPA: 4/4) +Graduation Year: 2020 + +Experience +Co-Founder & Software Engineer +CryptoWave Solutions (03/2021 - Present) +Location: Ireland | Industry: Blockchain Technology + +Co-founded and led a startup specializing in app and software development with a focus on blockchain technology +Provided blockchain consultations for 10+ companies, enhancing their software capabilities with secure, decentralized solutions +Developed blockchain applications, integrated cutting-edge technology to meet client needs and drive industry innovation +Research Intern +National University of Ireland, Galway (11/2022 - 03/2023) +Location: Galway, Ireland | Industry: IoT Security Research + +Conducted in-depth research on IoT security, focusing on binary instrumentation and runtime monitoring +Performed in-depth study of the MQTT protocol and Falco +Developed multiple software components including MQTT packet analysis library, Falco adapter, and RML monitor in Prolog +Authored thesis "Binary Instrumentation for Runtime Monitoring of Internet of Things Systems Using Falco" +Software Engineer +University Hospital Galway (05/2022 - 11/2022) +Location: Galway, Ireland | Industry: Healthcare IT + +Integrated and enforced robust security protocols +Developed and maintained a critical software tool for password validation used by over 1,600 employees +Played an integral role in the hospital's cybersecurity team +Projects +JobBot +AI-driven tool to automate and personalize job applications on LinkedIn, gained over 3000 stars on GitHub, improving efficiency and reducing application time +Link: JobBot + +mqtt-packet-parser +Developed a Node.js module for parsing MQTT packets, improved parsing efficiency by 40% +Link: mqtt-packet-parser + +Achievements +Winner of an Irish public competition - Won first place in a public competition with a perfect score of 70/70, securing a Software Developer position at University Hospital Galway +Galway Merit Scholarship - Awarded annually from 2018 to 2020 in recognition of academic excellence and contribution +GitHub Recognition - Gained over 3000 stars on GitHub with JobBot project +Certifications +C1 + +Languages +English - Native +Spanish - Professional +Interests +Full-Stack Development, Software Architecture, IoT system design and development, Artificial Intelligence, Cloud Technologies + diff --git a/assets/resume_schema.yaml b/assets/resume_schema.yaml new file mode 100644 index 0000000..9a86f2f --- /dev/null +++ b/assets/resume_schema.yaml @@ -0,0 +1,132 @@ +# YAML Schema for plain_text_resume.yaml + +personal_information: + type: object + properties: + name: {type: string} + surname: {type: string} + date_of_birth: {type: string, format: date} + country: {type: string} + city: {type: string} + address: {type: string} + phone_prefix: {type: string, format: phone_prefix} + phone: {type: string, format: phone} + email: {type: string, format: email} + github: {type: string, format: uri} + linkedin: {type: string, format: uri} + required: [name, surname, date_of_birth, country, city, address, phone_prefix, phone, email] + +education_details: + type: array + items: + type: object + properties: + degree: {type: string} + university: {type: string} + gpa: {type: string} + graduation_year: {type: string} + field_of_study: {type: string} + exam: + type: object + additionalProperties: {type: string} + required: [degree, university, gpa, graduation_year, field_of_study] + +experience_details: + type: array + items: + type: object + properties: + position: {type: string} + company: {type: string} + employment_period: {type: string} + location: {type: string} + industry: {type: string} + key_responsibilities: + type: object + additionalProperties: {type: string} + skills_acquired: + type: array + items: {type: string} + required: [position, company, employment_period, location, industry, key_responsibilities, skills_acquired] + +projects: + type: array + items: + type: object + properties: + name: {type: string} + description: {type: string} + link: {type: string, format: uri} + required: [name, description] + +achievements: + type: array + items: + type: object + properties: + name: {type: string} + description: {type: string} + required: [name, description] + +certifications: + type: array + items: {type: string} + +languages: + type: array + items: + type: object + properties: + language: {type: string} + proficiency: {type: string, enum: [Native, Fluent, Intermediate, Beginner]} + required: [language, proficiency] + +interests: + type: array + items: {type: string} + +availability: + type: object + properties: + notice_period: {type: string} + required: [notice_period] + +salary_expectations: + type: object + properties: + salary_range_usd: {type: string} + required: [salary_range_usd] + +self_identification: + type: object + properties: + gender: {type: string} + pronouns: {type: string} + veteran: {type: string, enum: [Yes, No]} + disability: {type: string, enum: [Yes, No]} + ethnicity: {type: string} + required: [gender, pronouns, veteran, disability, ethnicity] + +legal_authorization: + type: object + properties: + eu_work_authorization: {type: string, enum: [Yes, No]} + us_work_authorization: {type: string, enum: [Yes, No]} + requires_us_visa: {type: string, enum: [Yes, No]} + requires_us_sponsorship: {type: string, enum: [Yes, No]} + requires_eu_visa: {type: string, enum: [Yes, No]} + legally_allowed_to_work_in_eu: {type: string, enum: [Yes, No]} + legally_allowed_to_work_in_us: {type: string, enum: [Yes, No]} + requires_eu_sponsorship: {type: string, enum: [Yes, No]} + required: [eu_work_authorization, us_work_authorization, requires_us_visa, requires_us_sponsorship, requires_eu_visa, legally_allowed_to_work_in_eu, legally_allowed_to_work_in_us, requires_eu_sponsorship] + +work_preferences: + type: object + properties: + remote_work: {type: string, enum: [Yes, No]} + in_person_work: {type: string, enum: [Yes, No]} + open_to_relocation: {type: string, enum: [Yes, No]} + willing_to_complete_assessments: {type: string, enum: [Yes, No]} + willing_to_undergo_drug_tests: {type: string, enum: [Yes, No]} + willing_to_undergo_background_checks: {type: string, enum: [Yes, No]} + required: [remote_work, in_person_work, open_to_relocation, willing_to_complete_assessments, willing_to_undergo_drug_tests, willing_to_undergo_background_checks] \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index f74a689c00362ab6724bec113a729791b031cce0..c473e7251426a7a8eb528daafe8dadc3b19202cf 100644 GIT binary patch delta 48 zcmbQodW?0$JSGiZ1}=syhGK?%hCCpd%#gv5%8<*D$Y2YECJcHEMhwP4(trT~2HgnA delta 7 OcmX@cI*)b3JSG4P2LitU diff --git a/resume_yaml_generator.py b/resume_yaml_generator.py new file mode 100644 index 0000000..00e6922 --- /dev/null +++ b/resume_yaml_generator.py @@ -0,0 +1,164 @@ +import argparse +import yaml +from openai import OpenAI +import os +from typing import Dict, Any +import tiktoken +import re +from jsonschema import validate, ValidationError + +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) + api_key = secrets.get('openai_api_key') + if not api_key: + raise ValueError("OpenAI API key not found in secrets.yaml") + + return api_key + +def num_tokens_from_string(string: str, model: str) -> int: + encoding = tiktoken.encoding_for_model(model) + return len(encoding.encode(string)) + +def generate_yaml_from_resume(resume_text: str, schema: Dict[str, Any], api_key: str) -> str: + client = OpenAI(api_key=api_key) + + prompt = f""" + I'm sending you the content of a text-based resume. Your task is to interpret this content and generate a YAML file that conforms to the following schema structure. + The generated YAML should include all required fields and follow the structure defined in the schema. + + Pay special attention to the property attributes in the schema. These indicate the expected type and format for each field: + - 'type': Specifies the data type (e.g., string, object, array) + - 'format': Indicates a specific format for certain fields: + - 'date' format should be a valid date (e.g., YYYY-MM-DD) + - 'phone_prefix' format should be a valid country code with a '+' prefix (e.g., +1 for US) + - 'phone' format should be a valid phone number + - 'email' format should be a valid email address + - 'uri' format should be a valid URL + - 'enum': Provides a list of allowed values for a field + + Important instructions: + 1. Ensure that the YAML structure matches exactly with the provided schema. Use a dictionary structure that mirrors the schema. + 2. For all sections, if information is not explicitly provided in the resume, make a best guess based on the context of the resume. This is CRUCIAL for the following fields: + - languages: Infer from the resume content or make an educated guess. Use the 'enum' values for proficiency. + - interests: Deduce from the overall resume or related experiences. + - availability (notice_period): Provide a reasonable estimate (e.g., "2 weeks" or "1 month"). + - salary_expectations (salary_range_usd): Estimate based on experience level and industry standards. + - self_identification: Make reasonable assumptions based on the resume context. Use 'enum' values where provided. + - legal_authorization: Provide plausible values based on the resume information. Use 'Yes' or 'No' as per the 'enum' values. + - work_preferences: Infer from job history, skills, and overall resume tone. Use 'Yes' or 'No' as per the 'enum' values. + 3. For the fields mentioned in point 2, always provide a value. Do not leave them blank or omit them. + 4. For the 'key_responsibilities' field in 'experience_details', format the responsibilities as follows: + responsibility_1: "Description of first responsibility" + responsibility_2: "Description of second responsibility" + responsibility_3: "Description of third responsibility" + responsibility_4: "Description of fourth responsibility" + Continue this pattern for all responsibilities listed. + 5. In the 'experience_details' section, ensure that 'position' comes before 'company' in each entry. + 6. For the 'skills_acquired' field in 'experience_details', infer relevant skills based on the job responsibilities and industry. Do not leave this field empty. + 7. Make reasonable inferences for any missing dates, such as date_of_birth or employment dates, ensuring they follow the 'date' format. + 8. For array types (e.g., education_details, experience_details), ensure to include all required fields for each item as specified in the schema. + + Resume Text Content: + {resume_text} + + YAML Schema: + {yaml.dump(schema, default_flow_style=False)} + + Generate the YAML content that matches this schema based on the resume content provided, ensuring all format hints are followed and making educated guesses where necessary. Be sure to include best guesses for ALL fields, even if not explicitly mentioned in the resume. + Enclose your response in tags. Only include the YAML content within these tags, without any additional text or code block markers. + """ + + model = "gpt-3.5-turbo-16k" # This model has a 16k token limit + tokens = num_tokens_from_string(prompt, model) + max_tokens = min(16385 - tokens, 4000) # Ensure we don't exceed model's limit + + if tokens > 16385: + print(f"Warning: The input exceeds the model's context length. Tokens: {tokens}") + + response = client.chat.completions.create( + model=model, + 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": "user", "content": prompt} + ], + max_tokens=max_tokens, + n=1, + stop=None, + 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: + return match.group(1).strip() + 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) + validate(instance=yaml_dict, schema=schema) + return {"valid": True, "errors": None} + 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" + if validation_result["valid"]: + report += "YAML is valid and conforms to the schema.\n" + else: + report += "YAML is not valid. Errors:\n" + report += validation_result["errors"] + "\n" + + print(report) + with open(f"{output_file}_validation_report.txt", 'w') as file: + file.write(report) + +def main(): + parser = argparse.ArgumentParser(description="Generate a resume YAML file from a text resume using OpenAI API") + parser.add_argument("resume_file", help="Path to the input text resume file") + parser.add_argument("schema_file", help="Path to the YAML schema file") + parser.add_argument("output_file", help="Path to the output YAML file") + args = parser.parse_args() + + try: + api_key = get_api_key() + schema = load_yaml(args.schema_file) + resume_text = load_resume_text(args.resume_file) + + generated_yaml = generate_yaml_from_resume(resume_text, schema, api_key) + save_yaml(generated_yaml, args.output_file) + + print(f"Resume YAML generated and saved to {args.output_file}") + + validation_result = validate_yaml(generated_yaml, schema) + generate_report(validation_result, args.output_file) + + except FileNotFoundError as e: + print(f"Error: {e}") + except ValueError as e: + print(f"Error: {e}") + except Exception as e: + print(f"An unexpected error occurred: {e}") + +if __name__ == "__main__": + main() \ No newline at end of file From 93691913f99d2b432f1a733b6ee8975a29ddaa34 Mon Sep 17 00:00:00 2001 From: Maurice McCabe Date: Sat, 31 Aug 2024 15:41:08 -0700 Subject: [PATCH 12/97] update cmdline utility to match README --- {assets => data_folder_example}/resume_liam_murphy.txt | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename {assets => data_folder_example}/resume_liam_murphy.txt (100%) diff --git a/assets/resume_liam_murphy.txt b/data_folder_example/resume_liam_murphy.txt similarity index 100% rename from assets/resume_liam_murphy.txt rename to data_folder_example/resume_liam_murphy.txt From 67543499ab881909d3f5496d6c6404235953fdcc Mon Sep 17 00:00:00 2001 From: Maurice McCabe Date: Sat, 31 Aug 2024 15:41:33 -0700 Subject: [PATCH 13/97] update cmdline utility to match README --- .gitignore | 2 -- resume_yaml_generator.py | 27 ++++++++++++--------------- 2 files changed, 12 insertions(+), 17 deletions(-) diff --git a/.gitignore b/.gitignore index 1e8e081..4e73720 100644 --- a/.gitignore +++ b/.gitignore @@ -11,5 +11,3 @@ generated_cv* .vscode chrome_profile answers.json -resume.yaml -resume.yaml_validation_report.txt diff --git a/resume_yaml_generator.py b/resume_yaml_generator.py index 00e6922..dcc6607 100644 --- a/resume_yaml_generator.py +++ b/resume_yaml_generator.py @@ -130,35 +130,32 @@ def generate_report(validation_result: Dict[str, Any], output_file: str): report += validation_result["errors"] + "\n" print(report) - with open(f"{output_file}_validation_report.txt", 'w') as file: - file.write(report) def main(): parser = argparse.ArgumentParser(description="Generate a resume YAML file from a text resume using OpenAI API") - parser.add_argument("resume_file", help="Path to the input text resume file") - parser.add_argument("schema_file", help="Path to the YAML schema file") - parser.add_argument("output_file", help="Path to the output YAML file") + parser.add_argument("--input", required=True, help="Path to the input text resume file") + parser.add_argument("--output", default="data_folder/plain_text_resume.yaml", help="Path to the output YAML file") args = parser.parse_args() try: api_key = get_api_key() - schema = load_yaml(args.schema_file) - resume_text = load_resume_text(args.resume_file) + schema = load_yaml("assets/resume_schema.yaml") + resume_text = load_resume_text(args.input) generated_yaml = generate_yaml_from_resume(resume_text, schema, api_key) - save_yaml(generated_yaml, args.output_file) + save_yaml(generated_yaml, args.output) - print(f"Resume YAML generated and saved to {args.output_file}") + print(f"Resume YAML generated and saved to {args.output}") validation_result = validate_yaml(generated_yaml, schema) - generate_report(validation_result, args.output_file) + if validation_result["valid"]: + print("YAML is valid and conforms to the schema.") + else: + print("YAML is not valid. Errors:") + print(validation_result["errors"]) - except FileNotFoundError as e: - print(f"Error: {e}") - except ValueError as e: - print(f"Error: {e}") except Exception as e: - print(f"An unexpected error occurred: {e}") + print(f"An error occurred: {e}") if __name__ == "__main__": main() \ No newline at end of file From 369c23791da7ffeb789c66f1e54a3fce9bcf28e2 Mon Sep 17 00:00:00 2001 From: Maurice McCabe Date: Sat, 31 Aug 2024 15:58:55 -0700 Subject: [PATCH 14/97] use gpt-4o-mini --- resume_yaml_generator.py | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/resume_yaml_generator.py b/resume_yaml_generator.py index dcc6607..0252caa 100644 --- a/resume_yaml_generator.py +++ b/resume_yaml_generator.py @@ -79,23 +79,13 @@ def generate_yaml_from_resume(resume_text: str, schema: Dict[str, Any], api_key: Generate the YAML content that matches this schema based on the resume content provided, ensuring all format hints are followed and making educated guesses where necessary. Be sure to include best guesses for ALL fields, even if not explicitly mentioned in the resume. Enclose your response in tags. Only include the YAML content within these tags, without any additional text or code block markers. """ - - model = "gpt-3.5-turbo-16k" # This model has a 16k token limit - tokens = num_tokens_from_string(prompt, model) - max_tokens = min(16385 - tokens, 4000) # Ensure we don't exceed model's limit - - if tokens > 16385: - print(f"Warning: The input exceeds the model's context length. Tokens: {tokens}") response = client.chat.completions.create( - model=model, + 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": "user", "content": prompt} ], - max_tokens=max_tokens, - n=1, - stop=None, temperature=0.5, ) @@ -106,8 +96,7 @@ def generate_yaml_from_resume(resume_text: str, schema: Dict[str, Any], api_key: if match: return match.group(1).strip() else: - raise ValueError("YAML content not found in the expected format") - + 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) From a9d9e13474b29b05e9f2c537b3dd34b091bb33c5 Mon Sep 17 00:00:00 2001 From: user Date: Sun, 1 Sep 2024 11:59:43 +0200 Subject: [PATCH 15/97] Adapter class added --- README.md | 3 +- data_folder/config.yaml | 8 ++--- src/gpt.py | 79 +++++++++++++++++++++++++++++++++-------- 3 files changed, 70 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index b396b3f..fc4f060 100644 --- a/README.md +++ b/README.md @@ -211,11 +211,12 @@ This file defines your job search parameters and bot behavior. Each section cont - Marketing ``` - `llm_model_type`: - - Choose the model type, supported: openai / ollama + - Choose the model type, supported: openai / ollama / claude - `llm_model`: - Choose the LLM model, currently supported: - openai: gpt-4o - ollama: llama2, mistral:v0.3 + - claude: any model - `llm_api_url`: - Link of the API endpoint for the LLM model diff --git a/data_folder/config.yaml b/data_folder/config.yaml index e28e58a..60647c0 100644 --- a/data_folder/config.yaml +++ b/data_folder/config.yaml @@ -40,7 +40,7 @@ companyBlacklist: titleBlacklist: - word1 - word2 - -llm_model_type: [openai / ollama] -llm_model: ['gpt-4o' / 'mistral:v0.3'] -llm_api_url: [https://api.pawan.krd/cosmosrp/v1', http://127.0.0.1:11434/] \ No newline at end of file + +llm_model_type: [openai / ollama / claude] +llm_model: ['gpt-4o' / 'mistral:v0.3' / anymodel] +llm_api_url: [https://api.pawan.krd/cosmosrp/v1' / http://127.0.0.1:11434/] \ No newline at end of file diff --git a/src/gpt.py b/src/gpt.py index bc71e8b..b4b66bc 100644 --- a/src/gpt.py +++ b/src/gpt.py @@ -3,6 +3,7 @@ import os import re import textwrap from datetime import datetime +from abc import ABC, abstractmethod from typing import Dict, List, Union from pathlib import Path from dotenv import load_dotenv @@ -11,17 +12,75 @@ from langchain_core.output_parsers import StrOutputParser from langchain_core.prompt_values import StringPromptValue from langchain_core.prompts import ChatPromptTemplate from langchain_openai import ChatOpenAI -from langchain_ollama import ChatOllama from Levenshtein import distance import src.strings as strings load_dotenv() +class AIModel(ABC): + @abstractmethod + def generate_response(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 generate_response(self, prompt: str) -> str: + response = self.model.invoke(prompt) + return response.content + +class ClaudeModel(AIModel): + def __init__(self, api_key: str, llm_model: str, llm_api_url: str): + from anthropic import Anthropic + self.client = Anthropic(api_key=api_key) + + def generate_response(self, prompt: str) -> str: + formatted_prompt = f"\n\nHuman: {prompt}\n\nAssistant:" + response = self.client.completions.create( + model="claude-2", + prompt=formatted_prompt, + max_tokens_to_sample=300 + ) + return response.completion.strip() + +class OllamaModel(AIModel): + def __init__(self, api_key: str, llm_model: str, llm_api_url: str): + from langchain_ollama import ChatOllama + self.model = ChatOllama(model=llm_model, base_url=llm_api_url) + + def generate_response(self, prompt: str) -> str: + response = self.model.invoke(prompt) + 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: + llm_model_type = config['llm_model_type'] + llm_model = config['llm_model'] + llm_api_url = config['llm_api_url'] + print('Using {0} with {1} from {2}'.format(llm_model_type, llm_model, llm_api_url)) + + if llm_model_type == "openai": + return OpenAIModel(api_key, llm_model, llm_api_url) + elif llm_model_type == "claude": + return ClaudeModel(api_key, llm_model, llm_api_url) + elif llm_model_type == "ollama": + return OllamaModel(api_key, llm_model, llm_api_url) + else: + raise ValueError(f"Unsupported model type: {model_type}") + + def generate_response(self, prompt: str) -> str: + return self.model.generate_response(prompt) class LLMLogger: - def __init__(self, llm: Union[ChatOpenAI, ChatOllama]): + def __init__(self, llm: Union[OpenAIModel, OllamaModel, ClaudeModel]): self.llm = llm @staticmethod @@ -79,7 +138,7 @@ class LLMLogger: class LoggerChatModel: - def __init__(self, llm: Union[ChatOpenAI, ChatOllama]): + def __init__(self, llm: Union[OpenAIModel, OllamaModel, ClaudeModel]): self.llm = llm def __call__(self, messages: List[Dict[str, str]]) -> str: @@ -115,18 +174,8 @@ class LoggerChatModel: class GPTAnswerer: def __init__(self, config, llm_api_key): - llm_model_type = config['llm_model_type'] - llm_model = config['llm_model'] - llm_api_url = config['llm_api_url'] - - print('Using {0} with {1} from {2}'.format(llm_model_type, llm_model, llm_api_url)) - - if llm_model_type == "ollama": - self.llm_model = ChatOllama(model=llm_model, temperature = 0.4, base_url=llm_api_url) - elif llm_model_type == "openai": - self.llm_model = ChatOpenAI(model_name=llm_model, openai_api_key=llm_api_key, temperature=0.4, - base_url=llm_api_url) - self.llm_cheap = LoggerChatModel(self.llm_model) + self.ai_adapter = AIAdapter(config, llm_api_key) + self.llm_cheap = LoggerChatModel(self.ai_adapter) @property def job_description(self): return self.job.description From 6b4ebe1bd0546771b2e105788ab6a9f759377633 Mon Sep 17 00:00:00 2001 From: user Date: Sun, 1 Sep 2024 12:01:39 +0200 Subject: [PATCH 16/97] config.yaml corrected --- data_folder/config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data_folder/config.yaml b/data_folder/config.yaml index 60647c0..f38ce82 100644 --- a/data_folder/config.yaml +++ b/data_folder/config.yaml @@ -42,5 +42,5 @@ titleBlacklist: - word2 llm_model_type: [openai / ollama / claude] -llm_model: ['gpt-4o' / 'mistral:v0.3' / anymodel] +llm_model: [gpt-4o / mistral:v0.3 / anymodel] llm_api_url: [https://api.pawan.krd/cosmosrp/v1' / http://127.0.0.1:11434/] \ No newline at end of file From 9ed0c3ea6385d16177703fb3fe9ed45d9fb5cc5d Mon Sep 17 00:00:00 2001 From: Earl Perry Date: Sun, 1 Sep 2024 09:01:52 -0400 Subject: [PATCH 17/97] Instead of adding a documentation section, improved trouble shooting guide, with current issues, solutions, and addtional resources --- README.md | 85 ++++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 75 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 15fe7b8..adb4eb1 100644 --- a/README.md +++ b/README.md @@ -501,19 +501,84 @@ Using this folder as a guide can be particularly helpful for: python main.py --resume /path/to/your/resume.pdf ``` -## Documentation -TODO ): +### Troubleshooting Common Issues -## Troubleshooting +#### 1. OpenAI API Rate Limit Errors + +**Error Message:** + +openai.RateLimitError: Error code: 429 - {'error': {'message': 'You exceeded your current quota, please check your plan and billing details. For more information on this error, read the docs: https://platform.openai.com/docs/guides/error-codes/api-errors.', 'type': 'insufficient_quota', 'param': None, 'code': 'insufficient_quota'}} + +**Solution:** +- Check your OpenAI API billing settings at https://platform.openai.com/account/billing +- Ensure you have added a valid payment method to your OpenAI account +- Note that ChatGPT Plus subscription is different from API access +- If you've recently added funds or upgraded, wait 12-24 hours for changes to take effect +- Free tier has a 3 RPM limit; spend at least $5 on API usage to increase + +#### 2. LinkedIn Easy Apply Button Not Found + +**Error Message:** + +Exception: No clickable 'Easy Apply' button found + +**Solution:** +- Ensure that you're logged into LinkedIn properly +- Check if the job listings you're targeting actually have the "Easy Apply" option +- Verify that your search parameters in the `config.yaml` file are correct and returning jobs with the "Easy Apply" button +- Try increasing the wait time for page loading in the script to ensure all elements are loaded before searching for the button + +#### 3. Incorrect Information in Job Applications + +**Issue:** Bot provides inaccurate data for experience, CTC, and notice period + +**Solution:** +- Update prompts for professional experience specificity +- Add fields in `config.yaml` for current CTC, expected CTC, and notice period +- Modify bot logic to use these new config fields + +#### 4. YAML Configuration Errors + +**Error Message:** + +yaml.scanner.ScannerError: while scanning a simple key + +**Solution:** +- Copy example `config.yaml` and modify gradually +- Ensure proper YAML indentation and spacing +- Use a YAML validator tool +- Avoid unnecessary special characters or quotes + +#### 5. Bot Logs In But Doesn't Apply to Jobs + +**Issue:** Bot searches for jobs but continues scrolling without applying + +**Solution:** +- Check for security checks or CAPTCHAs +- Verify `config.yaml` job search parameters +- Ensure your LinkedIn profile meets job requirements +- Review console output for error messages + +### General Troubleshooting Tips + +- Use the latest version of the script +- Verify all dependencies are installed and updated +- Check internet connection stability +- Use VPNs cautiously to avoid triggering LinkedIn security +- Clear browser cache and cookies if issues persist + +For further assistance, please create an issue on the [GitHub repository](https://github.com/feder-cr/LinkedIn_AIHawk_automatic_job_application/issues) with detailed information about your problem, including error messages and your configuration (with sensitive information removed). + +### Additional Resources + +- [Video Tutorial: How to set up LinkedIn_AIHawk](https://youtu.be/gdW9wogHEUM) +- [OpenAI API Documentation](https://platform.openai.com/docs/) +- [LinkedIn Developer Documentation](https://developer.linkedin.com/) +- [Lang Chain Developer Documentation](https://python.langchain.com/v0.2/docs/integrations/components/) + +Remember to always use LinkedIn_AIHawk responsibly and in compliance with LinkedIn's terms of service. -- **Carefully read logs and output :** Most of the errors are verbosely reflected just watch the output and try to find the root couse. -- **If nothing works by unknown reason:** Use tested OS. Reboot and/or update OS. Use new clean venv. Try update Python to the tested version. -- **ChromeDriver Issues:** Ensure ChromeDriver is compatible with your installed Chrome version. -- **Missing Files:** Verify that all necessary files are present in the data folder. -- **Invalid YAML:** Check your YAML files for syntax errors . Try to use external YAML validators e.g. https://www.yamllint.com/ -- **OpenAI endpoint isues**: Try to check possible limits\blocking at their side - If you encounter any issues, you can open an issue on [GitHub](https://github.com/feder-cr/linkedIn_auto_jobs_applier_with_AI/issues). Please add valuable details to the subject and to the description. If you need new feature then please reflect this. I'll be more than happy to assist you! From 777d1fa4a6cd94ecf6805c671eacda85bec1d28f Mon Sep 17 00:00:00 2001 From: user Date: Sun, 1 Sep 2024 19:33:42 +0200 Subject: [PATCH 18/97] Claude support with langchain antrhopic --- data_folder/config.yaml | 4 ++-- requirements.txt | Bin 674 -> 766 bytes src/gpt.py | 31 ++++++++++++++----------------- 3 files changed, 16 insertions(+), 19 deletions(-) diff --git a/data_folder/config.yaml b/data_folder/config.yaml index f38ce82..d26a6cc 100644 --- a/data_folder/config.yaml +++ b/data_folder/config.yaml @@ -42,5 +42,5 @@ titleBlacklist: - word2 llm_model_type: [openai / ollama / claude] -llm_model: [gpt-4o / mistral:v0.3 / anymodel] -llm_api_url: [https://api.pawan.krd/cosmosrp/v1' / http://127.0.0.1:11434/] \ No newline at end of file +llm_model: [gpt-4o / mistral:v0.3 / claude-3-5-sonnet-20240620] +llm_api_url: [https://api.pawan.krd/cosmosrp/v1', http://127.0.0.1:11434/, https://api.anthropic.com/v1/messages] \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index aef4baed9ae147969488a70f26fd5aae9edacb36..0edf5dfd3cbcff11444a30bf3b3f85ae1798dc54 100644 GIT binary patch delta 57 zcmZ3)`j2(OEy;X_93V_&$OU3sAT(gmV=x3_XG3844IO8Im_1 He8>m@q~8vv delta 11 Scmeyzx`=hct<7ePCl~=A_youR diff --git a/src/gpt.py b/src/gpt.py index b4b66bc..22c7d6c 100644 --- a/src/gpt.py +++ b/src/gpt.py @@ -20,7 +20,7 @@ load_dotenv() class AIModel(ABC): @abstractmethod - def generate_response(self, prompt: str) -> str: + def invoke(self, prompt: str) -> str: pass class OpenAIModel(AIModel): @@ -29,32 +29,29 @@ class OpenAIModel(AIModel): self.model = ChatOpenAI(model_name=llm_model, openai_api_key=api_key, temperature=0.4, base_url=llm_api_url) - def generate_response(self, prompt: str) -> str: + def invoke(self, prompt: str) -> str: + print("invoke in openai") response = self.model.invoke(prompt) - return response.content + return response class ClaudeModel(AIModel): def __init__(self, api_key: str, llm_model: str, llm_api_url: str): - from anthropic import Anthropic - self.client = Anthropic(api_key=api_key) + from langchain_anthropic import ChatAnthropic + self.model = ChatAnthropic(model=llm_model, api_key=api_key, + temperature=0.4, base_url=llm_api_url) - def generate_response(self, prompt: str) -> str: - formatted_prompt = f"\n\nHuman: {prompt}\n\nAssistant:" - response = self.client.completions.create( - model="claude-2", - prompt=formatted_prompt, - max_tokens_to_sample=300 - ) - return response.completion.strip() + def invoke(self, prompt: str) -> str: + response = self.model.invoke(prompt) + return response class OllamaModel(AIModel): def __init__(self, api_key: str, llm_model: str, llm_api_url: str): from langchain_ollama import ChatOllama self.model = ChatOllama(model=llm_model, base_url=llm_api_url) - def generate_response(self, prompt: str) -> str: + 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): @@ -75,8 +72,8 @@ class AIAdapter: else: raise ValueError(f"Unsupported model type: {model_type}") - def generate_response(self, prompt: str) -> str: - return self.model.generate_response(prompt) + def invoke(self, prompt: str) -> str: + return self.model.invoke(prompt) class LLMLogger: From bb4e0ed8a6fd841083fd6dcdff48c5ffbd0708fa Mon Sep 17 00:00:00 2001 From: anton6tak Date: Sun, 1 Sep 2024 10:49:36 -0700 Subject: [PATCH 19/97] add information about request limit errors and account type issues --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 15fe7b8..1373357 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,11 @@ This file contains sensitive information. Never share or commit this file to ver - 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). + - According to the [OpenAI community](https://community.openai.com/t/usage-tier-free-to-tier-1/919150) and our users' reports, right after setting up the OpenAI account and purchasing the required credits, users still have a `Free` account type. This prevents them from having unlimited access to OpenAI models and allows only 200 requests per day. This might cause runtime errors such as: + `Error code: 429 - {'error': {'message': 'You exceeded your current quota, please check your plan and billing details. ...}}` + `{'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). From 28b8fa37469fab8b09b454911f967c8e5be0a825 Mon Sep 17 00:00:00 2001 From: user Date: Sun, 1 Sep 2024 21:15:03 +0200 Subject: [PATCH 20/97] README updated --- README.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index fc4f060..cba2849 100644 --- a/README.md +++ b/README.md @@ -210,7 +210,9 @@ This file defines your job search parameters and bot behavior. Each section cont - Sales - Marketing ``` -- `llm_model_type`: +#### 2.1 config.yaml - Customize LLM model endpoint + +- `llm_model_type`: - Choose the model type, supported: openai / ollama / claude - `llm_model`: - Choose the LLM model, currently supported: @@ -219,7 +221,11 @@ This file defines your job search parameters and bot behavior. Each section cont - claude: 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 + - Note: To run local Ollama, follow the guidelines here: [Guide to Ollama deployment](https://github.com/ollama/ollama) + ### 3. plain_text_resume.yaml This file contains your resume information in a structured format. Fill it out with your personal details, education, work experience, and skills. This information is used to auto-fill application forms and generate customized resumes. From d1d9e9f6a3eedbe4a0399f632559a4a332b740e7 Mon Sep 17 00:00:00 2001 From: user Date: Sun, 1 Sep 2024 21:16:02 +0200 Subject: [PATCH 21/97] config yaml file with default openai gpt freely hosted --- data_folder/config.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/data_folder/config.yaml b/data_folder/config.yaml index d26a6cc..bfbcd82 100644 --- a/data_folder/config.yaml +++ b/data_folder/config.yaml @@ -41,6 +41,6 @@ titleBlacklist: - word1 - word2 -llm_model_type: [openai / ollama / claude] -llm_model: [gpt-4o / mistral:v0.3 / claude-3-5-sonnet-20240620] -llm_api_url: [https://api.pawan.krd/cosmosrp/v1', http://127.0.0.1:11434/, https://api.anthropic.com/v1/messages] \ No newline at end of file +llm_model_type: openai +llm_model: gpt-4o +llm_api_url: https://api.pawan.krd/cosmosrp/v1 \ No newline at end of file From c0f186ba844f7b12ea515675709c4977bc817e32 Mon Sep 17 00:00:00 2001 From: Manu Altieri Date: Sun, 1 Sep 2024 22:48:01 +0200 Subject: [PATCH 22/97] Fixed easy apply search, implemented method to extract required fields from API with new endpoint, Todo; parse result json to return a dict that contains title, optionUrn and possible choices for the value if present --- src/linkedin-api.py | 76 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 73 insertions(+), 3 deletions(-) diff --git a/src/linkedin-api.py b/src/linkedin-api.py index f1395f8..6bb2c12 100644 --- a/src/linkedin-api.py +++ b/src/linkedin-api.py @@ -1,12 +1,19 @@ from typing import Dict, List from linkedin_api import Linkedin from typing import Optional, Union, Literal -from urllib.parse import urlencode +from urllib.parse import quote, urlencode +import logging +import json + +# set log to all debug +logging.basicConfig(level=logging.INFO) + class LinkedInEvolvedAPI(Linkedin): def __init__(self, username, password): super().__init__(username, password) + def search_jobs( self, keywords: Optional[str] = None, @@ -106,7 +113,7 @@ class LinkedInEvolvedAPI(Linkedin): if remote: query["selectedFilters"]["workplaceType"] = f"List({','.join(remote)})" if easy_apply: - query["selectedFilters"]["easyApply"] = "List(true)" + query["selectedFilters"]["applyWithLinkedin"] = "List(true)" query["selectedFilters"]["timePostedRange"] = f"List(r{listed_at})" query["spellCorrectionEnabled"] = "true" @@ -160,9 +167,72 @@ class LinkedInEvolvedAPI(Linkedin): self.logger.debug(f"results grew to {len(results)}") return results - + def get_fields_for_easy_apply(self,job_id:str) -> List[Dict]: + """Get fields needed for easy apply jobs. + + :param job_id: Job ID + :type job_id: str + :return: Fields + :rtype: dict + """ + + cookies = self.client.session.cookies.get_dict() + cookie_str = "; ".join([f"{k}={v}" for k, v in cookies.items()]) + + headers: Dict[str, str] = self._headers() + + headers['User-Agent'] = headers['user-agent'] + headers['Accept-Language'] = headers['accept-language'] + headers["Accept"] = "application/vnd.linkedin.normalized+json+2.1" + headers["csrf-token"] = cookies["JSESSIONID"].replace('"', "") + headers["Cookie"] = cookie_str + headers["Connection"] = "keep-alive" + + headers.pop("user-agent") + headers.pop("accept-language") + default_params = { + "decorationId": "com.linkedin.voyager.dash.deco.jobs.OnsiteApplyApplication-67", + "jobPostingUrn": f"urn:li:fsd_jobPosting:{job_id}", + "q": "jobPosting", + } + + default_params = urlencode(default_params) + res = self._fetch( + f"/voyagerJobsDashOnsiteApplyApplication?{default_params}", + headers=headers, + cookies=cookies, + ) + + try: + data = res.json() + except ValueError: + self.logger.error("Failed to parse JSON response") + return [] + + form_components = [] + + for item in data.get("included", []): + if 'formComponent' in item: + title = item['title']['text'] + form_components.append({title: item['formComponent']}) + + + + + def get_cookies_hitting_url(self, url: str): + res = self._fetch(url,base_request=False) + return res.headers + +## EXAMPLE USAGE +#if __name__ == "__main__": +# api: LinkedInEvolvedAPI = LinkedInEvolvedAPI("", "") +# jobs = api.search_jobs(keywords="Python", location_name="Italy", limit=1, easy_apply=True) +# for job in jobs: +# job_id: str = job["job_id"] +# fields = api.get_fields_for_easy_apply(job_id) + \ No newline at end of file From d76a35fa7ca0d53f93035ca8ed5e2f314b5b424d Mon Sep 17 00:00:00 2001 From: Manu Altieri Date: Sun, 1 Sep 2024 22:53:05 +0200 Subject: [PATCH 23/97] Fixed easy apply search, implemented method to extract required fields from API with new endpoint, Todo; parse result json to return a dict that contains title, optionUrn and possible choices for the value if present --- src/linkedin-api.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/linkedin-api.py b/src/linkedin-api.py index 6bb2c12..8b642ce 100644 --- a/src/linkedin-api.py +++ b/src/linkedin-api.py @@ -222,10 +222,6 @@ class LinkedInEvolvedAPI(Linkedin): - def get_cookies_hitting_url(self, url: str): - res = self._fetch(url,base_request=False) - return res.headers - ## EXAMPLE USAGE #if __name__ == "__main__": # api: LinkedInEvolvedAPI = LinkedInEvolvedAPI("", "") From ff087129b09bb21635a106e639d5ffd173f1dd4e Mon Sep 17 00:00:00 2001 From: Maurice McCabe Date: Mon, 2 Sep 2024 01:06:11 -0700 Subject: [PATCH 24/97] add support for converting a pdf to txt --- requirements.txt | 3 ++- resume_yaml_generator.py | 29 ++++++++++++++++++++--------- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/requirements.txt b/requirements.txt index 7f6e144..5139ade 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,4 +12,5 @@ selenium==4.9.1 webdriver-manager==4.0.2 click git+https://github.com/feder-cr/lib_resume_builder_AIHawk.git -linkedin-api \ No newline at end of file +linkedin-api +PyPDF2==3.0.1 \ No newline at end of file diff --git a/resume_yaml_generator.py b/resume_yaml_generator.py index 0252caa..acf36f5 100644 --- a/resume_yaml_generator.py +++ b/resume_yaml_generator.py @@ -3,9 +3,9 @@ import yaml from openai import OpenAI import os from typing import Dict, Any -import tiktoken import re from jsonschema import validate, ValidationError +import PyPDF2 def load_yaml(file_path: str) -> Dict[str, Any]: with open(file_path, 'r') as file: @@ -27,10 +27,6 @@ def get_api_key() -> str: return api_key -def num_tokens_from_string(string: str, model: str) -> int: - encoding = tiktoken.encoding_for_model(model) - return len(encoding.encode(string)) - def generate_yaml_from_resume(resume_text: str, schema: Dict[str, Any], api_key: str) -> str: client = OpenAI(api_key=api_key) @@ -96,7 +92,8 @@ def generate_yaml_from_resume(resume_text: str, schema: Dict[str, Any], api_key: if match: return match.group(1).strip() else: - raise ValueError("YAML content not found in the expected format") + 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) @@ -120,16 +117,30 @@ def generate_report(validation_result: Dict[str, Any], output_file: str): print(report) +def pdf_to_text(pdf_path: str) -> str: + text = "" + with open(pdf_path, 'rb') as file: + reader = PyPDF2.PdfReader(file) + for page in reader.pages: + text += page.extract_text() + return text + def main(): - parser = argparse.ArgumentParser(description="Generate a resume YAML file from a text resume using OpenAI API") - parser.add_argument("--input", required=True, help="Path to the input text resume file") + 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() try: api_key = get_api_key() schema = load_yaml("assets/resume_schema.yaml") - resume_text = load_resume_text(args.input) + + # Check if input is PDF or TXT + if args.input.lower().endswith('.pdf'): + resume_text = pdf_to_text(args.input) + print(f"PDF resume converted to text successfully.") + else: + resume_text = load_resume_text(args.input) generated_yaml = generate_yaml_from_resume(resume_text, schema, api_key) save_yaml(generated_yaml, args.output) From 23567ee7c48618d10a5acdc14665b16321a7104f Mon Sep 17 00:00:00 2001 From: Maurice McCabe Date: Mon, 2 Sep 2024 01:25:38 -0700 Subject: [PATCH 25/97] updated for generating resume yaml from pdf --- README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 76621d7..edb4dc8 100644 --- a/README.md +++ b/README.md @@ -452,21 +452,21 @@ Each section has specific fields to fill out: willing_to_undergo_drug_tests: "No" willing_to_undergo_background_checks: "Yes" ``` -### 4. Generating plain_text_resume.yaml from a Text Resume +### 4. Generating plain_text_resume.yaml from a PDF or Text Resume -To simplify the process of creating your `plain_text_resume.yaml` file, you can use the provided script to generate it from a text-based resume. Follow these steps: +To simplify the process of creating your `plain_text_resume.yaml` file, you can use the provided script to generate it from a pdf-based or text-based resume. Follow these steps: -1. Prepare your resume in a plain text format (.txt file). +1. Prepare your resume in a pdf (.pdf file) or plain text (.txt file) format. -2. Place your text resume in the `data_folder` directory. +2. Place your resume in the `data_folder` directory. 3. Run the following command: ```bash - python generate_resume_yaml.py --input data_folder/your_resume.txt --output data_folder/plain_text_resume.yaml + python generate_resume_yaml.py --input data_folder/your_resume.[pdf|txt] --output data_folder/plain_text_resume.yaml ``` - Replace `your_resume.txt` with the actual name of your text resume file. + Replace `your_resume.[pdf|txt]` with the actual name of your pdf or text resume file. 4. The script will generate a `plain_text_resume.yaml` file in the `data_folder` directory. From 685da0f9fc71dbb7a0c869efa2f702bab1cdcc1e Mon Sep 17 00:00:00 2001 From: Maurice McCabe Date: Mon, 2 Sep 2024 01:44:54 -0700 Subject: [PATCH 26/97] replace lib PYPDF2 with pdfminer.six --- requirements.txt | 32 ++++++++++++++++---------------- resume_yaml_generator.py | 9 ++------- 2 files changed, 18 insertions(+), 23 deletions(-) diff --git a/requirements.txt b/requirements.txt index 5139ade..03290b7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,16 +1,16 @@ -langchain==0.2.11 -langchain-community==0.2.10 -langchain-core==0.2.24 -langchain-openai==0.1.17 -langchain-text-splitters==0.2.2 -langsmith==0.1.93 -Levenshtein==0.25.1 -openai==1.37.1 -regex==2024.7.24 -reportlab==4.2.2 -selenium==4.9.1 -webdriver-manager==4.0.2 -click -git+https://github.com/feder-cr/lib_resume_builder_AIHawk.git -linkedin-api -PyPDF2==3.0.1 \ No newline at end of file +langchain==0.2.11 +langchain-community==0.2.10 +langchain-core==0.2.24 +langchain-openai==0.1.17 +langchain-text-splitters==0.2.2 +langsmith==0.1.93 +Levenshtein==0.25.1 +openai==1.37.1 +regex==2024.7.24 +reportlab==4.2.2 +selenium==4.9.1 +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 diff --git a/resume_yaml_generator.py b/resume_yaml_generator.py index acf36f5..46982c2 100644 --- a/resume_yaml_generator.py +++ b/resume_yaml_generator.py @@ -5,7 +5,7 @@ import os from typing import Dict, Any import re from jsonschema import validate, ValidationError -import PyPDF2 +from pdfminer.high_level import extract_text def load_yaml(file_path: str) -> Dict[str, Any]: with open(file_path, 'r') as file: @@ -118,12 +118,7 @@ def generate_report(validation_result: Dict[str, Any], output_file: str): print(report) def pdf_to_text(pdf_path: str) -> str: - text = "" - with open(pdf_path, 'rb') as file: - reader = PyPDF2.PdfReader(file) - for page in reader.pages: - text += page.extract_text() - return text + 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") From d2372523fa876c3abbceffd9c7b371a3afb10535 Mon Sep 17 00:00:00 2001 From: Manu Altieri Date: Mon, 2 Sep 2024 11:10:18 +0200 Subject: [PATCH 27/97] fixed the parsing of the required fields for apply --- src/linkedin-api.py | 66 ++++++++++++++++++++++++++++++++++----------- 1 file changed, 50 insertions(+), 16 deletions(-) diff --git a/src/linkedin-api.py b/src/linkedin-api.py index 8b642ce..a34e381 100644 --- a/src/linkedin-api.py +++ b/src/linkedin-api.py @@ -13,7 +13,6 @@ class LinkedInEvolvedAPI(Linkedin): def __init__(self, username, password): super().__init__(username, password) - def search_jobs( self, keywords: Optional[str] = None, @@ -182,16 +181,12 @@ class LinkedInEvolvedAPI(Linkedin): headers: Dict[str, str] = self._headers() - headers['User-Agent'] = headers['user-agent'] - headers['Accept-Language'] = headers['accept-language'] + headers["Accept"] = "application/vnd.linkedin.normalized+json+2.1" headers["csrf-token"] = cookies["JSESSIONID"].replace('"', "") headers["Cookie"] = cookie_str headers["Connection"] = "keep-alive" - headers.pop("user-agent") - headers.pop("accept-language") - default_params = { "decorationId": "com.linkedin.voyager.dash.deco.jobs.OnsiteApplyApplication-67", @@ -206,6 +201,16 @@ class LinkedInEvolvedAPI(Linkedin): cookies=cookies, ) + match res.status_code: + case 200: + pass + case 409: + self.logger.error("Failed to fetch fields for easy apply job because already applied to this job!") + return [] + case _: + self.logger.error("Failed to fetch fields for easy apply job") + return [] + try: data = res.json() except ValueError: @@ -216,19 +221,48 @@ class LinkedInEvolvedAPI(Linkedin): for item in data.get("included", []): if 'formComponent' in item: - title = item['title']['text'] - form_components.append({title: item['formComponent']}) + 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'] + ] + component_info['selectableOptions'] = options + elif 'selectableOptions' in form_component_details: + options = [ + opt['textSelectableOption']['optionText']['text'] + for opt in form_component_details['selectableOptions'] + ] + component_info['selectableOptions'] = options + + form_components.append(component_info) + return form_components - - ## EXAMPLE USAGE -#if __name__ == "__main__": -# api: LinkedInEvolvedAPI = LinkedInEvolvedAPI("", "") -# jobs = api.search_jobs(keywords="Python", location_name="Italy", limit=1, easy_apply=True) -# for job in jobs: -# job_id: str = job["job_id"] -# fields = api.get_fields_for_easy_apply(job_id) +if __name__ == "__main__": + api: LinkedInEvolvedAPI = LinkedInEvolvedAPI(username="", password="") + jobs = api.search_jobs(keywords="Frontend Developer", location_name="Italia", limit=5, easy_apply=True, offset=1) + for job in jobs: + job_id: str = job["job_id"] + + fields = api.get_fields_for_easy_apply(job_id) + for field in fields: + print(field) + \ No newline at end of file From 092f4e70324561304f24bef30af2599be5185bfb Mon Sep 17 00:00:00 2001 From: Manu Altieri Date: Tue, 3 Sep 2024 00:27:54 +0200 Subject: [PATCH 28/97] Added instructions toDo; about apply method --- src/linkedin-api.py | 115 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 111 insertions(+), 4 deletions(-) diff --git a/src/linkedin-api.py b/src/linkedin-api.py index a34e381..ae37845 100644 --- a/src/linkedin-api.py +++ b/src/linkedin-api.py @@ -8,7 +8,6 @@ import json # set log to all debug logging.basicConfig(level=logging.INFO) - class LinkedInEvolvedAPI(Linkedin): def __init__(self, username, password): super().__init__(username, password) @@ -167,7 +166,7 @@ class LinkedInEvolvedAPI(Linkedin): 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 @@ -220,7 +219,7 @@ class LinkedInEvolvedAPI(Linkedin): form_components = [] for item in data.get("included", []): - if 'formComponent' in item: + if 'formComponent' in item: urn = item['urn'] try: title = item['title']['text'] @@ -251,10 +250,117 @@ class LinkedInEvolvedAPI(Linkedin): form_components.append(component_info) return form_components + + 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) + # 2. Fill the fields with the data adding a response parameter in the specific field in the dict object, for example: + # {'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'} + # Became: + # {'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": [ + # { + # "formElementUrn": "urn:li:fsd_formElement:urn:li:jobs_applyformcommon_easyApplyFormElement:(4005350454,3497278561,multipleChoice)", + # "formElementInputValues": [ + # { + # "entityInputValue": { + # "inputEntityName": "email@gmail.com" + # } + # } + # ] + # }, + # { + # "formElementUrn": "urn:li:fsd_formElement:urn:li:jobs_applyformcommon_easyApplyFormElement:(4005350454,3497278545,phoneNumber~country)", + # "formElementInputValues": [ + # { + # "entityInputValue": { + # "inputEntityName": "Italy (+39)", + # "inputEntityUrn": "urn:li:country:it" + # } + # } + # ] + # }, + # { + # "formElementUrn": "urn:li:fsd_formElement:urn:li:jobs_applyformcommon_easyApplyFormElement:(4005350454,3497278545,phoneNumber~nationalNumber)", + # "formElementInputValues": [ + # { + # "textInputValue": "3333333" + # } + # ] + # }, + # { + # "formElementUrn": "urn:li:fsd_formElement:urn:li:jobs_applyformcommon_easyApplyFormElement:(4005350454,3497278529,multipleChoice)", + # "formElementInputValues": [ + # { + # "entityInputValue": { + # "inputEntityName": "Native or bilingual" + # } + # } + # ] + # }, + # { + # "formElementUrn": "urn:li:fsd_formElement:urn:li:jobs_applyformcommon_easyApplyFormElement:(4005350454,3497278537,numeric)", + # "formElementInputValues": [ + # { + # "textInputValue": "0" + # } + # ] + # }, + # { + # "formElementUrn": "urn:li:fsd_formElement:urn:li:jobs_applyformcommon_easyApplyFormElement:(4005350454,3498546713,multipleChoice)", + # "formElementInputValues": [ + # { + # "entityInputValue": { + # "inputEntityName": "No" + # } + # } + # ] + # }, + # { + # "formElementUrn": "urn:li:fsd_formElement:urn:li:jobs_applyformcommon_easyApplyFormElement:(4005350454,3497278521,multipleChoice)", + # "formElementInputValues": [ + # { + # "entityInputValue": { + # "inputEntityName": "No" + # } + # } + # ] + # } + # ], + # "referenceId": "", + # "trackingCode": "d_flagship3_search_srp_jobs", + # "fileUploadResponses": [ + # { + # "inputUrn": "urn:li:fsd_resume:/##todo##", + # "formElementUrn": "urn:li:fsu_jobApplicationFileUploadFormElement:urn:li:jobs_applyformcommon_easyApplyFormElement:(4005350454,3497278553,document)" + # } + # ], + # "trackingId": "" + #} + + # Push the commit to the repository and create a pull request to the v3 branch. + + + + + + + ## EXAMPLE USAGE if __name__ == "__main__": - api: LinkedInEvolvedAPI = LinkedInEvolvedAPI(username="", password="") + api: LinkedInEvolvedAPI = LinkedInEvolvedAPI(username="", password="") jobs = api.search_jobs(keywords="Frontend Developer", location_name="Italia", limit=5, easy_apply=True, offset=1) for job in jobs: job_id: str = job["job_id"] @@ -262,6 +368,7 @@ if __name__ == "__main__": fields = api.get_fields_for_easy_apply(job_id) for field in fields: print(field) + break From f9b6f363573c5dc1036b26ff7a3a1ae50899b835 Mon Sep 17 00:00:00 2001 From: Syed Date: Wed, 4 Sep 2024 06:07:44 +0530 Subject: [PATCH 29/97] Add bug issue template --- .github/CONTRIBUTING.md | 0 .github/ISSUE_TEMPLATE/bug-issue.yml | 85 ++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 .github/CONTRIBUTING.md create mode 100644 .github/ISSUE_TEMPLATE/bug-issue.yml diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..e69de29 diff --git a/.github/ISSUE_TEMPLATE/bug-issue.yml b/.github/ISSUE_TEMPLATE/bug-issue.yml new file mode 100644 index 0000000..64dbd43 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-issue.yml @@ -0,0 +1,85 @@ +name: Bug report +description: Report a bug or an issue that isn't working as expected. +title: "[BUG]: " +labels: ["bug"] +assignees: "" + +body: + - type: markdown + attributes: + value: | + Please fill out the following information to help us resolve the issue. + + - type: input + id: description + attributes: + label: Describe the bug + description: A clear and concise description of what the bug is. + placeholder: "Describe the bug in detail..." + validations: + required: true + + - type: textarea + id: steps + attributes: + label: Steps to reproduce + description: | + Steps to reproduce the behavior: + 1. Go to '...' + 2. Click on '...' + 3. Scroll down to '...' + 4. See error + placeholder: "List the steps to reproduce the bug..." + validations: + required: true + + - type: input + id: expected + attributes: + label: Expected behavior + description: What you expected to happen. + placeholder: "What was the expected result?" + validations: + required: true + + - type: input + id: actual + attributes: + label: Actual behavior + description: What actually happened instead. + placeholder: "What happened instead?" + validations: + required: true + + - type: dropdown + id: environment + attributes: + label: Environment + description: Specify the environment where the bug occurred. + options: + - label: Production + value: production + - label: Development + value: development + - label: Staging + value: staging + validations: + required: true + + - type: input + id: version + attributes: + label: Version + description: Version of the application where the bug occurred. + placeholder: "e.g., 1.0.0" + validations: + required: false + + - type: textarea + id: additional + attributes: + label: Additional context + description: Add any other context about the problem here. + placeholder: "Any additional information..." + validations: + required: false From 9e88d3ba8d10014ae83e633dd04dda62e7abf200 Mon Sep 17 00:00:00 2001 From: Syed Date: Wed, 4 Sep 2024 06:22:08 +0530 Subject: [PATCH 30/97] Add config for templates --- .github/ISSUE_TEMPLATE/config.yml | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/config.yml diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..9dc9d50 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: true +contact_links: + - name: Questions + url: t.me/AIhawkCommunity + about: You can join the discussions on Telegram. + - name: New issue + url: https://github.com/feder-cr/linkedIn_auto_jobs_applier_with_AI/blob/v3/.github/CONTRIBUTING.md + about: Before opening a new issue, please make sure to read CONTRIBUTING.md From 14fff0b05110800c0fd88e4b5c8b147ac76a756b Mon Sep 17 00:00:00 2001 From: Syed Date: Wed, 4 Sep 2024 06:25:31 +0530 Subject: [PATCH 31/97] prettified yml --- .github/ISSUE_TEMPLATE/bug-issue.yml | 21 +++++++-------------- .github/ISSUE_TEMPLATE/config.yml | 5 +++-- 2 files changed, 10 insertions(+), 16 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug-issue.yml b/.github/ISSUE_TEMPLATE/bug-issue.yml index 64dbd43..1bbd001 100644 --- a/.github/ISSUE_TEMPLATE/bug-issue.yml +++ b/.github/ISSUE_TEMPLATE/bug-issue.yml @@ -1,24 +1,22 @@ name: Bug report description: Report a bug or an issue that isn't working as expected. title: "[BUG]: " -labels: ["bug"] +labels: + - bug assignees: "" - body: - type: markdown attributes: value: | Please fill out the following information to help us resolve the issue. - - type: input id: description attributes: label: Describe the bug description: A clear and concise description of what the bug is. - placeholder: "Describe the bug in detail..." + placeholder: Describe the bug in detail... validations: required: true - - type: textarea id: steps attributes: @@ -29,28 +27,25 @@ body: 2. Click on '...' 3. Scroll down to '...' 4. See error - placeholder: "List the steps to reproduce the bug..." + placeholder: List the steps to reproduce the bug... validations: required: true - - type: input id: expected attributes: label: Expected behavior description: What you expected to happen. - placeholder: "What was the expected result?" + placeholder: What was the expected result? validations: required: true - - type: input id: actual attributes: label: Actual behavior description: What actually happened instead. - placeholder: "What happened instead?" + placeholder: What happened instead? validations: required: true - - type: dropdown id: environment attributes: @@ -65,7 +60,6 @@ body: value: staging validations: required: true - - type: input id: version attributes: @@ -74,12 +68,11 @@ body: placeholder: "e.g., 1.0.0" validations: required: false - - type: textarea id: additional attributes: label: Additional context description: Add any other context about the problem here. - placeholder: "Any additional information..." + placeholder: Any additional information... validations: required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 9dc9d50..bc3f586 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -4,5 +4,6 @@ contact_links: url: t.me/AIhawkCommunity about: You can join the discussions on Telegram. - name: New issue - url: https://github.com/feder-cr/linkedIn_auto_jobs_applier_with_AI/blob/v3/.github/CONTRIBUTING.md - about: Before opening a new issue, please make sure to read CONTRIBUTING.md + url: >- + https://github.com/feder-cr/linkedIn_auto_jobs_applier_with_AI/blob/v3/.github/CONTRIBUTING.md + about: "Before opening a new issue, please make sure to read CONTRIBUTING.md" From 059f5e93025a9afaea1897d3f44c15ec77dc1bc8 Mon Sep 17 00:00:00 2001 From: Syed Date: Wed, 4 Sep 2024 06:28:26 +0530 Subject: [PATCH 32/97] fixed erros for arrays --- .github/ISSUE_TEMPLATE/bug-issue.yml | 37 +++++++++++----------------- 1 file changed, 15 insertions(+), 22 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug-issue.yml b/.github/ISSUE_TEMPLATE/bug-issue.yml index 1bbd001..6fc75a5 100644 --- a/.github/ISSUE_TEMPLATE/bug-issue.yml +++ b/.github/ISSUE_TEMPLATE/bug-issue.yml @@ -1,22 +1,22 @@ name: Bug report description: Report a bug or an issue that isn't working as expected. title: "[BUG]: " -labels: - - bug -assignees: "" +labels: ["bug"] +assignees: [] + body: - type: markdown attributes: value: | Please fill out the following information to help us resolve the issue. + - type: input id: description attributes: label: Describe the bug description: A clear and concise description of what the bug is. - placeholder: Describe the bug in detail... - validations: - required: true + placeholder: "Describe the bug in detail..." + - type: textarea id: steps attributes: @@ -27,25 +27,22 @@ body: 2. Click on '...' 3. Scroll down to '...' 4. See error - placeholder: List the steps to reproduce the bug... - validations: - required: true + placeholder: "List the steps to reproduce the bug..." + - type: input id: expected attributes: label: Expected behavior description: What you expected to happen. - placeholder: What was the expected result? - validations: - required: true + placeholder: "What was the expected result?" + - type: input id: actual attributes: label: Actual behavior description: What actually happened instead. - placeholder: What happened instead? - validations: - required: true + placeholder: "What happened instead?" + - type: dropdown id: environment attributes: @@ -58,21 +55,17 @@ body: value: development - label: Staging value: staging - validations: - required: true + - type: input id: version attributes: label: Version description: Version of the application where the bug occurred. placeholder: "e.g., 1.0.0" - validations: - required: false + - type: textarea id: additional attributes: label: Additional context description: Add any other context about the problem here. - placeholder: Any additional information... - validations: - required: false + placeholder: "Any additional information..." From 08810adf054e57535afb2f1a78a1f0836a4ffade Mon Sep 17 00:00:00 2001 From: Syed Date: Wed, 4 Sep 2024 06:38:23 +0530 Subject: [PATCH 33/97] fixed yaml indentations --- .github/ISSUE_TEMPLATE/bug-issue.yml | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug-issue.yml b/.github/ISSUE_TEMPLATE/bug-issue.yml index 6fc75a5..a6ff696 100644 --- a/.github/ISSUE_TEMPLATE/bug-issue.yml +++ b/.github/ISSUE_TEMPLATE/bug-issue.yml @@ -49,12 +49,9 @@ body: label: Environment description: Specify the environment where the bug occurred. options: - - label: Production - value: production - - label: Development - value: development - - label: Staging - value: staging + - Production + - Development + - Staging - type: input id: version From cee47f7d70e98a7380dd4957657607d4e76dfad2 Mon Sep 17 00:00:00 2001 From: Syed Date: Wed, 4 Sep 2024 06:42:43 +0530 Subject: [PATCH 34/97] enhancement template --- .github/ISSUE_TEMPLATE/enhancement-issue.yml | 46 ++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/enhancement-issue.yml diff --git a/.github/ISSUE_TEMPLATE/enhancement-issue.yml b/.github/ISSUE_TEMPLATE/enhancement-issue.yml new file mode 100644 index 0000000..433ef84 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/enhancement-issue.yml @@ -0,0 +1,46 @@ +name: Feature request +description: Suggest a new feature or improvement for the project. +title: "[FEATURE]: " +labels: ["enhancement"] +assignees: [] + +body: + - type: markdown + attributes: + value: | + Thank you for suggesting a feature! Please fill out the form below to help us understand your idea. + + - type: input + id: summary + attributes: + label: Feature summary + description: Provide a short summary of the feature you're requesting. + placeholder: "Summarize the feature in a few words..." + + - type: textarea + id: description + attributes: + label: Feature description + description: A detailed description of the feature or improvement. + placeholder: "Describe the feature in detail..." + + - type: input + id: motivation + attributes: + label: Motivation + description: Explain why this feature would be beneficial and how it solves a problem. + placeholder: "Why do you need this feature?" + + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: List any alternative solutions or features you've considered. + placeholder: "Are there any alternative features or solutions you’ve considered?" + + - type: input + id: additional + attributes: + label: Additional context + description: Add any other context or screenshots to support your feature request. + placeholder: "Any additional information..." From 512efda803d0c358c48f33e6cff8cb2e7cb6b7c2 Mon Sep 17 00:00:00 2001 From: Syed Date: Wed, 4 Sep 2024 06:44:59 +0530 Subject: [PATCH 35/97] invalid issue template --- .github/ISSUE_TEMPLATE/invalid-issue.yml | 39 ++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/invalid-issue.yml diff --git a/.github/ISSUE_TEMPLATE/invalid-issue.yml b/.github/ISSUE_TEMPLATE/invalid-issue.yml new file mode 100644 index 0000000..cc4f27f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/invalid-issue.yml @@ -0,0 +1,39 @@ +name: Invalid issue report +description: Report an issue that doesn't seem correct or is invalid. +title: "[INVALID]: " +labels: ["invalid"] +assignees: [] + +body: + - type: markdown + attributes: + value: | + If you've identified an issue that seems incorrect or should not exist, please fill out the form below to provide more details. + + - type: input + id: reason + attributes: + label: Reason for invalidation + description: Briefly explain why this issue is considered invalid or incorrect. + placeholder: "Why do you think this issue is invalid?" + + - type: textarea + id: steps + attributes: + label: Steps to validate + description: Provide steps or evidence that confirm the issue is invalid. + placeholder: "Explain how you verified this issue is not valid..." + + - type: input + id: original_issue + attributes: + label: Related issue (if applicable) + description: Provide a link to the original issue if this is related to an existing one. + placeholder: "Link to the related issue (if applicable)" + + - type: input + id: additional + attributes: + label: Additional context + description: Any additional information you think is necessary. + placeholder: "Add any other context here..." From db69b0f1bfc97f0e8fc628a69cc3cd30cbaa8707 Mon Sep 17 00:00:00 2001 From: Syed Date: Wed, 4 Sep 2024 06:46:44 +0530 Subject: [PATCH 36/97] documentation issue template --- .../ISSUE_TEMPLATE/documentation-issue.yml | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/documentation-issue.yml diff --git a/.github/ISSUE_TEMPLATE/documentation-issue.yml b/.github/ISSUE_TEMPLATE/documentation-issue.yml new file mode 100644 index 0000000..14f63a4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/documentation-issue.yml @@ -0,0 +1,39 @@ +name: Documentation request +description: Suggest improvements or additions to the project's documentation. +title: "[DOCS]: " +labels: ["documentation"] +assignees: [] + +body: + - type: markdown + attributes: + value: | + Thanks for helping to improve the project's documentation! Please provide the following details to ensure your request is clear. + + - type: input + id: doc_section + attributes: + label: Affected documentation section + description: Specify which part of the documentation needs improvement or addition. + placeholder: "e.g., Installation Guide, API Reference..." + + - type: textarea + id: description + attributes: + label: Documentation improvement description + description: Describe the specific improvements or additions you suggest. + placeholder: "Explain what changes you propose and why..." + + - type: input + id: reason + attributes: + label: Why is this change necessary? + description: Explain why the documentation needs to be updated or expanded. + placeholder: "Describe the issue or gap in the documentation..." + + - type: input + id: additional + attributes: + label: Additional context + description: Add any other context, such as related documentation, external resources, or screenshots. + placeholder: "Add any other supporting information..." From 6ef07a5f30f9b6caec830ef57efb2910c28798ce Mon Sep 17 00:00:00 2001 From: Syed Date: Wed, 4 Sep 2024 06:49:10 +0530 Subject: [PATCH 37/97] questions and duplicates template added --- .github/ISSUE_TEMPLATE/duplicate-issue.yml | 32 ++++++++++++++++++ .github/ISSUE_TEMPLATE/question-issue.yml | 39 ++++++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/duplicate-issue.yml create mode 100644 .github/ISSUE_TEMPLATE/question-issue.yml diff --git a/.github/ISSUE_TEMPLATE/duplicate-issue.yml b/.github/ISSUE_TEMPLATE/duplicate-issue.yml new file mode 100644 index 0000000..8057a32 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/duplicate-issue.yml @@ -0,0 +1,32 @@ +name: Duplicate issue report +description: Report an issue or pull request that already exists in the project. +title: "[DUPLICATE]: " +labels: ["duplicate"] +assignees: [] + +body: + - type: markdown + attributes: + value: | + Please provide information about the duplicate issue or pull request. + + - type: input + id: duplicate_link + attributes: + label: Link to the original issue/pull request + description: Provide the URL of the original issue or pull request that duplicates this one. + placeholder: "https://github.com/your-repo/issue/123" + + - type: input + id: reason + attributes: + label: Reason for marking as duplicate + description: Explain why this issue is considered a duplicate. + placeholder: "Briefly explain why this is a duplicate." + + - type: input + id: additional + attributes: + label: Additional context + description: Add any additional context or supporting information. + placeholder: "Any additional information or comments..." diff --git a/.github/ISSUE_TEMPLATE/question-issue.yml b/.github/ISSUE_TEMPLATE/question-issue.yml new file mode 100644 index 0000000..e2e949e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question-issue.yml @@ -0,0 +1,39 @@ +name: Question or Information Request +description: Ask a question or request more information related to the project. +title: "[QUESTION]: " +labels: ["question"] +assignees: [] + +body: + - type: markdown + attributes: + value: | + Please fill out the form below to ask your question or request further information. + + - type: input + id: question_summary + attributes: + label: Summary of your question + description: Provide a brief summary of your question or information request. + placeholder: "Summarize your question in a few words..." + + - type: textarea + id: question_details + attributes: + label: Question details + description: Provide a detailed explanation of your question or what information you're requesting. + placeholder: "Describe your question or information request in detail..." + + - type: input + id: context + attributes: + label: Context for the question + description: Provide any relevant context or background information that may help clarify your question. + placeholder: "Add context for your question (e.g., where you encountered the issue, what you're trying to do)..." + + - type: input + id: additional + attributes: + label: Additional context + description: Add any additional information that may help answer your question. + placeholder: "Any extra information or comments..." From c77d891a769d8b98064ef76a881013eb0e2870e2 Mon Sep 17 00:00:00 2001 From: Syed Date: Wed, 4 Sep 2024 06:52:42 +0530 Subject: [PATCH 38/97] wont fix and help templates added --- .github/ISSUE_TEMPLATE/help-issue.yml | 39 ++++++++++++++++++++++++ .github/ISSUE_TEMPLATE/wontfix-issue.yml | 32 +++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/help-issue.yml create mode 100644 .github/ISSUE_TEMPLATE/wontfix-issue.yml diff --git a/.github/ISSUE_TEMPLATE/help-issue.yml b/.github/ISSUE_TEMPLATE/help-issue.yml new file mode 100644 index 0000000..4177fcd --- /dev/null +++ b/.github/ISSUE_TEMPLATE/help-issue.yml @@ -0,0 +1,39 @@ +name: Help wanted +description: Request additional help or attention for an issue that needs extra effort. +title: "[HELP WANTED]: " +labels: ["help wanted"] +assignees: [] + +body: + - type: markdown + attributes: + value: | + We need additional help with this issue. Please provide as much detail as possible to assist contributors. + + - type: textarea + id: issue_description + attributes: + label: Issue description + description: Provide a detailed description of the issue and what kind of help is needed. + placeholder: "Describe the issue and the type of help required..." + + - type: input + id: specific_tasks + attributes: + label: Specific tasks + description: List any specific tasks or sub-tasks where help is needed. + placeholder: "List specific tasks or areas where help is needed..." + + - type: input + id: additional_resources + attributes: + label: Additional resources + description: Provide links to related documentation, resources, or references that might help contributors. + placeholder: "Link to relevant resources or documentation..." + + - type: input + id: additional + attributes: + label: Additional context + description: Add any extra information or context that might help in addressing the issue. + placeholder: "Any additional information or comments..." diff --git a/.github/ISSUE_TEMPLATE/wontfix-issue.yml b/.github/ISSUE_TEMPLATE/wontfix-issue.yml new file mode 100644 index 0000000..77d5871 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/wontfix-issue.yml @@ -0,0 +1,32 @@ +name: Won't fix +description: Mark an issue as won't fix if it will not be addressed or resolved. +title: "[WONTFIX]: " +labels: ["wontfix"] +assignees: [] + +body: + - type: markdown + attributes: + value: | + This issue will not be fixed. Please provide reasons or context for why the issue is being closed as won't fix. + + - type: textarea + id: reason + attributes: + label: Reason for won't fix + description: Explain why this issue will not be fixed or addressed. + placeholder: "Describe the reason why this issue is being marked as won't fix..." + + - type: input + id: decision_maker + attributes: + label: Decision maker + description: Specify who made the decision to mark the issue as won't fix. + placeholder: "Name of the person or team responsible for this decision..." + + - type: input + id: additional + attributes: + label: Additional context + description: Add any other context or information relevant to the decision. + placeholder: "Any additional information or comments..." From b780bae4a0e712e147c2f50776c49e414794f5d1 Mon Sep 17 00:00:00 2001 From: Syed Date: Wed, 4 Sep 2024 06:55:35 +0530 Subject: [PATCH 39/97] goodfirst template added --- .github/ISSUE_TEMPLATE/goodfirst-issue.yml | 46 ++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/goodfirst-issue.yml diff --git a/.github/ISSUE_TEMPLATE/goodfirst-issue.yml b/.github/ISSUE_TEMPLATE/goodfirst-issue.yml new file mode 100644 index 0000000..212a0d6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/goodfirst-issue.yml @@ -0,0 +1,46 @@ +name: Good first issue +description: Suitable for newcomers or those new to the project. +title: "[GOOD FIRST ISSUE]: " +labels: ["good first issue"] +assignees: [] + +body: + - type: markdown + attributes: + value: | + Welcome to contributing to our project! This issue is marked as a "Good First Issue," which means it is a great starting point for new contributors. Please provide the following information to help us understand your issue. + + - type: input + id: issue_summary + attributes: + label: Issue summary + description: Provide a brief summary of the issue or task. + placeholder: "Summarize the issue or task..." + + - type: textarea + id: detailed_description + attributes: + label: Detailed description + description: Provide a detailed description of what needs to be done, including any relevant background information or steps. + placeholder: "Describe the issue or task in detail, including any relevant information..." + + - type: input + id: steps_to_reproduce + attributes: + label: Steps to reproduce (if applicable) + description: If this issue involves a bug, list the steps to reproduce the problem. + placeholder: "List the steps to reproduce the issue (if applicable)..." + + - type: input + id: expected_outcome + attributes: + label: Expected outcome + description: Describe what you expect to happen once the issue is resolved. + placeholder: "Describe the expected outcome..." + + - type: input + id: additional_context + attributes: + label: Additional context + description: Add any other context or information that might be helpful for resolving the issue. + placeholder: "Any additional information or comments..." From 5069a0b18c3f9693d8c33a357d70add0117f8e29 Mon Sep 17 00:00:00 2001 From: Syed Date: Wed, 4 Sep 2024 07:20:38 +0530 Subject: [PATCH 40/97] added contributing.md for issues --- .github/CONTRIBUTING.md | 65 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index e69de29..04e478a 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,65 @@ +# Issues Reporting Guidelines + +Welcome to the LinkedIn Auto Jobs Applier with AI issues page! To keep things organized and ensure issues are resolved quickly, please follow the guidelines below when submitting a bug report, feature request, or any other issue. + +## Before You Submit an Issue + +### 1. Search Existing Issues + +Please search through the existing open issues and closed issues to ensure your issue hasn’t already been reported. This helps avoid duplicates and allows us to focus on unresolved problems. + +### 2. Check Documentation + +Review the README and any available documentation to see if your issue is covered. + +### 3. Provide Detailed Information + +If you are reporting a bug, make sure you include enough details to reproduce the issue. The more information you provide, the faster we can diagnose and fix the problem. + +## Issue Types + +### 1. Bug Reports + +Please include the following information: + +- **Description:** A clear and concise description of the problem. +- **Steps to Reproduce:** Provide detailed steps to reproduce the bug. +- **Expected Behavior:** What should have happened. +- **Actual Behavior:** What actually happened. +- **Environment Details:** Include your OS, browser version (if applicable), and any other relevant environment details. +- **Logs/Screenshots:** If applicable, attach screenshots or log outputs. + +### 2. Feature Requests + +For new features or improvements: + +- Clearly describe the feature you would like to see. +- Explain the problem this feature would solve or the benefit it would bring. +- If possible, provide examples or references to similar features in other tools or platforms. + +### 3. Questions/Discussions + +- If you’re unsure whether something is a bug or if you’re seeking clarification on functionality, you can ask a question. Please make sure to label your issue as a question. + +## Issue Labeling and Response Time + +We use the following labels to categorize issues: + +- **bug:** An issue where something isn't functioning as expected. +- **documentation:** Improvements or additions to project documentation. +- **duplicate:** This issue or pull request already exists elsewhere. +- **enhancement:** A request for a new feature or improvement. +- **good first issue:** A simple issue suitable for newcomers. +- **help wanted:** The issue needs extra attention or assistance. +- **invalid:** The issue is not valid or doesn't seem correct. +- **question:** Additional information or clarification is needed. +- **wontfix:** The issue will not be fixed or addressed. +- We aim to respond to issues as early as possible. Please be patient, as maintainers may have limited availability. + +## Contributing Fixes + +If you’re able to contribute a fix for an issue: + +1. Fork the repository and create a new branch for your fix. +2. Reference the issue number in your branch and pull request. +3. Submit a pull request with a detailed description of the changes and how they resolve the issue. From 75468db0b937728704faf777b890978074ed4929 Mon Sep 17 00:00:00 2001 From: Vinicius Tavares Date: Wed, 4 Sep 2024 10:30:18 -0300 Subject: [PATCH 41/97] add config to apply once at company --- data_folder/config.yaml | 2 ++ data_folder_example/config.yaml | 3 ++- src/linkedIn_job_manager.py | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/data_folder/config.yaml b/data_folder/config.yaml index 58a6f1c..22d4c1f 100644 --- a/data_folder/config.yaml +++ b/data_folder/config.yaml @@ -31,6 +31,8 @@ locations: - Country1 - Country2 +applyOnceAtCompany: [true/false] + distance: 100 companyBlacklist: diff --git a/data_folder_example/config.yaml b/data_folder_example/config.yaml index 6e362be..f0f6557 100644 --- a/data_folder_example/config.yaml +++ b/data_folder_example/config.yaml @@ -26,10 +26,11 @@ date: positions: - Software Tester - locations: - USA +applyOnceAtCompany: [true/false] + distance: 100 companyBlacklist: diff --git a/src/linkedIn_job_manager.py b/src/linkedIn_job_manager.py index 7a87ae5..e42c087 100644 --- a/src/linkedIn_job_manager.py +++ b/src/linkedIn_job_manager.py @@ -36,6 +36,7 @@ class LinkedInJobManager: self.title_blacklist = parameters.get('titleBlacklist', []) or [] self.positions = parameters.get('positions', []) self.locations = parameters.get('locations', []) + self.apply_once_at_company = parameters.get('applyOnceAtCompany', False) self.base_search_url = self.get_base_search_url(parameters) self.seen_jobs = [] resume_path = parameters.get('uploads', {}).get('resume', None) @@ -121,6 +122,12 @@ class LinkedInJobManager: utils.printyellow(f"Blacklisted {job.title} at {job.company}, skipping...") self.write_to_file(job, "skipped") continue + if self.is_already_applied_to_job(job.title, job.company, job.link): + self.write_to_file(job, "skipped") + continue + if self.is_already_applied_to_company(job.company): + self.write_to_file(job, "skipped") + continue try: if job.apply_method not in {"Continue", "Applied", "Apply"}: self.easy_applier_component.job_apply(job) @@ -206,3 +213,28 @@ class LinkedInJobManager: company_blacklisted = company.strip().lower() in (word.strip().lower() for word in self.company_blacklist) link_seen = link in self.seen_jobs return title_blacklisted or company_blacklisted or link_seen + + def is_already_applied_to_job(self, job_title, company, link): + link_seen = link in self.seen_jobs + if link_seen: + utils.printyellow(f"Already applied to job: {job_title} at {company}, skipping...") + return link_seen + + def is_already_applied_to_company(self, company): + if not self.apply_once_at_company: + return False + + output_files = ["success.json"] + for file_name in output_files: + file_path = self.output_file_directory / file_name + if file_path.exists(): + with open(file_path, 'r', encoding='utf-8') as f: + try: + existing_data = json.load(f) + for applied_job in existing_data: + if applied_job['company'].strip().lower() == company.strip().lower(): + utils.printyellow(f"Already applied at {company} (once per company policy), skipping...") + return True + except json.JSONDecodeError: + continue + return False \ No newline at end of file From 89a8c462f63383886be4247a55955fa75d1e8d77 Mon Sep 17 00:00:00 2001 From: Vinicius Nunes <104783995+viniciusnunest@users.noreply.github.com> Date: Wed, 4 Sep 2024 12:51:52 -0300 Subject: [PATCH 42/97] Update README.md --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 15fe7b8..3994b63 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,8 @@ This file defines your job search parameters and bot behavior. Each section cont - Italy - London ``` +- `applyOnceAtCompany: [True/False]` + - Set if you will apply in more than one opportunity per company - `distance: [number]` - Set the radius for your job search in miles From d7fd4009183e30c0db9b33c1f11b346e81662063 Mon Sep 17 00:00:00 2001 From: Manu Altieri Date: Wed, 4 Sep 2024 19:51:08 +0200 Subject: [PATCH 43/97] Added a little list to save jobs_id to avoid re-iterate applied already jobs --- src/linkedin-api.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/linkedin-api.py b/src/linkedin-api.py index ae37845..70088b8 100644 --- a/src/linkedin-api.py +++ b/src/linkedin-api.py @@ -9,6 +9,8 @@ import json logging.basicConfig(level=logging.INFO) class LinkedInEvolvedAPI(Linkedin): + already_applied_jobs: List[str] = [] + def __init__(self, username, password): super().__init__(username, password) @@ -351,6 +353,8 @@ class LinkedInEvolvedAPI(Linkedin): # 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) @@ -360,10 +364,13 @@ class LinkedInEvolvedAPI(Linkedin): ## EXAMPLE USAGE if __name__ == "__main__": - api: LinkedInEvolvedAPI = LinkedInEvolvedAPI(username="", password="") + api: LinkedInEvolvedAPI = LinkedInEvolvedAPI(username="", password="") jobs = api.search_jobs(keywords="Frontend Developer", location_name="Italia", limit=5, easy_apply=True, offset=1) for job in jobs: job_id: str = job["job_id"] + if job_id in api.already_applied_jobs: + logging.info(f"Already applied to job {job_id}, skipping it") + continue fields = api.get_fields_for_easy_apply(job_id) for field in fields: From 5673308a3d8d917484ae4b03b8314b0875493d37 Mon Sep 17 00:00:00 2001 From: karnoark Date: Thu, 5 Sep 2024 16:38:23 +0530 Subject: [PATCH 44/97] stripped away additional text --- src/gpt.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/gpt.py b/src/gpt.py index 22c7d6c..77a9992 100644 --- a/src/gpt.py +++ b/src/gpt.py @@ -326,7 +326,11 @@ class GPTAnswerer: prompt = ChatPromptTemplate.from_template(section_prompt) chain = prompt | self.llm_cheap | StrOutputParser() output = chain.invoke({"question": question}) - section_name = output.lower().replace(" ", "_") + 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}) From 104f99aa96cc822a5524d4d411de93f2d90ef14e Mon Sep 17 00:00:00 2001 From: Manu Altieri Date: Thu, 5 Sep 2024 17:27:04 +0200 Subject: [PATCH 45/97] Fixed search filter to avoid fixed results --- src/linkedin-api.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/linkedin-api.py b/src/linkedin-api.py index 70088b8..37f727d 100644 --- a/src/linkedin-api.py +++ b/src/linkedin-api.py @@ -47,7 +47,7 @@ class LinkedInEvolvedAPI(Linkedin): industries: Optional[List[str]] = None, location_name: Optional[str] = None, remote: Optional[List[Union[Literal["1"], Literal["2"], Literal["3"]]]] = None, - listed_at=24 * 60 * 60, + listed_at: None | int = None, distance: Optional[int] = None, easy_apply: Optional[bool] = True, limit=-1, @@ -72,8 +72,8 @@ class LinkedInEvolvedAPI(Linkedin): :type location_name: str, optional :param remote: Filter for remote jobs, onsite or hybrid. onsite:"1", remote:"2", hybrid:"3" :type remote: list, optional - :param listed_at: maximum number of seconds passed since job posting. 86400 will filter job postings posted in last 24 hours. - :type listed_at: int/str, optional. Default value is equal to 24 hours. + :param listed_at: maximum number of seconds passed since job posting. 86400 will filter job postings posted in last 24 hours, default is None + :type listed_at: int or none, if none, no filter applied, otherwise, filter applied in seconds :param distance: maximum distance from location in miles :type distance: int/str, optional. If not specified, None or 0, the default value of 25 miles applied. :param easy_apply: filter for jobs that are easy to apply to @@ -115,7 +115,8 @@ class LinkedInEvolvedAPI(Linkedin): if easy_apply: query["selectedFilters"]["applyWithLinkedin"] = "List(true)" - query["selectedFilters"]["timePostedRange"] = f"List(r{listed_at})" + if listed_at: + query["selectedFilters"]["timePostedRange"] = f"List(r{listed_at})" query["spellCorrectionEnabled"] = "true" query_string = ( @@ -144,7 +145,6 @@ class LinkedInEvolvedAPI(Linkedin): headers={"accept": "application/vnd.linkedin.normalized+json+2.1"}, ) data = res.json() - elements = data.get("included", []) new_data = [] for e in elements: @@ -365,9 +365,12 @@ class LinkedInEvolvedAPI(Linkedin): ## EXAMPLE USAGE if __name__ == "__main__": api: LinkedInEvolvedAPI = LinkedInEvolvedAPI(username="", password="") - jobs = api.search_jobs(keywords="Frontend Developer", location_name="Italia", limit=5, easy_apply=True, offset=1) + 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}") + continue + if job_id in api.already_applied_jobs: logging.info(f"Already applied to job {job_id}, skipping it") continue From 525e794f8b9baf56efed473bb19d7ccc06d89624 Mon Sep 17 00:00:00 2001 From: queukat Date: Fri, 6 Sep 2024 01:53:11 +0300 Subject: [PATCH 46/97] add logs and some bugs fixes --- src/gpt.py | 316 ++++++++++++++++++------------- src/linkedIn_authenticator.py | 62 +++--- src/linkedIn_easy_applier.py | 343 ++++++++++++++++++++++++++++------ src/linkedIn_job_manager.py | 53 ++++++ src/utils.py | 15 +- 5 files changed, 565 insertions(+), 224 deletions(-) diff --git a/src/gpt.py b/src/gpt.py index 62f362a..baa87de 100644 --- a/src/gpt.py +++ b/src/gpt.py @@ -2,20 +2,21 @@ import json import os import re import textwrap +import time from datetime import datetime -from typing import Dict, List +from functools import wraps from pathlib import Path +from typing import Dict, List + +import httpx +from Levenshtein import distance from dotenv import load_dotenv +from httpx import HTTPStatusError from langchain_core.messages.ai import AIMessage from langchain_core.output_parsers import StrOutputParser from langchain_core.prompt_values import StringPromptValue from langchain_core.prompts import ChatPromptTemplate from langchain_openai import ChatOpenAI -from Levenshtein import distance -import time -from functools import wraps -from openai import RateLimitError, OpenAIError, APIError - import src.strings as strings from src.utils import logger @@ -42,156 +43,209 @@ def global_rate_limiter(min_interval): return decorator -def parse_wait_time_from_error_message(error_message: str) -> int: - logger.debug("Parsing wait time from error message: %s", error_message) - match = re.search(r"Please try again in (\d+)([smhd])", error_message) - if match: - value, unit = int(match.group(1)), match.group(2) - logger.debug("Extracted wait time: %d %s", value, unit) - if unit == 's': - return value - elif unit == 'm': - return value * 60 - elif unit == 'h': - return value * 3600 - elif unit == 'd': - return value * 86400 - logger.debug("Default wait time applied: 30 seconds") - return 30 - class LLMLogger: def __init__(self, llm: ChatOpenAI): + logger.debug("Initializing LLMLogger with LLM: %s", llm) self.llm = llm - logger.debug("LLMLogger initialized with LLM: %s", llm) + logger.debug("LLMLogger successfully initialized with LLM: %s", llm) @staticmethod def log_request(prompts, parsed_reply: Dict[str, Dict]): - logger.debug("Logging request with prompts: %s", prompts) - calls_log = os.path.join(Path("data_folder/output"), "open_ai_calls.json") + logger.debug("Starting log_request method") + logger.debug("Prompts received: %s", prompts) + logger.debug("Parsed reply received: %s", parsed_reply) + # Определяем путь к файлу для записи логов + try: + 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)) + raise + + # Преобразование prompts в текст или словарь if isinstance(prompts, StringPromptValue): + logger.debug("Prompts are of type StringPromptValue") prompts = prompts.text + logger.debug("Prompts converted to text: %s", prompts) elif isinstance(prompts, Dict): - # Convert prompts to a dictionary if they are not in the expected format - prompts = { - f"prompt_{i+1}": prompt.content - for i, prompt in enumerate(prompts.messages) - } + 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) + except Exception as e: + logger.error("Error converting prompts to dictionary: %s", str(e)) + raise else: - prompts = { - f"prompt_{i+1}": prompt.content - for i, prompt in enumerate(prompts.messages) + 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) + except Exception as e: + logger.error("Error converting prompts using default method: %s", str(e)) + raise + + # Получение текущего времени + try: + current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + logger.debug("Current time obtained: %s", current_time) + except Exception as e: + logger.error("Error obtaining current time: %s", str(e)) + raise + + # Извлечение информации о токенах + try: + token_usage = parsed_reply["usage_metadata"] + 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) + except KeyError as e: + logger.error("KeyError in parsed_reply structure: %s", str(e)) + raise + + # Извлечение имени модели + try: + model_name = parsed_reply["response_metadata"]["model_name"] + logger.debug("Model name: %s", model_name) + except KeyError as e: + logger.error("KeyError in response_metadata: %s", str(e)) + raise + + # Вычисление стоимости использования API + 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) + logger.debug("Total cost calculated: %f", total_cost) + except Exception as e: + logger.error("Error calculating total cost: %s", str(e)) + raise + + # Формирование записи лога + try: + log_entry = { + "model": model_name, + "time": current_time, + "prompts": prompts, + "replies": parsed_reply["content"], # Контент ответа + "total_tokens": total_tokens, + "input_tokens": input_tokens, + "output_tokens": output_tokens, + "total_cost": total_cost, } + 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)) + raise - current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - logger.debug("Current time: %s", current_time) - - # Extract token usage details from the response - token_usage = parsed_reply["usage_metadata"] - 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) - - model_name = parsed_reply["response_metadata"]["model_name"] - prompt_price_per_token = 0.00000015 - completion_price_per_token = 0.0000006 - - # Calculate the total cost of the API call - total_cost = (input_tokens * prompt_price_per_token) + ( - output_tokens * completion_price_per_token - ) - - logger.debug("Total cost calculated: %f", total_cost) - - log_entry = { - "model": model_name, - "time": current_time, - "prompts": prompts, - "replies": parsed_reply["content"], # Response content - "total_tokens": total_tokens, - "input_tokens": input_tokens, - "output_tokens": output_tokens, - "total_cost": total_cost, - } - - logger.debug("Log entry created: %s", log_entry) - - with open(calls_log, "a", encoding="utf-8") as f: - 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) + # Запись в файл + try: + with open(calls_log, "a", encoding="utf-8") as f: + 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: + logger.error("Error writing log entry to file: %s", str(e)) + raise class LoggerChatModel: - def __init__(self, llm: ChatOpenAI): + logger.debug("Initializing LoggerChatModel with LLM: %s", llm) self.llm = llm - logger.debug("LoggerChatModel 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("Calling LoggerChatModel with messages: %s", messages) - while True: + logger.debug("Entering __call__ method with messages: %s", messages) + while True: # Бесконечный цикл до успешного выполнения try: - # Попытка вызвать модель - reply = self.llm(messages) - logger.debug("Model reply received: %s", reply) + logger.debug("Attempting to call the LLM with messages") + reply = self.llm(messages) # Вызов LLM + 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) - return reply - except RateLimitError as err: - # Handle RateLimitError - wait_time = self.parse_wait_time_from_error_message(str(err)) - logger.warning("Rate limit exceeded. Waiting for %d seconds before retrying...", wait_time) - time.sleep(wait_time) + logger.debug("Request successfully logged") + + return reply # Возвращаем корректный ответ, завершаем цикл + + except httpx.HTTPStatusError as e: + logger.error("HTTPStatusError encountered: %s", str(e)) + if e.response.status_code == 429: + retry_after = e.response.headers.get('retry-after') + retry_after_ms = e.response.headers.get('retry-after-ms') + + if retry_after: + wait_time = int(retry_after) + logger.warning("Rate limit exceeded. Waiting for %d seconds before retrying (extracted from 'retry-after' header)...", wait_time) + time.sleep(wait_time) + elif retry_after_ms: + wait_time = int(retry_after_ms) / 1000.0 + logger.warning("Rate limit exceeded. Waiting for %f seconds before retrying (extracted from 'retry-after-ms' header)...", wait_time) + time.sleep(wait_time) + else: + wait_time = 30 # Время ожидания по умолчанию + logger.warning("'retry-after' header not found. Waiting for %d seconds before retrying (default)...", wait_time) + time.sleep(wait_time) + else: + logger.error("HTTP error occurred with status code: %d, waiting 30 seconds before retrying", e.response.status_code) + time.sleep(30) + except Exception as e: logger.error("Unexpected error occurred: %s", str(e)) - raise + logger.info("Waiting for 30 seconds before retrying due to an unexpected error.") + time.sleep(30) + continue # Продолжаем цикл def parse_llmresult(self, llmresult: AIMessage) -> Dict[str, Dict]: logger.debug("Parsing LLM result: %s", llmresult) - content = llmresult.content - response_metadata = llmresult.response_metadata - id_ = llmresult.id - usage_metadata = llmresult.usage_metadata - parsed_result = { - "content": content, - "response_metadata": { - "model_name": response_metadata.get("model_name", ""), - "system_fingerprint": response_metadata.get("system_fingerprint", ""), - "finish_reason": response_metadata.get("finish_reason", ""), - "logprobs": response_metadata.get("logprobs", None), - }, - "id": id_, - "usage_metadata": { - "input_tokens": usage_metadata.get("input_tokens", 0), - "output_tokens": usage_metadata.get("output_tokens", 0), - "total_tokens": usage_metadata.get("total_tokens", 0), - }, - } - logger.debug("Parsed LLM result: %s", parsed_result) - return parsed_result - def parse_wait_time_from_error_message(self, error_message: str) -> int: - logger.debug("Parsing wait time from error message: %s", error_message) - match = re.search(r"Please try again in (\d+)([smhd])", error_message) - if match: - value, unit = match.groups() - value = int(value) - logger.debug("Extracted wait time: %d %s", value, unit) - if unit == "s": - return value - elif unit == "m": - return value * 60 - elif unit == "h": - return value * 3600 - elif unit == "d": - return value * 86400 - logger.debug("Default wait time applied: 30 seconds") - return 30 + # Извлечение данных из ответа + try: + content = llmresult.content + response_metadata = llmresult.response_metadata + id_ = llmresult.id + usage_metadata = llmresult.usage_metadata + + parsed_result = { + "content": content, + "response_metadata": { + "model_name": response_metadata.get("model_name", ""), + "system_fingerprint": response_metadata.get("system_fingerprint", ""), + "finish_reason": response_metadata.get("finish_reason", ""), + "logprobs": response_metadata.get("logprobs", None), + }, + "id": id_, + "usage_metadata": { + "input_tokens": usage_metadata.get("input_tokens", 0), + "output_tokens": usage_metadata.get("output_tokens", 0), + "total_tokens": usage_metadata.get("total_tokens", 0), + }, + } + + logger.debug("Parsed LLM result successfully: %s", parsed_result) + return parsed_result + + except KeyError as 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)) + raise + class GPTAnswerer: @@ -239,7 +293,7 @@ class GPTAnswerer: logger.debug("Setting job application profile: %s", job_application_profile) self.job_application_profile = job_application_profile - @global_rate_limiter(25) + #@global_rate_limiter(25) def summarize_job_description(self, text: str) -> str: logger.debug("Summarizing job description: %s", text) strings.summarize_prompt_template = self._preprocess_template_string( @@ -256,7 +310,7 @@ class GPTAnswerer: prompt = ChatPromptTemplate.from_template(template) return prompt | self.llm_cheap | StrOutputParser() - @global_rate_limiter(25) + #@global_rate_limiter(25) def answer_question_textual_wide_range(self, question: str) -> str: logger.debug("Answering textual question: %s", question) chains = { @@ -384,7 +438,7 @@ class GPTAnswerer: logger.debug("Question answered: %s", output) return output - @global_rate_limiter(25) + #@global_rate_limiter(25) 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) @@ -410,7 +464,7 @@ class GPTAnswerer: logger.error("No numbers found in the string") raise ValueError("No numbers found in the string") - @global_rate_limiter(25) + #@global_rate_limiter(25) 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) @@ -422,11 +476,11 @@ class GPTAnswerer: logger.debug("Best option determined: %s", best_option) return best_option - @global_rate_limiter(25) + #@global_rate_limiter(25) def resume_or_cover(self, phrase: str) -> str: 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. Do not provide any additional information or explanations. + Given the following phrase, respond with only 'resume' if the phrase is about a resume, or 'cover' if it's about a cover letter. If the phrase contains only the word 'upload', consider it as 'cover'. Do not provide any additional information or explanations. phrase: {phrase} """ diff --git a/src/linkedIn_authenticator.py b/src/linkedIn_authenticator.py index 513fb38..d84fc22 100644 --- a/src/linkedIn_authenticator.py +++ b/src/linkedIn_authenticator.py @@ -25,7 +25,14 @@ class LinkedInAuthenticator: logger.info("Starting Chrome browser to log in to LinkedIn.") self.driver.get('https://www.linkedin.com/feed') self.wait_for_page_load() - if not self.is_logged_in(): + + time.sleep(3) + + if self.is_logged_in(): + logger.info("User is already logged in. Skipping login process.") + return + else: + logger.info("User is not logged in. Proceeding with login.") self.handle_login() def handle_login(self): @@ -82,12 +89,12 @@ class LinkedInAuthenticator: print("Security check not completed. Please try again later.") def is_logged_in(self): - target_url = 'https://www.linkedin.com/feed' - - # Navigate to the target URL if not already there - if self.driver.current_url != target_url: - logger.debug("Navigating to target URL: %s", target_url) - self.driver.get(target_url) + # target_url = 'https://www.linkedin.com/feed' + # + # # Navigate to the target URL if not already there + # if self.driver.current_url != target_url: + # logger.debug("Navigating to target URL: %s", target_url) + # self.driver.get(target_url) try: # Increase the wait time for the page elements to load @@ -98,38 +105,29 @@ class LinkedInAuthenticator: # Check for the presence of the "Start a post" button buttons = self.driver.find_elements(By.CLASS_NAME, 'share-box-feed-entry__trigger') - if any(button.text.strip() == 'Start a post' for button in buttons): - logger.info("User is already logged in.") + logger.debug("Found %d 'Start a post' buttons", len(buttons)) - try: - # Wait for the profile picture and name to load - profile_img = WebDriverWait(self.driver, 10).until( - EC.presence_of_element_located((By.XPATH, "//img[contains(@alt, 'Photo of')]")) - ) - profile_name = WebDriverWait(self.driver, 10).until( - EC.presence_of_element_located((By.XPATH, "//div[@class='t-16 t-black t-bold']")) - ) + # Выведем текст всех найденных кнопок в лог для диагностики + for i, button in enumerate(buttons): + logger.debug("Button %d text: %s", i + 1, button.text.strip()) - if profile_img and profile_name: - logger.info("Profile picture found for user: %s", profile_name.text) - return True - except NoSuchElementException: - logger.warning("Profile picture or name not found.") - print("Profile picture or name not found.") - return False - except TimeoutException: - logger.warning("Profile picture or name took too long to load.") - print("Profile picture or name took too long to load.") - return False + if any(button.text.strip().lower() == 'start a post' for button in buttons): + logger.info("Found 'Start a post' button indicating user is logged in.") + return True + + # Альтернативная проверка авторизации по наличию изображения профиля + profile_img_elements = self.driver.find_elements(By.XPATH, "//img[contains(@alt, 'Photo of')]") + if profile_img_elements: + logger.info("Profile image found. Assuming user is logged in.") + return True + + logger.info("Did not find 'Start a post' button or profile image. User might not be logged in.") + return False except TimeoutException: logger.error("Page elements took too long to load or were not found.") - print("Page elements took too long to load or were not found.") return False - return False - - def wait_for_page_load(self, timeout=10): try: logger.debug("Waiting for page to load with timeout: %s seconds", timeout) diff --git a/src/linkedIn_easy_applier.py b/src/linkedIn_easy_applier.py index 047d99d..71139bd 100644 --- a/src/linkedIn_easy_applier.py +++ b/src/linkedIn_easy_applier.py @@ -8,6 +8,9 @@ import time import traceback from datetime import date from typing import List, Optional, Any, Tuple + +from httpx import HTTPStatusError +from openai import RateLimitError from reportlab.lib.pagesizes import letter from reportlab.pdfgen import canvas from selenium.common.exceptions import NoSuchElementException, TimeoutException @@ -57,54 +60,131 @@ class LinkedInEasyApplier: def job_apply(self, job: Any): logger.debug("Starting job application for job: %s", job) - self.driver.get(job.link) - time.sleep(random.uniform(3, 5)) + + # Открываем страницу с вакансией try: + self.driver.get(job.link) + logger.debug("Navigated to job link: %s", job.link) + except Exception as e: + logger.error("Failed to navigate to job link: %s, error: %s", job.link, str(e)) + raise + + # Добавляем небольшую паузу для загрузки страницы + time.sleep(random.uniform(3, 5)) + + try: + # Поиск кнопки 'Easy Apply' + logger.debug("Searching for 'Easy Apply' button on job page") easy_apply_button = self._find_easy_apply_button() - job.set_job_description(self._get_job_description()) - job.set_recruiter_link(self._get_job_recruiter()) + + # Получаем описание вакансии + logger.debug("Retrieving job description") + job_description = self._get_job_description() + job.set_job_description(job_description) + logger.debug("Job description set: %s", job_description[:100]) # Логируем только первые 100 символов + + # Получаем ссылку на рекрутера (если есть) + logger.debug("Retrieving recruiter link") + recruiter_link = self._get_job_recruiter() + job.set_recruiter_link(recruiter_link) + logger.debug("Recruiter link set: %s", recruiter_link) + + # Действие: нажимаем на кнопку 'Easy Apply' + logger.debug("Attempting to click 'Easy Apply' button") actions = ActionChains(self.driver) actions.move_to_element(easy_apply_button).click().perform() + logger.debug("'Easy Apply' button clicked successfully") + + # Передача информации о работе для дальнейшей обработки + logger.debug("Passing job information to GPT Answerer") self.gpt_answerer.set_job(job) + + # Заполнение формы подачи заявки + logger.debug("Filling out application form") self._fill_application_form(job) - logger.debug("Job application process completed for job: %s", job) - except Exception: + logger.debug("Job application process completed successfully for job: %s", job) + + except Exception as e: + # Захват и логирование полного traceback в случае ошибки tb_str = traceback.format_exc() - logger.error("Failed to apply to job: %s", tb_str) + logger.error("Failed to apply to job: %s. Error traceback: %s", job, tb_str) + + # Отмена заявки в случае ошибки + logger.debug("Discarding application due to failure") self._discard_application() - raise Exception(f"Failed to apply to job! Original exception: \nTraceback:\n{tb_str}") + + # Поднятие исключения с оригинальной ошибкой + raise Exception(f"Failed to apply to job! Original exception:\nTraceback:\n{tb_str}") def _find_easy_apply_button(self) -> WebElement: logger.debug("Searching for 'Easy Apply' button") attempt = 0 + + # Список методов поиска кнопки + search_methods = [ + { + 'description': "find all 'Easy Apply' buttons using find_elements", + 'find_elements': True, # Используем find_elements для поиска всех кнопок + 'xpath': '//button[contains(@class, "jobs-apply-button") and contains(., "Easy Apply")]' + }, + { + 'description': "'aria-label' containing 'Easy Apply to'", + 'xpath': '//button[contains(@aria-label, "Easy Apply to")]' + }, + { + 'description': "button text search", + 'xpath': '//button[contains(text(), "Easy Apply") or contains(text(), "Apply now")]' + } + ] + while attempt < 2: self._scroll_page() - try: - buttons = WebDriverWait(self.driver, 10).until( - EC.presence_of_all_elements_located( - (By.XPATH, '//button[contains(@class, "jobs-apply-button") and contains(., "Easy Apply")]') - ) - ) - for index, _ in enumerate(buttons): - try: - button = WebDriverWait(self.driver, 10).until( - EC.element_to_be_clickable( - (By.XPATH, f'(//button[contains(@class, "jobs-apply-button") and contains(., "Easy Apply")])[{index + 1}]') - ) - ) - logger.debug("Found and clicking 'Easy Apply' button") - return button - except Exception as e: - logger.warning("Failed to click 'Easy Apply' button on attempt %d: %s", attempt + 1, e) - except TimeoutException: - logger.warning("Timeout while searching for 'Easy Apply' button") + for method in search_methods: + try: + logger.debug(f"Attempting search using {method['description']}") + + # Если метод использует find_elements + if method.get('find_elements'): + # Поиск всех кнопок "Easy Apply" + buttons = self.driver.find_elements(By.XPATH, method['xpath']) + if buttons: + for index, button in enumerate(buttons): + try: + # Проверка видимости и кликабельности каждой кнопки + WebDriverWait(self.driver, 10).until(EC.visibility_of(button)) + WebDriverWait(self.driver, 10).until(EC.element_to_be_clickable(button)) + logger.debug(f"Found 'Easy Apply' button {index + 1}, attempting to click") + return button + except Exception as e: + logger.warning(f"Button {index + 1} found but not clickable: {e}") + else: + raise TimeoutException("No 'Easy Apply' buttons found") + else: + # Стандартный метод с WebDriverWait для одного элемента + button = WebDriverWait(self.driver, 10).until( + EC.presence_of_element_located((By.XPATH, method['xpath'])) + ) + WebDriverWait(self.driver, 10).until(EC.visibility_of(button)) + WebDriverWait(self.driver, 10).until(EC.element_to_be_clickable(button)) + logger.debug("Found 'Easy Apply' button, attempting to click") + return button + + except TimeoutException: + logger.warning(f"Timeout during search using {method['description']}") + except Exception as e: + logger.warning(f"Failed to click 'Easy Apply' button using {method['description']} on attempt {attempt + 1}: {e}") + + # Обновление страницы после первой неудачной попытки if attempt == 0: logger.debug("Refreshing page to retry finding 'Easy Apply' button") self.driver.refresh() time.sleep(random.randint(3, 5)) attempt += 1 - logger.error("No clickable 'Easy Apply' button found after 2 attempts") + + # Если не удалось найти кнопку, выводим HTML для отладки + page_source = self.driver.page_source + logger.error("No clickable 'Easy Apply' button found after 2 attempts. Page source:\n%s", page_source) raise Exception("No clickable 'Easy Apply' button found") def _get_job_description(self) -> str: @@ -136,10 +216,18 @@ class LinkedInEasyApplier: hiring_team_section = WebDriverWait(self.driver, 10).until( EC.presence_of_element_located((By.XPATH, '//h2[text()="Meet the hiring team"]')) ) - recruiter_element = hiring_team_section.find_element(By.XPATH, './/following::a[contains(@href, "linkedin.com/in/")]') - recruiter_link = recruiter_element.get_attribute('href') - logger.debug("Job recruiter link retrieved successfully") - return recruiter_link + logger.debug("Hiring team section found") + + recruiter_elements = hiring_team_section.find_elements(By.XPATH, './/following::a[contains(@href, "linkedin.com/in/")]') + + if recruiter_elements: + recruiter_element = recruiter_elements[0] + recruiter_link = recruiter_element.get_attribute('href') + logger.debug("Job recruiter link retrieved successfully: %s", recruiter_link) + return recruiter_link + else: + logger.debug("No recruiter link found in the hiring team section") + return "" except Exception as e: logger.warning("Failed to retrieve recruiter information: %s", e) return "" @@ -202,11 +290,19 @@ class LinkedInEasyApplier: def fill_up(self, job) -> None: logger.debug("Filling up form sections for job: %s", job) - easy_apply_content = self.driver.find_element(By.CLASS_NAME, 'jobs-easy-apply-content') - pb4_elements = easy_apply_content.find_elements(By.CLASS_NAME, 'pb4') - for element in pb4_elements: - self._process_form_element(element, job) - + + # Используем WebDriverWait для ожидания элемента с классом 'jobs-easy-apply-content' + try: + easy_apply_content = WebDriverWait(self.driver, 10).until( + EC.presence_of_element_located((By.CLASS_NAME, 'jobs-easy-apply-content')) + ) + + # После нахождения 'jobs-easy-apply-content' ищем элементы с классом 'pb4' + pb4_elements = easy_apply_content.find_elements(By.CLASS_NAME, 'pb4') + for element in pb4_elements: + self._process_form_element(element, job) + except Exception as e: + logger.error(f"Failed to find form elements: {e}") def _process_form_element(self, element: WebElement, job) -> None: logger.debug("Processing form element") if self._is_upload_field(element): @@ -221,40 +317,114 @@ class LinkedInEasyApplier: def _handle_upload_fields(self, element: WebElement, job) -> None: logger.debug("Handling upload fields") + + try: + show_more_button = self.driver.find_element(By.XPATH, "//button[contains(@aria-label, 'Show more resumes')]") + show_more_button.click() + logger.debug("Clicked 'Show more resumes' button") + except NoSuchElementException: + logger.debug("'Show more resumes' button not found, continuing...") + file_upload_elements = self.driver.find_elements(By.XPATH, "//input[@type='file']") for element in file_upload_elements: parent = element.find_element(By.XPATH, "..") self.driver.execute_script("arguments[0].classList.remove('hidden')", element) + output = self.gpt_answerer.resume_or_cover(parent.text.lower()) if 'resume' in output: logger.debug("Uploading resume") if self.resume_path is not None and self.resume_path.resolve().is_file(): element.send_keys(str(self.resume_path.resolve())) + logger.debug(f"Resume uploaded from path: {self.resume_path.resolve()}") else: + logger.debug("Resume path not found or invalid, generating new resume") self._create_and_upload_resume(element, job) elif 'cover' in output: logger.debug("Uploading cover letter") self._create_and_upload_cover_letter(element) + logger.debug("Finished handling upload fields") + def _create_and_upload_resume(self, element, job): - logger.debug("Creating and uploading resume") - folder_path = 'generated_cv' - os.makedirs(folder_path, exist_ok=True) - try: - timestamp = int(time.time()) - file_path_pdf = os.path.join(folder_path, f"CV_{timestamp}.pdf") + logger.debug("Starting the process of creating and uploading resume.") + folder_path = 'generated_cv' - with open(file_path_pdf, "xb") as f: # gjcvjn - f.write(base64.b64decode(self.resume_generator_manager.pdf_base64(job_description_text=job.description))) + try: + if not os.path.exists(folder_path): + logger.debug(f"Creating directory at path: {folder_path}") + os.makedirs(folder_path, exist_ok=True) + except Exception as e: + logger.error(f"Failed to create directory: {folder_path}. Error: {e}") + raise - element.send_keys(os.path.abspath(file_path_pdf)) - job.pdf_path = os.path.abspath(file_path_pdf) - time.sleep(2) - logger.debug("Resume created and uploaded successfully: %s", file_path_pdf) - except Exception: - tb_str = traceback.format_exc() - logger.error("Resume upload failed: %s", tb_str) - raise Exception(f"Upload failed: \nTraceback:\n{tb_str}") + while True: + try: + timestamp = int(time.time()) + file_path_pdf = os.path.join(folder_path, f"CV_{timestamp}.pdf") + logger.debug(f"Generated file path for resume: {file_path_pdf}") + + logger.debug(f"Generating resume for job: {job.title} at {job.company}") + resume_pdf_base64 = self.resume_generator_manager.pdf_base64(job_description_text=job.description) + with open(file_path_pdf, "xb") as f: + f.write(base64.b64decode(resume_pdf_base64)) + logger.debug(f"Resume successfully generated and saved to: {file_path_pdf}") + + break + except HTTPStatusError as e: + if e.response.status_code == 429: + + retry_after = e.response.headers.get('retry-after') + retry_after_ms = e.response.headers.get('retry-after-ms') + + if retry_after: + wait_time = int(retry_after) + logger.warning(f"Rate limit exceeded, waiting {wait_time} seconds before retrying...") + elif retry_after_ms: + wait_time = int(retry_after_ms) / 1000.0 + logger.warning(f"Rate limit exceeded, waiting {wait_time} milliseconds before retrying...") + else: + wait_time = 20 + logger.warning(f"Rate limit exceeded, waiting {wait_time} seconds before retrying...") + + time.sleep(wait_time) + else: + logger.error(f"HTTP error: {e}") + raise + + except Exception as e: + logger.error(f"Failed to generate resume: {e}") + tb_str = traceback.format_exc() + logger.error(f"Traceback: {tb_str}") + if "RateLimitError" in str(e): + logger.warning("Rate limit error encountered, retrying...") + time.sleep(20) + else: + raise + + file_size = os.path.getsize(file_path_pdf) + max_file_size = 2 * 1024 * 1024 # 2 MB + logger.debug(f"Resume file size: {file_size} bytes") + if file_size > max_file_size: + logger.error(f"Resume file size exceeds 2 MB: {file_size} bytes") + raise ValueError("Resume file size exceeds the maximum limit of 2 MB.") + + allowed_extensions = {'.pdf', '.doc', '.docx'} + file_extension = os.path.splitext(file_path_pdf)[1].lower() + logger.debug(f"Resume file extension: {file_extension}") + if file_extension not in allowed_extensions: + logger.error(f"Invalid resume file format: {file_extension}") + raise ValueError("Resume file format is not allowed. Only PDF, DOC, and DOCX formats are supported.") + + try: + logger.debug(f"Uploading resume from path: {file_path_pdf}") + element.send_keys(os.path.abspath(file_path_pdf)) + job.pdf_path = os.path.abspath(file_path_pdf) + time.sleep(2) + logger.debug(f"Resume created and uploaded successfully: {file_path_pdf}") + except Exception as e: + tb_str = traceback.format_exc() + logger.error(f"Resume upload failed: {tb_str}") + raise Exception(f"Upload failed: \nTraceback:\n{tb_str}") def _create_and_upload_cover_letter(self, element: WebElement) -> None: logger.debug("Creating and uploading cover letter") @@ -329,30 +499,56 @@ class LinkedInEasyApplier: return False def _find_and_handle_textbox_question(self, section: WebElement) -> bool: + logger.debug("Searching for text fields in the section.") text_fields = section.find_elements(By.TAG_NAME, 'input') + section.find_elements(By.TAG_NAME, 'textarea') + if text_fields: text_field = text_fields[0] question_text = section.find_element(By.TAG_NAME, 'label').text.lower() + logger.debug(f"Found text field with label: {question_text}") + is_numeric = self._is_numeric_field(text_field) + logger.debug(f"Is the field numeric? {'Yes' if is_numeric else 'No'}") + if is_numeric: question_type = 'numeric' answer = self.gpt_answerer.answer_question_numeric(question_text) + logger.debug(f"Generated numeric answer: {answer}") else: question_type = 'textbox' answer = self.gpt_answerer.answer_question_textual_wide_range(question_text) + logger.debug(f"Generated textual answer: {answer}") + existing_answer = None for item in self.all_data: if item['question'] == self._sanitize_text(question_text) and item['type'] == question_type: existing_answer = item + logger.debug(f"Found existing answer in the data: {existing_answer['answer']}") break + if existing_answer: self._enter_text(text_field, existing_answer['answer']) - logger.debug("Entered existing textbox answer") + logger.debug("Entered existing textbox answer.") + + # Нажать "Вниз" и "Enter" для выбора первого элемента в выпадающем списке + time.sleep(1) # Ожидание появления выпадающего списка + text_field.send_keys(Keys.ARROW_DOWN) + text_field.send_keys(Keys.ENTER) + logger.debug("Selected first option from the dropdown.") return True + self._save_questions_to_json({'type': question_type, 'question': question_text, 'answer': answer}) self._enter_text(text_field, answer) - logger.debug("Entered new textbox answer") + logger.debug("Entered new textbox answer and saved it to JSON.") + + # Нажать "Вниз" и "Enter" для выбора первого элемента в выпадающем списке + time.sleep(1) # Ожидание появления выпадающего списка + text_field.send_keys(Keys.ARROW_DOWN) + text_field.send_keys(Keys.ENTER) + logger.debug("Selected first option from the dropdown.") return True + + logger.debug("No text fields found in the section.") return False def _find_and_handle_date_question(self, section: WebElement) -> bool: @@ -384,16 +580,20 @@ class LinkedInEasyApplier: try: question = section.find_element(By.CLASS_NAME, 'jobs-easy-apply-form-element') question_text = question.find_element(By.TAG_NAME, 'label').text.lower() - dropdown = question.find_element(By.TAG_NAME, 'select') - if dropdown: + logger.debug(f"Processing dropdown or combobox question: {question_text}") + + try: + dropdown = question.find_element(By.TAG_NAME, 'select') select = Select(dropdown) options = [option.text for option in select.options] + logger.debug(f"Dropdown options found: {options}") existing_answer = None for item in self.all_data: if self._sanitize_text(question_text) in item['question'] and item['type'] == 'dropdown': existing_answer = item break + if existing_answer: self._select_dropdown_option(dropdown, existing_answer['answer']) logger.debug("Selected existing dropdown answer") @@ -404,14 +604,37 @@ class LinkedInEasyApplier: self._select_dropdown_option(dropdown, answer) logger.debug("Selected new dropdown answer") return True + + except NoSuchElementException: + combobox = question.find_element(By.TAG_NAME, 'input') + logger.debug(f"Found combobox with ID: {combobox.get_attribute('id')}") + + existing_answer = None + for item in self.all_data: + if self._sanitize_text(question_text) in item['question'] and item['type'] == 'combobox': + existing_answer = item + break + + if existing_answer: + self._enter_text(combobox, existing_answer['answer']) + logger.debug("Entered existing combobox answer") + return True + + answer = self.gpt_answerer.answer_question_textual_wide_range(question_text) + self._save_questions_to_json({'type': 'combobox', 'question': question_text, 'answer': answer}) + self._enter_text(combobox, answer) + logger.debug("Entered new combobox answer") + return True + except Exception as e: - logger.warning("Failed to handle dropdown question: %s", e) + logger.warning("Failed to handle dropdown or combobox question: %s", e) return False def _is_numeric_field(self, field: WebElement) -> bool: field_type = field.get_attribute('type').lower() - is_numeric = 'numeric' in field_type or ('id' in field.get_attribute("id") and 'numeric' in field.get_attribute("id")) - logger.debug("Field is numeric: %s", is_numeric) + field_id = field.get_attribute("id").lower() + is_numeric = 'numeric' in field_id or field_type == 'number' or ('text' == field_type and 'numeric' in field_id) + logger.debug("Field type: %s, Field ID: %s, Is numeric: %s", field_type, field_id, is_numeric) return is_numeric def _enter_text(self, element: WebElement, text: str) -> None: diff --git a/src/linkedIn_job_manager.py b/src/linkedIn_job_manager.py index cdd4584..9f9c66d 100644 --- a/src/linkedIn_job_manager.py +++ b/src/linkedIn_job_manager.py @@ -85,12 +85,24 @@ class LinkedInJobManager: self.next_job_page(position, location_url, job_page_number) time.sleep(random.uniform(1.5, 3.5)) utils.printyellow("Starting the application process for this page...") + + # Проверка на наличие вакансий на странице + try: + jobs = self.get_jobs_from_page() + if not jobs: + utils.printyellow("No more jobs found on this page. Exiting loop.") + break + except Exception as e: + logger.error(f"Failed to retrieve jobs: {e}") + break # Выходим из цикла, если не удалось получить вакансии + try: self.apply_jobs() except Exception as e: logger.error("Error during job application: %s", e) utils.printred(f"Error during job application: {e}") continue + utils.printyellow("Applying to jobs on this page has been completed!") time_left = minimum_page_time - time.time() @@ -122,6 +134,47 @@ class LinkedInJobManager: time.sleep(sleep_time) page_sleep += 1 + + def get_jobs_from_page(self): + """ + Функция для получения списка вакансий на текущей странице. + Если вакансии не найдены, возвращает пустой список. + """ + try: + # Проверка на отсутствие вакансий + no_jobs_element = self.driver.find_element(By.CLASS_NAME, 'jobs-search-two-pane__no-results-banner--expand') + if 'No matching jobs found' in no_jobs_element.text or 'unfortunately, things aren' in self.driver.page_source.lower(): + utils.printyellow("No matching jobs found on this page.") + logger.debug("No matching jobs found on this page, skipping.") + return [] # Возвращаем пустой список, если нет вакансий + + except NoSuchElementException: + pass # Если элемент не найден, продолжаем поиск вакансий + + # Поиск контейнера результатов с вакансиями + try: + job_results = self.driver.find_element(By.CLASS_NAME, "jobs-search-results-list") + utils.scroll_slow(self.driver, job_results) + utils.scroll_slow(self.driver, job_results, step=300, reverse=True) + + # Поиск элементов списка вакансий + job_list_elements = self.driver.find_elements(By.CLASS_NAME, 'scaffold-layout__list-container')[0].find_elements(By.CLASS_NAME, 'jobs-search-results__list-item') + if not job_list_elements: + utils.printyellow("No job class elements found on page.") + logger.debug("No job class elements found on page, skipping.") + return [] + + # Возвращаем список найденных вакансий + return job_list_elements + + except NoSuchElementException: + logger.debug("No job results found on the page.") + return [] # Если не найден контейнер с результатами, возвращаем пустой список + + except Exception as e: + logger.error(f"Error while fetching job elements: {e}") + return [] + def apply_jobs(self): try: no_jobs_element = self.driver.find_element(By.CLASS_NAME, 'jobs-search-two-pane__no-results-banner--expand') diff --git a/src/utils.py b/src/utils.py index 71e03e3..61c40f0 100644 --- a/src/utils.py +++ b/src/utils.py @@ -10,6 +10,11 @@ import logging logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') logger = logging.getLogger(__name__) +# Отключаем логирование для selenium и urllib3 +logging.getLogger("selenium.webdriver.remote.remote_connection").setLevel(logging.WARNING) +logging.getLogger("urllib3").setLevel(logging.WARNING) +logging.getLogger("httpcore").setLevel(logging.WARNING) + chromeProfilePath = os.path.join(os.getcwd(), "chrome_profile", "linkedin_profile") @@ -31,7 +36,7 @@ def is_scrollable(element): logger.debug("Element scrollable check: scrollHeight=%s, clientHeight=%s, scrollable=%s", scroll_height, client_height, scrollable) return scrollable -def scroll_slow(driver, scrollable_element, start=0, end=3600, step=100, reverse=False): +def scroll_slow(driver, scrollable_element, start=0, end=3600, step=300, reverse=False): logger.debug("Starting slow scroll: start=%d, end=%d, step=%d, reverse=%s", start, end, step, reverse) if reverse: start, end = end, start @@ -39,6 +44,14 @@ def scroll_slow(driver, scrollable_element, start=0, end=3600, step=100, reverse if step == 0: logger.error("Step value cannot be zero.") raise ValueError("Step cannot be zero.") + + max_scroll_height = int(scrollable_element.get_attribute("scrollHeight")) + logger.debug("Max scroll height of the element: %d", max_scroll_height) + + if end > max_scroll_height: + logger.warning("End value exceeds the scroll height. Adjusting end to %d", max_scroll_height) + end = max_scroll_height + script_scroll_to = "arguments[0].scrollTop = arguments[1];" try: if scrollable_element.is_displayed(): From b1de845ec12773cf69b0b6f0cfdc8ee6ed2f3d44 Mon Sep 17 00:00:00 2001 From: queukat Date: Fri, 6 Sep 2024 02:02:55 +0300 Subject: [PATCH 47/97] add logs and some bugs fixes --- src/gpt.py | 44 ++++---------------------------------------- 1 file changed, 4 insertions(+), 40 deletions(-) diff --git a/src/gpt.py b/src/gpt.py index baa87de..1f6a163 100644 --- a/src/gpt.py +++ b/src/gpt.py @@ -23,27 +23,6 @@ from src.utils import logger load_dotenv() -# Global timestamp for rate limiting -last_call_time = 0 - - -def global_rate_limiter(min_interval): - def decorator(func): - @wraps(func) - def wrapper(*args, **kwargs): - global last_call_time - elapsed = time.time() - last_call_time - if elapsed < min_interval: - logger.debug("Rate limit hit, sleeping for %s seconds", min_interval - elapsed) - time.sleep(min_interval - elapsed) - last_call_time = time.time() - return func(*args, **kwargs) - - return wrapper - - return decorator - - class LLMLogger: def __init__(self, llm: ChatOpenAI): @@ -57,7 +36,6 @@ class LLMLogger: logger.debug("Prompts received: %s", prompts) logger.debug("Parsed reply received: %s", parsed_reply) - # Определяем путь к файлу для записи логов try: calls_log = os.path.join(Path("data_folder/output"), "open_ai_calls.json") logger.debug("Logging path determined: %s", calls_log) @@ -65,7 +43,6 @@ class LLMLogger: logger.error("Error determining the log path: %s", str(e)) raise - # Преобразование prompts в текст или словарь if isinstance(prompts, StringPromptValue): logger.debug("Prompts are of type StringPromptValue") prompts = prompts.text @@ -93,7 +70,6 @@ class LLMLogger: logger.error("Error converting prompts using default method: %s", str(e)) raise - # Получение текущего времени try: current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") logger.debug("Current time obtained: %s", current_time) @@ -101,7 +77,6 @@ class LLMLogger: logger.error("Error obtaining current time: %s", str(e)) raise - # Извлечение информации о токенах try: token_usage = parsed_reply["usage_metadata"] output_tokens = token_usage["output_tokens"] @@ -112,7 +87,6 @@ class LLMLogger: logger.error("KeyError in parsed_reply structure: %s", str(e)) raise - # Извлечение имени модели try: model_name = parsed_reply["response_metadata"]["model_name"] logger.debug("Model name: %s", model_name) @@ -120,7 +94,6 @@ class LLMLogger: logger.error("KeyError in response_metadata: %s", str(e)) raise - # Вычисление стоимости использования API try: prompt_price_per_token = 0.00000015 completion_price_per_token = 0.0000006 @@ -130,7 +103,6 @@ class LLMLogger: logger.error("Error calculating total cost: %s", str(e)) raise - # Формирование записи лога try: log_entry = { "model": model_name, @@ -147,7 +119,6 @@ class LLMLogger: 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) @@ -166,7 +137,7 @@ class LoggerChatModel: def __call__(self, messages: List[Dict[str, str]]) -> str: logger.debug("Entering __call__ method with messages: %s", messages) - while True: # Бесконечный цикл до успешного выполнения + while True: try: logger.debug("Attempting to call the LLM with messages") reply = self.llm(messages) # Вызов LLM @@ -175,11 +146,10 @@ class LoggerChatModel: parsed_reply = self.parse_llmresult(reply) logger.debug("Parsed LLM reply: %s", parsed_reply) - # Логируем запрос и ответ LLMLogger.log_request(prompts=messages, parsed_reply=parsed_reply) logger.debug("Request successfully logged") - return reply # Возвращаем корректный ответ, завершаем цикл + return reply except httpx.HTTPStatusError as e: logger.error("HTTPStatusError encountered: %s", str(e)) @@ -207,12 +177,11 @@ class LoggerChatModel: logger.error("Unexpected error occurred: %s", str(e)) logger.info("Waiting for 30 seconds before retrying due to an unexpected error.") time.sleep(30) - continue # Продолжаем цикл + continue def parse_llmresult(self, llmresult: AIMessage) -> Dict[str, Dict]: logger.debug("Parsing LLM result: %s", llmresult) - # Извлечение данных из ответа try: content = llmresult.content response_metadata = llmresult.response_metadata @@ -240,7 +209,7 @@ class LoggerChatModel: except KeyError as e: logger.error("KeyError while parsing LLM result: missing key %s", str(e)) - raise # Повторно выбрасываем исключение, чтобы оно обрабатывалось выше + raise except Exception as e: logger.error("Unexpected error while parsing LLM result: %s", str(e)) @@ -293,7 +262,6 @@ class GPTAnswerer: logger.debug("Setting job application profile: %s", job_application_profile) self.job_application_profile = job_application_profile - #@global_rate_limiter(25) def summarize_job_description(self, text: str) -> str: logger.debug("Summarizing job description: %s", text) strings.summarize_prompt_template = self._preprocess_template_string( @@ -310,7 +278,6 @@ class GPTAnswerer: prompt = ChatPromptTemplate.from_template(template) return prompt | self.llm_cheap | StrOutputParser() - #@global_rate_limiter(25) def answer_question_textual_wide_range(self, question: str) -> str: logger.debug("Answering textual question: %s", question) chains = { @@ -438,7 +405,6 @@ class GPTAnswerer: logger.debug("Question answered: %s", output) return output - #@global_rate_limiter(25) 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) @@ -464,7 +430,6 @@ class GPTAnswerer: logger.error("No numbers found in the string") raise ValueError("No numbers found in the string") - #@global_rate_limiter(25) 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) @@ -476,7 +441,6 @@ class GPTAnswerer: logger.debug("Best option determined: %s", best_option) return best_option - #@global_rate_limiter(25) def resume_or_cover(self, phrase: str) -> str: logger.debug("Determining if phrase refers to resume or cover letter: %s", phrase) prompt_template = """ From 35cc5d3bdea09691505c4c127c7e9f31c425dd0b Mon Sep 17 00:00:00 2001 From: queukat Date: Fri, 6 Sep 2024 20:06:30 +0300 Subject: [PATCH 48/97] resolve issues --- src/gpt.py | 96 +++++++++++++++++++++++++++++++----- src/linkedIn_easy_applier.py | 8 +-- src/linkedIn_job_manager.py | 57 +++++++++++++++------ 3 files changed, 126 insertions(+), 35 deletions(-) diff --git a/src/gpt.py b/src/gpt.py index 1f6a163..9495e2a 100644 --- a/src/gpt.py +++ b/src/gpt.py @@ -3,6 +3,8 @@ import os import re import textwrap import time +from abc import ABC, abstractmethod +from typing import Dict, List, Union from datetime import datetime from functools import wraps from pathlib import Path @@ -17,15 +19,73 @@ from langchain_core.output_parsers import StrOutputParser from langchain_core.prompt_values import StringPromptValue from langchain_core.prompts import ChatPromptTemplate from langchain_openai import ChatOpenAI +from Levenshtein import distance import src.strings as strings from src.utils import logger load_dotenv() +class AIModel(ABC): + @abstractmethod + def invoke(self, prompt: str) -> str: + pass + +class OpenAIModel(AIModel): + def __init__(self, api_key: str, llm_model: str, llm_api_url: str): + from langchain_openai import ChatOpenAI + self.model = ChatOpenAI(model_name=llm_model, openai_api_key=api_key, + temperature=0.4, base_url=llm_api_url) + + def invoke(self, prompt: str) -> str: + print("invoke in openai") + response = self.model.invoke(prompt) + return response + +class ClaudeModel(AIModel): + def __init__(self, api_key: str, llm_model: str, llm_api_url: str): + from langchain_anthropic import ChatAnthropic + self.model = ChatAnthropic(model=llm_model, api_key=api_key, + temperature=0.4, base_url=llm_api_url) + + def invoke(self, prompt: str) -> str: + response = self.model.invoke(prompt) + return response + +class OllamaModel(AIModel): + def __init__(self, api_key: str, llm_model: str, llm_api_url: str): + from langchain_ollama import ChatOllama + self.model = ChatOllama(model=llm_model, base_url=llm_api_url) + + def invoke(self, prompt: str) -> str: + response = self.model.invoke(prompt) + return response + +class AIAdapter: + def __init__(self, config: dict, api_key: str): + self.model = self._create_model(config, api_key) + + def _create_model(self, config: dict, api_key: str) -> AIModel: + llm_model_type = config['llm_model_type'] + llm_model = config['llm_model'] + llm_api_url = config['llm_api_url'] + print('Using {0} with {1} from {2}'.format(llm_model_type, llm_model, llm_api_url)) + + if llm_model_type == "openai": + return OpenAIModel(api_key, llm_model, llm_api_url) + elif llm_model_type == "claude": + return ClaudeModel(api_key, llm_model, llm_api_url) + elif llm_model_type == "ollama": + return OllamaModel(api_key, llm_model, llm_api_url) + else: + raise ValueError(f"Unsupported model type: {model_type}") + + def invoke(self, prompt: str) -> str: + return self.model.invoke(prompt) + class LLMLogger: - def __init__(self, llm: ChatOpenAI): + def __init__(self, llm: Union[OpenAIModel, OllamaModel, ClaudeModel]): logger.debug("Initializing LLMLogger with LLM: %s", llm) self.llm = llm logger.debug("LLMLogger successfully initialized with LLM: %s", llm) @@ -48,6 +108,7 @@ class LLMLogger: prompts = prompts.text logger.debug("Prompts converted to text: %s", prompts) elif isinstance(prompts, Dict): + # Convert prompts to a dictionary if they are not in the expected format logger.debug("Prompts are of type Dict") try: prompts = { @@ -76,7 +137,7 @@ class LLMLogger: except Exception as e: logger.error("Error obtaining current time: %s", str(e)) raise - + # Extract token usage details from the response try: token_usage = parsed_reply["usage_metadata"] output_tokens = token_usage["output_tokens"] @@ -86,14 +147,14 @@ class LLMLogger: except KeyError as e: logger.error("KeyError in parsed_reply structure: %s", str(e)) raise - + # Extract model details from the response try: model_name = parsed_reply["response_metadata"]["model_name"] logger.debug("Model name: %s", model_name) except KeyError as e: logger.error("KeyError in response_metadata: %s", str(e)) raise - + # Calculate the total cost of the API call try: prompt_price_per_token = 0.00000015 completion_price_per_token = 0.0000006 @@ -108,7 +169,7 @@ class LLMLogger: "model": model_name, "time": current_time, "prompts": prompts, - "replies": parsed_reply["content"], # Контент ответа + "replies": parsed_reply["content"], # Response content "total_tokens": total_tokens, "input_tokens": input_tokens, "output_tokens": output_tokens, @@ -118,7 +179,7 @@ class LLMLogger: except KeyError as e: logger.error("Error creating log entry: missing key %s in parsed_reply", str(e)) raise - + # Write the log entry to the log file in JSON format try: with open(calls_log, "a", encoding="utf-8") as f: json_string = json.dumps(log_entry, ensure_ascii=False, indent=4) @@ -130,17 +191,18 @@ class LLMLogger: class LoggerChatModel: - def __init__(self, llm: ChatOpenAI): + def __init__(self, llm: Union[OpenAIModel, OllamaModel, ClaudeModel]): logger.debug("Initializing LoggerChatModel with LLM: %s", llm) self.llm = llm logger.debug("LoggerChatModel successfully initialized with LLM: %s", llm) def __call__(self, messages: List[Dict[str, str]]) -> str: + # Call the LLM with the provided messages and log the response. logger.debug("Entering __call__ method with messages: %s", messages) while True: try: logger.debug("Attempting to call the LLM with messages") - reply = self.llm(messages) # Вызов LLM + reply = self.llm(messages) logger.debug("LLM response received: %s", reply) parsed_reply = self.parse_llmresult(reply) @@ -180,6 +242,8 @@ class LoggerChatModel: continue def parse_llmresult(self, llmresult: AIMessage) -> Dict[str, Dict]: + # Parse the LLM result into a structured format. + logger.debug("Parsing LLM result: %s", llmresult) try: @@ -218,10 +282,9 @@ class LoggerChatModel: class GPTAnswerer: - def __init__(self, openai_api_key): - self.llm_cheap = LoggerChatModel( - ChatOpenAI(model_name="gpt-4o-mini", openai_api_key=openai_api_key, temperature=0.4) - ) + def __init__(self, config, llm_api_key): + self.ai_adapter = AIAdapter(config, llm_api_key) + self.llm_cheap = LoggerChatModel(self.ai_adapter) logger.debug("GPTAnswerer initialized with API key") @property @@ -246,6 +309,7 @@ class GPTAnswerer: @staticmethod def _preprocess_template_string(template: str) -> str: + # Preprocess a template string to remove unnecessary indentation. logger.debug("Preprocessing template string") return textwrap.dedent(template) @@ -279,6 +343,7 @@ class GPTAnswerer: return prompt | self.llm_cheap | StrOutputParser() def answer_question_textual_wide_range(self, question: str) -> str: + # Define chains for each section of the resume logger.debug("Answering textual question: %s", question) chains = { "personal_information": self._create_chain(strings.personal_information_template), @@ -387,7 +452,11 @@ class GPTAnswerer: chain = prompt | self.llm_cheap | StrOutputParser() output = chain.invoke({"question": question}) logger.debug("Section determined from question: %s", output) - section_name = output.lower().replace(" ", "_") + 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}) @@ -442,6 +511,7 @@ class GPTAnswerer: return best_option def resume_or_cover(self, phrase: str) -> str: + # Define the prompt template logger.debug("Determining if phrase refers to resume or cover letter: %s", phrase) prompt_template = """ Given the following phrase, respond with only 'resume' if the phrase is about a resume, or 'cover' if it's about a cover letter. If the phrase contains only the word 'upload', consider it as 'cover'. Do not provide any additional information or explanations. diff --git a/src/linkedIn_easy_applier.py b/src/linkedIn_easy_applier.py index 71139bd..d3f9bbc 100644 --- a/src/linkedIn_easy_applier.py +++ b/src/linkedIn_easy_applier.py @@ -8,7 +8,6 @@ import time import traceback from datetime import date from typing import List, Optional, Any, Tuple - from httpx import HTTPStatusError from openai import RateLimitError from reportlab.lib.pagesizes import letter @@ -175,7 +174,6 @@ class LinkedInEasyApplier: except Exception as e: logger.warning(f"Failed to click 'Easy Apply' button using {method['description']} on attempt {attempt + 1}: {e}") - # Обновление страницы после первой неудачной попытки if attempt == 0: logger.debug("Refreshing page to retry finding 'Easy Apply' button") self.driver.refresh() @@ -530,8 +528,7 @@ class LinkedInEasyApplier: self._enter_text(text_field, existing_answer['answer']) logger.debug("Entered existing textbox answer.") - # Нажать "Вниз" и "Enter" для выбора первого элемента в выпадающем списке - time.sleep(1) # Ожидание появления выпадающего списка + time.sleep(1) text_field.send_keys(Keys.ARROW_DOWN) text_field.send_keys(Keys.ENTER) logger.debug("Selected first option from the dropdown.") @@ -541,8 +538,7 @@ class LinkedInEasyApplier: self._enter_text(text_field, answer) logger.debug("Entered new textbox answer and saved it to JSON.") - # Нажать "Вниз" и "Enter" для выбора первого элемента в выпадающем списке - time.sleep(1) # Ожидание появления выпадающего списка + time.sleep(1) text_field.send_keys(Keys.ARROW_DOWN) text_field.send_keys(Keys.ENTER) logger.debug("Selected first option from the dropdown.") diff --git a/src/linkedIn_job_manager.py b/src/linkedIn_job_manager.py index 9f9c66d..82602dc 100644 --- a/src/linkedIn_job_manager.py +++ b/src/linkedIn_job_manager.py @@ -47,6 +47,7 @@ class LinkedInJobManager: self.title_blacklist = parameters.get('titleBlacklist', []) or [] self.positions = parameters.get('positions', []) self.locations = parameters.get('locations', []) + self.apply_once_at_company = parameters.get('applyOnceAtCompany', False) self.base_search_url = self.get_base_search_url(parameters) self.seen_jobs = [] resume_path = parameters.get('uploads', {}).get('resume', None) @@ -86,7 +87,6 @@ class LinkedInJobManager: time.sleep(random.uniform(1.5, 3.5)) utils.printyellow("Starting the application process for this page...") - # Проверка на наличие вакансий на странице try: jobs = self.get_jobs_from_page() if not jobs: @@ -94,7 +94,7 @@ class LinkedInJobManager: break except Exception as e: logger.error(f"Failed to retrieve jobs: {e}") - break # Выходим из цикла, если не удалось получить вакансии + break try: self.apply_jobs() @@ -136,40 +136,32 @@ class LinkedInJobManager: def get_jobs_from_page(self): - """ - Функция для получения списка вакансий на текущей странице. - Если вакансии не найдены, возвращает пустой список. - """ try: - # Проверка на отсутствие вакансий no_jobs_element = self.driver.find_element(By.CLASS_NAME, 'jobs-search-two-pane__no-results-banner--expand') if 'No matching jobs found' in no_jobs_element.text or 'unfortunately, things aren' in self.driver.page_source.lower(): utils.printyellow("No matching jobs found on this page.") logger.debug("No matching jobs found on this page, skipping.") - return [] # Возвращаем пустой список, если нет вакансий + return [] except NoSuchElementException: - pass # Если элемент не найден, продолжаем поиск вакансий + pass - # Поиск контейнера результатов с вакансиями try: job_results = self.driver.find_element(By.CLASS_NAME, "jobs-search-results-list") utils.scroll_slow(self.driver, job_results) utils.scroll_slow(self.driver, job_results, step=300, reverse=True) - # Поиск элементов списка вакансий job_list_elements = self.driver.find_elements(By.CLASS_NAME, 'scaffold-layout__list-container')[0].find_elements(By.CLASS_NAME, 'jobs-search-results__list-item') if not job_list_elements: utils.printyellow("No job class elements found on page.") logger.debug("No job class elements found on page, skipping.") return [] - # Возвращаем список найденных вакансий return job_list_elements except NoSuchElementException: logger.debug("No job results found on the page.") - return [] # Если не найден контейнер с результатами, возвращаем пустой список + return [] except Exception as e: logger.error(f"Error while fetching job elements: {e}") @@ -181,9 +173,9 @@ class LinkedInJobManager: if 'No matching jobs found' in no_jobs_element.text or 'unfortunately, things aren' in self.driver.page_source.lower(): utils.printyellow("No matching jobs found on this page, moving to next.") logger.debug("No matching jobs found on this page, skipping") - return # Выход из метода, если нет больше подходящих вакансий + return except NoSuchElementException: - pass # Если элемент не найден, просто продолжаем + pass job_results = self.driver.find_element(By.CLASS_NAME, "jobs-search-results-list") utils.scroll_slow(self.driver, job_results) @@ -192,7 +184,7 @@ class LinkedInJobManager: 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 # Выход из метода, если нет вакансий на странице + return job_list = [Job(*self.extract_job_information_from_tile(job_element)) for job_element in job_list_elements] for job in job_list: if self.is_blacklisted(job.title, job.company, job.link): @@ -200,6 +192,12 @@ class LinkedInJobManager: logger.debug("Job blacklisted: %s at %s", job.title, job.company) self.write_to_file(job, "skipped") continue + if self.is_already_applied_to_job(job.title, job.company, job.link): + self.write_to_file(job, "skipped") + continue + if self.is_already_applied_to_company(job.company): + self.write_to_file(job, "skipped") + continue try: if job.apply_method not in {"Continue", "Applied", "Apply"}: self.easy_applier_component.job_apply(job) @@ -301,6 +299,33 @@ class LinkedInJobManager: title_blacklisted = any(word in job_title_words for word in self.title_blacklist) company_blacklisted = company.strip().lower() in (word.strip().lower() for word in self.company_blacklist) link_seen = link in self.seen_jobs + is_blacklisted = title_blacklisted or company_blacklisted or link_seen logger.debug("Job blacklisted status: %s", is_blacklisted) return is_blacklisted + + + def is_already_applied_to_job(self, job_title, company, link): + link_seen = link in self.seen_jobs + if link_seen: + utils.printyellow(f"Already applied to job: {job_title} at {company}, skipping...") + return link_seen + + def is_already_applied_to_company(self, company): + if not self.apply_once_at_company: + return False + + output_files = ["success.json"] + for file_name in output_files: + file_path = self.output_file_directory / file_name + if file_path.exists(): + with open(file_path, 'r', encoding='utf-8') as f: + try: + existing_data = json.load(f) + for applied_job in existing_data: + if applied_job['company'].strip().lower() == company.strip().lower(): + utils.printyellow(f"Already applied at {company} (once per company policy), skipping...") + return True + except json.JSONDecodeError: + continue + return False From dfd79dc4c7ecf46e1c8116389d95cb470e583eb9 Mon Sep 17 00:00:00 2001 From: Shivam Sareen Date: Fri, 6 Sep 2024 12:34:25 -0700 Subject: [PATCH 49/97] 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 b45f3af360ae4cf7c8040d1bff882fa669be092c Mon Sep 17 00:00:00 2001 From: Shivam Sareen Date: Fri, 6 Sep 2024 14:00:42 -0700 Subject: [PATCH 50/97] Update resume_yaml_generator.py Updated resume_yaml_generator.py with the correct api key name present in the secrets.yaml file --- resume_yaml_generator.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/resume_yaml_generator.py b/resume_yaml_generator.py index 46982c2..336a23d 100644 --- a/resume_yaml_generator.py +++ b/resume_yaml_generator.py @@ -21,9 +21,13 @@ def get_api_key() -> str: raise FileNotFoundError(f"Secrets file not found at {secrets_path}") secrets = load_yaml(secrets_path) - api_key = secrets.get('openai_api_key') + + if not 'llm_api_key' in secrets: + raise KeyError("No key as llm_api_key in the secret.yaml") + + api_key = secrets.get('llm_api_key') if not api_key: - raise ValueError("OpenAI API key not found in secrets.yaml") + raise ValueError("LLM API key not found in secrets.yaml") return api_key @@ -153,4 +157,4 @@ def main(): print(f"An error occurred: {e}") if __name__ == "__main__": - main() \ No newline at end of file + main() From b6ceeb44ac29d8c434aa7eb73b8c05836a6f683d Mon Sep 17 00:00:00 2001 From: queukat Date: Sat, 7 Sep 2024 14:15:32 +0300 Subject: [PATCH 51/97] new func --- src/gpt.py | 133 +++------- src/job_application_profile.py | 8 +- src/linkedIn_authenticator.py | 7 +- src/linkedIn_bot_facade.py | 1 + src/linkedIn_easy_applier.py | 434 ++++++++++++++++++++------------- src/linkedIn_job_manager.py | 73 ++---- src/strings.py | 4 +- src/utils.py | 91 ++++--- 8 files changed, 406 insertions(+), 345 deletions(-) diff --git a/src/gpt.py b/src/gpt.py index 9495e2a..97a1168 100644 --- a/src/gpt.py +++ b/src/gpt.py @@ -3,89 +3,28 @@ import os import re import textwrap import time -from abc import ABC, abstractmethod -from typing import Dict, List, Union from datetime import datetime -from functools import wraps from pathlib import Path from typing import Dict, List import httpx from Levenshtein import distance from dotenv import load_dotenv -from httpx import HTTPStatusError from langchain_core.messages.ai import AIMessage from langchain_core.output_parsers import StrOutputParser from langchain_core.prompt_values import StringPromptValue from langchain_core.prompts import ChatPromptTemplate from langchain_openai import ChatOpenAI -from Levenshtein import distance import src.strings as strings from src.utils import logger load_dotenv() -class AIModel(ABC): - @abstractmethod - def invoke(self, prompt: str) -> str: - pass - -class OpenAIModel(AIModel): - def __init__(self, api_key: str, llm_model: str, llm_api_url: str): - from langchain_openai import ChatOpenAI - self.model = ChatOpenAI(model_name=llm_model, openai_api_key=api_key, - temperature=0.4, base_url=llm_api_url) - - def invoke(self, prompt: str) -> str: - print("invoke in openai") - response = self.model.invoke(prompt) - return response - -class ClaudeModel(AIModel): - def __init__(self, api_key: str, llm_model: str, llm_api_url: str): - from langchain_anthropic import ChatAnthropic - self.model = ChatAnthropic(model=llm_model, api_key=api_key, - temperature=0.4, base_url=llm_api_url) - - def invoke(self, prompt: str) -> str: - response = self.model.invoke(prompt) - return response - -class OllamaModel(AIModel): - def __init__(self, api_key: str, llm_model: str, llm_api_url: str): - from langchain_ollama import ChatOllama - self.model = ChatOllama(model=llm_model, base_url=llm_api_url) - - def invoke(self, prompt: str) -> str: - response = self.model.invoke(prompt) - return response - -class AIAdapter: - def __init__(self, config: dict, api_key: str): - self.model = self._create_model(config, api_key) - - def _create_model(self, config: dict, api_key: str) -> AIModel: - llm_model_type = config['llm_model_type'] - llm_model = config['llm_model'] - llm_api_url = config['llm_api_url'] - print('Using {0} with {1} from {2}'.format(llm_model_type, llm_model, llm_api_url)) - - if llm_model_type == "openai": - return OpenAIModel(api_key, llm_model, llm_api_url) - elif llm_model_type == "claude": - return ClaudeModel(api_key, llm_model, llm_api_url) - elif llm_model_type == "ollama": - return OllamaModel(api_key, llm_model, llm_api_url) - else: - raise ValueError(f"Unsupported model type: {model_type}") - - def invoke(self, prompt: str) -> str: - return self.model.invoke(prompt) class LLMLogger: - - def __init__(self, llm: Union[OpenAIModel, OllamaModel, ClaudeModel]): + + def __init__(self, llm: ChatOpenAI): logger.debug("Initializing LLMLogger with LLM: %s", llm) self.llm = llm logger.debug("LLMLogger successfully initialized with LLM: %s", llm) @@ -108,11 +47,10 @@ class LLMLogger: prompts = prompts.text logger.debug("Prompts converted to text: %s", prompts) elif isinstance(prompts, Dict): - # Convert prompts to a dictionary if they are not in the expected format logger.debug("Prompts are of type Dict") try: prompts = { - f"prompt_{i+1}": prompt.content + f"prompt_{i + 1}": prompt.content for i, prompt in enumerate(prompts.messages) } logger.debug("Prompts converted to dictionary: %s", prompts) @@ -123,7 +61,7 @@ class LLMLogger: logger.debug("Prompts are of unknown type, attempting default conversion") try: prompts = { - f"prompt_{i+1}": prompt.content + f"prompt_{i + 1}": prompt.content for i, prompt in enumerate(prompts.messages) } logger.debug("Prompts converted to dictionary using default method: %s", prompts) @@ -137,7 +75,7 @@ class LLMLogger: except Exception as e: logger.error("Error obtaining current time: %s", str(e)) raise - # Extract token usage details from the response + try: token_usage = parsed_reply["usage_metadata"] output_tokens = token_usage["output_tokens"] @@ -147,14 +85,14 @@ class LLMLogger: except KeyError as e: logger.error("KeyError in parsed_reply structure: %s", str(e)) raise - # Extract model details from the response + try: model_name = parsed_reply["response_metadata"]["model_name"] logger.debug("Model name: %s", model_name) except KeyError as e: logger.error("KeyError in response_metadata: %s", str(e)) raise - # Calculate the total cost of the API call + try: prompt_price_per_token = 0.00000015 completion_price_per_token = 0.0000006 @@ -169,7 +107,7 @@ class LLMLogger: "model": model_name, "time": current_time, "prompts": prompts, - "replies": parsed_reply["content"], # Response content + "replies": parsed_reply["content"], "total_tokens": total_tokens, "input_tokens": input_tokens, "output_tokens": output_tokens, @@ -179,7 +117,7 @@ class LLMLogger: except KeyError as e: logger.error("Error creating log entry: missing key %s in parsed_reply", str(e)) raise - # Write the log entry to the log file in JSON format + try: with open(calls_log, "a", encoding="utf-8") as f: json_string = json.dumps(log_entry, ensure_ascii=False, indent=4) @@ -191,13 +129,12 @@ class LLMLogger: class LoggerChatModel: - def __init__(self, llm: Union[OpenAIModel, OllamaModel, ClaudeModel]): + def __init__(self, llm: ChatOpenAI): logger.debug("Initializing LoggerChatModel with LLM: %s", llm) self.llm = llm logger.debug("LoggerChatModel successfully initialized with LLM: %s", llm) def __call__(self, messages: List[Dict[str, str]]) -> str: - # Call the LLM with the provided messages and log the response. logger.debug("Entering __call__ method with messages: %s", messages) while True: try: @@ -221,18 +158,25 @@ class LoggerChatModel: if retry_after: wait_time = int(retry_after) - logger.warning("Rate limit exceeded. Waiting for %d seconds before retrying (extracted from 'retry-after' header)...", wait_time) + logger.warning( + "Rate limit exceeded. Waiting for %d seconds before retrying (extracted from 'retry-after' header)...", + wait_time) time.sleep(wait_time) elif retry_after_ms: wait_time = int(retry_after_ms) / 1000.0 - logger.warning("Rate limit exceeded. Waiting for %f seconds before retrying (extracted from 'retry-after-ms' header)...", wait_time) + logger.warning( + "Rate limit exceeded. Waiting for %f seconds before retrying (extracted from 'retry-after-ms' header)...", + wait_time) time.sleep(wait_time) else: - wait_time = 30 # Время ожидания по умолчанию - logger.warning("'retry-after' header not found. Waiting for %d seconds before retrying (default)...", wait_time) + wait_time = 30 + logger.warning( + "'retry-after' header not found. Waiting for %d seconds before retrying (default)...", + wait_time) time.sleep(wait_time) else: - logger.error("HTTP error occurred with status code: %d, waiting 30 seconds before retrying", e.response.status_code) + logger.error("HTTP error occurred with status code: %d, waiting 30 seconds before retrying", + e.response.status_code) time.sleep(30) except Exception as e: @@ -242,8 +186,6 @@ class LoggerChatModel: continue def parse_llmresult(self, llmresult: AIMessage) -> Dict[str, Dict]: - # Parse the LLM result into a structured format. - logger.debug("Parsing LLM result: %s", llmresult) try: @@ -280,11 +222,11 @@ class LoggerChatModel: raise - class GPTAnswerer: - def __init__(self, config, llm_api_key): - self.ai_adapter = AIAdapter(config, llm_api_key) - self.llm_cheap = LoggerChatModel(self.ai_adapter) + def __init__(self, openai_api_key): + self.llm_cheap = LoggerChatModel( + ChatOpenAI(model_name="gpt-4o-mini", openai_api_key=openai_api_key, temperature=0.4) + ) logger.debug("GPTAnswerer initialized with API key") @property @@ -309,7 +251,6 @@ class GPTAnswerer: @staticmethod def _preprocess_template_string(template: str) -> str: - # Preprocess a template string to remove unnecessary indentation. logger.debug("Preprocessing template string") return textwrap.dedent(template) @@ -336,14 +277,13 @@ class GPTAnswerer: output = chain.invoke({"text": text}) logger.debug("Summary generated: %s", output) return output - + def _create_chain(self, template: str): logger.debug("Creating chain with template: %s", template) prompt = ChatPromptTemplate.from_template(template) return prompt | self.llm_cheap | StrOutputParser() def answer_question_textual_wide_range(self, question: str) -> str: - # Define chains for each section of the resume logger.debug("Answering textual question: %s", question) chains = { "personal_information": self._create_chain(strings.personal_information_template), @@ -452,17 +392,14 @@ class GPTAnswerer: chain = prompt | self.llm_cheap | StrOutputParser() output = chain.invoke({"question": question}) logger.debug("Section determined from question: %s", output) - match = re.search(r"(Personal information|Self Identification|Legal Authorization|Work Preferences|Education Details|Experience Details|Projects|Availability|Salary Expectations|Certifications|Languages|Interests|Cover letter)", output, re.IGNORECASE) - if not match: - raise ValueError("Could not extract section name from the response.") - - section_name = match.group(1).lower().replace(" ", "_") + section_name = output.lower().replace(" ", "_") if section_name == "cover_letter": chain = chains.get(section_name) output = chain.invoke({"resume": self.resume, "job_description": self.job_description}) logger.debug("Cover letter generated: %s", output) return output - resume_section = getattr(self.resume, section_name, None) or getattr(self.job_application_profile, section_name, None) + resume_section = getattr(self.resume, section_name, None) or getattr(self.job_application_profile, section_name, + None) if resume_section is None: logger.error("Section '%s' not found in either resume or job_application_profile.", section_name) raise ValueError(f"Section '{section_name}' not found in either resume or job_application_profile.") @@ -479,7 +416,9 @@ class GPTAnswerer: func_template = self._preprocess_template_string(strings.numeric_question_template) prompt = ChatPromptTemplate.from_template(func_template) chain = prompt | self.llm_cheap | StrOutputParser() - output_str = chain.invoke({"resume_educations": self.resume.education_details,"resume_jobs": self.resume.experience_details,"resume_projects": self.resume.projects , "question": question}) + output_str = chain.invoke( + {"resume_educations": self.resume.education_details, "resume_jobs": self.resume.experience_details, + "resume_projects": self.resume.projects, "question": question}) logger.debug("Raw output for numeric question: %s", output_str) try: output = self.extract_number_from_string(output_str) @@ -511,10 +450,12 @@ class GPTAnswerer: return best_option def resume_or_cover(self, phrase: str) -> str: - # Define the prompt template logger.debug("Determining if phrase refers to resume or cover letter: %s", phrase) prompt_template = """ - Given the following phrase, respond with only 'resume' if the phrase is about a resume, or 'cover' if it's about a cover letter. If the phrase contains only the word 'upload', consider it as 'cover'. Do not provide any additional information or explanations. + Given the following phrase, respond with only 'resume' if the phrase is about a resume, or 'cover' if it's about a cover letter. + If the phrase contains only one word 'upload', consider it as 'cover'. + If the phrase contains 'upload resume', consider it as 'resume'. + Do not provide any additional information or explanations. phrase: {phrase} """ diff --git a/src/job_application_profile.py b/src/job_application_profile.py index 43c4db1..5330c2b 100644 --- a/src/job_application_profile.py +++ b/src/job_application_profile.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Dict, List + import yaml from src.utils import logger @@ -13,6 +13,7 @@ class SelfIdentification: disability: str ethnicity: str + @dataclass class LegalAuthorization: eu_work_authorization: str @@ -24,6 +25,7 @@ class LegalAuthorization: legally_allowed_to_work_in_eu: str requires_eu_sponsorship: str + @dataclass class WorkPreferences: remote_work: str @@ -33,14 +35,17 @@ class WorkPreferences: willing_to_undergo_drug_tests: str willing_to_undergo_background_checks: str + @dataclass class Availability: notice_period: str + @dataclass class SalaryExpectations: salary_range_usd: str + @dataclass class JobApplicationProfile: self_identification: SelfIdentification @@ -159,6 +164,7 @@ class JobApplicationProfile: def __str__(self): logger.debug("Generating string representation of JobApplicationProfile") + def format_dataclass(obj): return "\n".join(f"{field.name}: {getattr(obj, field.name)}" for field in obj.__dataclass_fields__.values()) diff --git a/src/linkedIn_authenticator.py b/src/linkedIn_authenticator.py index d84fc22..8136d89 100644 --- a/src/linkedIn_authenticator.py +++ b/src/linkedIn_authenticator.py @@ -1,15 +1,16 @@ import random import time + from selenium.common.exceptions import NoSuchElementException, TimeoutException from selenium.webdriver.common.by import By -from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC +from selenium.webdriver.support.ui import WebDriverWait from src.utils import logger class LinkedInAuthenticator: - + def __init__(self, driver=None): self.driver = driver self.email = "" @@ -107,7 +108,6 @@ class LinkedInAuthenticator: buttons = self.driver.find_elements(By.CLASS_NAME, 'share-box-feed-entry__trigger') logger.debug("Found %d 'Start a post' buttons", len(buttons)) - # Выведем текст всех найденных кнопок в лог для диагностики for i, button in enumerate(buttons): logger.debug("Button %d text: %s", i + 1, button.text.strip()) @@ -115,7 +115,6 @@ class LinkedInAuthenticator: logger.info("Found 'Start a post' button indicating user is logged in.") return True - # Альтернативная проверка авторизации по наличию изображения профиля profile_img_elements = self.driver.find_elements(By.XPATH, "//img[contains(@alt, 'Photo of')]") if profile_img_elements: logger.info("Profile image found. Assuming user is logged in.") diff --git a/src/linkedIn_bot_facade.py b/src/linkedIn_bot_facade.py index f87b9da..2f1732c 100644 --- a/src/linkedIn_bot_facade.py +++ b/src/linkedIn_bot_facade.py @@ -23,6 +23,7 @@ class LinkedInBotState: raise ValueError(f"{key.replace('_', ' ').capitalize()} must be set before proceeding.") logger.debug("State validation passed") + class LinkedInBotFacade: def __init__(self, login_component, apply_component): logger.debug("Initializing LinkedInBotFacade") diff --git a/src/linkedIn_easy_applier.py b/src/linkedIn_easy_applier.py index d3f9bbc..137af0b 100644 --- a/src/linkedIn_easy_applier.py +++ b/src/linkedIn_easy_applier.py @@ -3,26 +3,28 @@ import json import os import random import re -import tempfile import time import traceback -from datetime import date from typing import List, Optional, Any, Tuple + from httpx import HTTPStatusError -from openai import RateLimitError from reportlab.lib.pagesizes import letter from reportlab.pdfgen import canvas from selenium.common.exceptions import NoSuchElementException, TimeoutException +from selenium.webdriver import ActionChains from selenium.webdriver.common.by import By from selenium.webdriver.common.keys import Keys from selenium.webdriver.remote.webelement import WebElement from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.support.ui import Select, WebDriverWait -from selenium.webdriver import ActionChains + import src.utils as utils from src.utils import logger + + class LinkedInEasyApplier: - def __init__(self, driver: Any, resume_dir: Optional[str], set_old_answers: List[Tuple[str, str, str]], gpt_answerer: Any, resume_generator_manager): + def __init__(self, driver: Any, resume_dir: Optional[str], set_old_answers: List[Tuple[str, str, str]], + gpt_answerer: Any, resume_generator_manager): logger.debug("Initializing LinkedInEasyApplier") if resume_dir is None or not os.path.exists(resume_dir): resume_dir = None @@ -56,11 +58,28 @@ class LinkedInEasyApplier: logger.error("Error loading questions data from JSON file: %s", tb_str) raise Exception(f"Error loading questions data from JSON file: \nTraceback:\n{tb_str}") + def check_for_premium_redirect(self, job: Any, max_attempts=3): + """Проверяет, был ли выполнен редирект на страницу LinkedIn Premium. + В случае редиректа возвращает пользователя на исходную страницу вакансии.""" + current_url = self.driver.current_url + attempts = 0 + + while "linkedin.com/premium" in current_url and attempts < max_attempts: + logger.warning("Redirected to LinkedIn Premium page. Attempting to return to job page.") + attempts += 1 + + self.driver.get(job.link) + time.sleep(2) + current_url = self.driver.current_url + + if "linkedin.com/premium" in current_url: + logger.error("Failed to return to job page after %d attempts. Cannot apply for the job.", max_attempts) + raise Exception( + f"Redirected to LinkedIn Premium page and failed to return after {max_attempts} attempts. Job application aborted.") def job_apply(self, job: Any): logger.debug("Starting job application for job: %s", job) - # Открываем страницу с вакансией try: self.driver.get(job.link) logger.debug("Navigated to job link: %s", job.link) @@ -68,62 +87,60 @@ class LinkedInEasyApplier: logger.error("Failed to navigate to job link: %s, error: %s", job.link, str(e)) raise - # Добавляем небольшую паузу для загрузки страницы time.sleep(random.uniform(3, 5)) + self.check_for_premium_redirect(job) try: - # Поиск кнопки 'Easy Apply' - logger.debug("Searching for 'Easy Apply' button on job page") - easy_apply_button = self._find_easy_apply_button() - # Получаем описание вакансии + self.driver.execute_script("document.activeElement.blur();") + logger.debug("Focus removed from the active element") + + self.check_for_premium_redirect(job) + + easy_apply_button = self._find_easy_apply_button(job) + + self.check_for_premium_redirect(job) + logger.debug("Retrieving job description") job_description = self._get_job_description() job.set_job_description(job_description) - logger.debug("Job description set: %s", job_description[:100]) # Логируем только первые 100 символов + logger.debug("Job description set: %s", job_description[:100]) - # Получаем ссылку на рекрутера (если есть) logger.debug("Retrieving recruiter link") recruiter_link = self._get_job_recruiter() job.set_recruiter_link(recruiter_link) logger.debug("Recruiter link set: %s", recruiter_link) - # Действие: нажимаем на кнопку 'Easy Apply' logger.debug("Attempting to click 'Easy Apply' button") actions = ActionChains(self.driver) actions.move_to_element(easy_apply_button).click().perform() logger.debug("'Easy Apply' button clicked successfully") - # Передача информации о работе для дальнейшей обработки logger.debug("Passing job information to GPT Answerer") self.gpt_answerer.set_job(job) - # Заполнение формы подачи заявки logger.debug("Filling out application form") self._fill_application_form(job) logger.debug("Job application process completed successfully for job: %s", job) except Exception as e: - # Захват и логирование полного traceback в случае ошибки + tb_str = traceback.format_exc() logger.error("Failed to apply to job: %s. Error traceback: %s", job, tb_str) - # Отмена заявки в случае ошибки logger.debug("Discarding application due to failure") self._discard_application() - # Поднятие исключения с оригинальной ошибкой raise Exception(f"Failed to apply to job! Original exception:\nTraceback:\n{tb_str}") - def _find_easy_apply_button(self) -> WebElement: + def _find_easy_apply_button(self, job: Any) -> WebElement: logger.debug("Searching for 'Easy Apply' button") attempt = 0 - # Список методов поиска кнопки search_methods = [ { 'description': "find all 'Easy Apply' buttons using find_elements", - 'find_elements': True, # Используем find_elements для поиска всех кнопок + 'find_elements': True, 'xpath': '//button[contains(@class, "jobs-apply-button") and contains(., "Easy Apply")]' }, { @@ -137,20 +154,21 @@ class LinkedInEasyApplier: ] while attempt < 2: + + self.check_for_premium_redirect(job) self._scroll_page() for method in search_methods: try: logger.debug(f"Attempting search using {method['description']}") - # Если метод использует find_elements if method.get('find_elements'): # Поиск всех кнопок "Easy Apply" buttons = self.driver.find_elements(By.XPATH, method['xpath']) if buttons: for index, button in enumerate(buttons): try: - # Проверка видимости и кликабельности каждой кнопки + WebDriverWait(self.driver, 10).until(EC.visibility_of(button)) WebDriverWait(self.driver, 10).until(EC.element_to_be_clickable(button)) logger.debug(f"Found 'Easy Apply' button {index + 1}, attempting to click") @@ -160,7 +178,7 @@ class LinkedInEasyApplier: else: raise TimeoutException("No 'Easy Apply' buttons found") else: - # Стандартный метод с WebDriverWait для одного элемента + button = WebDriverWait(self.driver, 10).until( EC.presence_of_element_located((By.XPATH, method['xpath'])) ) @@ -172,7 +190,10 @@ class LinkedInEasyApplier: except TimeoutException: logger.warning(f"Timeout during search using {method['description']}") except Exception as e: - logger.warning(f"Failed to click 'Easy Apply' button using {method['description']} on attempt {attempt + 1}: {e}") + logger.warning( + f"Failed to click 'Easy Apply' button using {method['description']} on attempt {attempt + 1}: {e}") + + self.check_for_premium_redirect(job) if attempt == 0: logger.debug("Refreshing page to retry finding 'Easy Apply' button") @@ -180,7 +201,6 @@ class LinkedInEasyApplier: time.sleep(random.randint(3, 5)) attempt += 1 - # Если не удалось найти кнопку, выводим HTML для отладки page_source = self.driver.page_source logger.error("No clickable 'Easy Apply' button found after 2 attempts. Page source:\n%s", page_source) raise Exception("No clickable 'Easy Apply' button found") @@ -189,7 +209,8 @@ class LinkedInEasyApplier: logger.debug("Getting job description") try: try: - see_more_button = self.driver.find_element(By.XPATH, '//button[@aria-label="Click to see more description"]') + see_more_button = self.driver.find_element(By.XPATH, + '//button[@aria-label="Click to see more description"]') actions = ActionChains(self.driver) actions.move_to_element(see_more_button).click().perform() time.sleep(2) @@ -216,7 +237,8 @@ class LinkedInEasyApplier: ) logger.debug("Hiring team section found") - recruiter_elements = hiring_team_section.find_elements(By.XPATH, './/following::a[contains(@href, "linkedin.com/in/")]') + recruiter_elements = hiring_team_section.find_elements(By.XPATH, + './/following::a[contains(@href, "linkedin.com/in/")]') if recruiter_elements: recruiter_element = recruiter_elements[0] @@ -289,18 +311,17 @@ class LinkedInEasyApplier: def fill_up(self, job) -> None: logger.debug("Filling up form sections for job: %s", job) - # Используем WebDriverWait для ожидания элемента с классом 'jobs-easy-apply-content' try: easy_apply_content = WebDriverWait(self.driver, 10).until( EC.presence_of_element_located((By.CLASS_NAME, 'jobs-easy-apply-content')) ) - # После нахождения 'jobs-easy-apply-content' ищем элементы с классом 'pb4' pb4_elements = easy_apply_content.find_elements(By.CLASS_NAME, 'pb4') for element in pb4_elements: self._process_form_element(element, job) except Exception as e: logger.error(f"Failed to find form elements: {e}") + def _process_form_element(self, element: WebElement, job) -> None: logger.debug("Processing form element") if self._is_upload_field(element): @@ -308,6 +329,47 @@ class LinkedInEasyApplier: else: self._fill_additional_questions() + def _handle_dropdown_fields(self, element: WebElement) -> None: + logger.debug("Handling dropdown fields") + + dropdown = element.find_element(By.TAG_NAME, 'select') + select = Select(dropdown) + + options = [option.text for option in select.options] + logger.debug(f"Dropdown options found: {options}") + + parent_element = dropdown.find_element(By.XPATH, '../..') + + label_elements = parent_element.find_elements(By.TAG_NAME, 'label') + if label_elements: + question_text = label_elements[0].text.lower() + else: + question_text = "unknown" + + logger.debug(f"Detected question text: {question_text}") + + existing_answer = None + for item in self.all_data: + if self._sanitize_text(question_text) in item['question'] and item['type'] == 'dropdown': + existing_answer = item['answer'] + break + + if existing_answer: + logger.debug(f"Found existing answer for question '{question_text}': {existing_answer}") + else: + + logger.debug(f"No existing answer found, querying model for: {question_text}") + existing_answer = self.gpt_answerer.answer_question_from_options(question_text, options) + logger.debug(f"Model provided answer: {existing_answer}") + self._save_questions_to_json({'type': 'dropdown', 'question': question_text, 'answer': existing_answer}) + + if existing_answer in options: + select.select_by_visible_text(existing_answer) + logger.debug(f"Selected option: {existing_answer}") + else: + logger.error(f"Answer '{existing_answer}' is not a valid option in the dropdown") + raise Exception(f"Invalid option selected: {existing_answer}") + def _is_upload_field(self, element: WebElement) -> bool: is_upload = bool(element.find_elements(By.XPATH, ".//input[@type='file']")) logger.debug("Element is upload field: %s", is_upload) @@ -317,7 +379,8 @@ class LinkedInEasyApplier: logger.debug("Handling upload fields") try: - show_more_button = self.driver.find_element(By.XPATH, "//button[contains(@aria-label, 'Show more resumes')]") + show_more_button = self.driver.find_element(By.XPATH, + "//button[contains(@aria-label, 'Show more resumes')]") show_more_button.click() logger.debug("Clicked 'Show more resumes' button") except NoSuchElementException: @@ -339,112 +402,160 @@ class LinkedInEasyApplier: self._create_and_upload_resume(element, job) elif 'cover' in output: logger.debug("Uploading cover letter") - self._create_and_upload_cover_letter(element) + self._create_and_upload_cover_letter(element, job) logger.debug("Finished handling upload fields") def _create_and_upload_resume(self, element, job): - logger.debug("Starting the process of creating and uploading resume.") - folder_path = 'generated_cv' + logger.debug("Starting the process of creating and uploading resume.") + folder_path = 'generated_cv' + try: + if not os.path.exists(folder_path): + logger.debug(f"Creating directory at path: {folder_path}") + os.makedirs(folder_path, exist_ok=True) + except Exception as e: + logger.error(f"Failed to create directory: {folder_path}. Error: {e}") + raise + + while True: try: - if not os.path.exists(folder_path): - logger.debug(f"Creating directory at path: {folder_path}") - os.makedirs(folder_path, exist_ok=True) + timestamp = int(time.time()) + file_path_pdf = os.path.join(folder_path, f"CV_{timestamp}.pdf") + logger.debug(f"Generated file path for resume: {file_path_pdf}") + + logger.debug(f"Generating resume for job: {job.title} at {job.company}") + resume_pdf_base64 = self.resume_generator_manager.pdf_base64(job_description_text=job.description) + with open(file_path_pdf, "xb") as f: + f.write(base64.b64decode(resume_pdf_base64)) + logger.debug(f"Resume successfully generated and saved to: {file_path_pdf}") + + break + except HTTPStatusError as e: + if e.response.status_code == 429: + + retry_after = e.response.headers.get('retry-after') + retry_after_ms = e.response.headers.get('retry-after-ms') + + if retry_after: + wait_time = int(retry_after) + logger.warning(f"Rate limit exceeded, waiting {wait_time} seconds before retrying...") + elif retry_after_ms: + wait_time = int(retry_after_ms) / 1000.0 + logger.warning(f"Rate limit exceeded, waiting {wait_time} milliseconds before retrying...") + else: + wait_time = 20 + logger.warning(f"Rate limit exceeded, waiting {wait_time} seconds before retrying...") + + time.sleep(wait_time) + else: + logger.error(f"HTTP error: {e}") + raise + except Exception as e: - logger.error(f"Failed to create directory: {folder_path}. Error: {e}") + logger.error(f"Failed to generate resume: {e}") + tb_str = traceback.format_exc() + logger.error(f"Traceback: {tb_str}") + if "RateLimitError" in str(e): + logger.warning("Rate limit error encountered, retrying...") + time.sleep(20) + else: + raise + + file_size = os.path.getsize(file_path_pdf) + max_file_size = 2 * 1024 * 1024 # 2 MB + logger.debug(f"Resume file size: {file_size} bytes") + if file_size > max_file_size: + logger.error(f"Resume file size exceeds 2 MB: {file_size} bytes") + raise ValueError("Resume file size exceeds the maximum limit of 2 MB.") + + allowed_extensions = {'.pdf', '.doc', '.docx'} + file_extension = os.path.splitext(file_path_pdf)[1].lower() + logger.debug(f"Resume file extension: {file_extension}") + if file_extension not in allowed_extensions: + logger.error(f"Invalid resume file format: {file_extension}") + raise ValueError("Resume file format is not allowed. Only PDF, DOC, and DOCX formats are supported.") + + try: + logger.debug(f"Uploading resume from path: {file_path_pdf}") + element.send_keys(os.path.abspath(file_path_pdf)) + job.pdf_path = os.path.abspath(file_path_pdf) + time.sleep(2) + logger.debug(f"Resume created and uploaded successfully: {file_path_pdf}") + except Exception as e: + tb_str = traceback.format_exc() + logger.error(f"Resume upload failed: {tb_str}") + raise Exception(f"Upload failed: \nTraceback:\n{tb_str}") + + def _create_and_upload_cover_letter(self, element: WebElement, job) -> None: + logger.debug("Starting the process of creating and uploading cover letter.") + + cover_letter_text = self.gpt_answerer.answer_question_textual_wide_range("Write a cover letter") + + folder_path = 'generated_cv' + + try: + + if not os.path.exists(folder_path): + logger.debug(f"Creating directory at path: {folder_path}") + os.makedirs(folder_path, exist_ok=True) + except Exception as e: + logger.error(f"Failed to create directory: {folder_path}. Error: {e}") + raise + + while True: + try: + timestamp = int(time.time()) + file_path_pdf = os.path.join(folder_path, f"Cover_Letter_{timestamp}.pdf") + logger.debug(f"Generated file path for cover letter: {file_path_pdf}") + + c = canvas.Canvas(file_path_pdf, pagesize=letter) + _, height = letter + text_object = c.beginText(100, height - 100) + text_object.setFont("Helvetica", 12) + text_object.textLines(cover_letter_text) + c.drawText(text_object) + c.save() + logger.debug(f"Cover letter successfully generated and saved to: {file_path_pdf}") + + break + except Exception as e: + logger.error(f"Failed to generate cover letter: {e}") + tb_str = traceback.format_exc() + logger.error(f"Traceback: {tb_str}") raise - while True: - try: - timestamp = int(time.time()) - file_path_pdf = os.path.join(folder_path, f"CV_{timestamp}.pdf") - logger.debug(f"Generated file path for resume: {file_path_pdf}") + file_size = os.path.getsize(file_path_pdf) + max_file_size = 2 * 1024 * 1024 # 2 MB + logger.debug(f"Cover letter file size: {file_size} bytes") + if file_size > max_file_size: + logger.error(f"Cover letter file size exceeds 2 MB: {file_size} bytes") + raise ValueError("Cover letter file size exceeds the maximum limit of 2 MB.") - logger.debug(f"Generating resume for job: {job.title} at {job.company}") - resume_pdf_base64 = self.resume_generator_manager.pdf_base64(job_description_text=job.description) - with open(file_path_pdf, "xb") as f: - f.write(base64.b64decode(resume_pdf_base64)) - logger.debug(f"Resume successfully generated and saved to: {file_path_pdf}") + allowed_extensions = {'.pdf', '.doc', '.docx'} + file_extension = os.path.splitext(file_path_pdf)[1].lower() + logger.debug(f"Cover letter file extension: {file_extension}") + if file_extension not in allowed_extensions: + logger.error(f"Invalid cover letter file format: {file_extension}") + raise ValueError("Cover letter file format is not allowed. Only PDF, DOC, and DOCX formats are supported.") - break - except HTTPStatusError as e: - if e.response.status_code == 429: + try: - retry_after = e.response.headers.get('retry-after') - retry_after_ms = e.response.headers.get('retry-after-ms') - - if retry_after: - wait_time = int(retry_after) - logger.warning(f"Rate limit exceeded, waiting {wait_time} seconds before retrying...") - elif retry_after_ms: - wait_time = int(retry_after_ms) / 1000.0 - logger.warning(f"Rate limit exceeded, waiting {wait_time} milliseconds before retrying...") - else: - wait_time = 20 - logger.warning(f"Rate limit exceeded, waiting {wait_time} seconds before retrying...") - - time.sleep(wait_time) - else: - logger.error(f"HTTP error: {e}") - raise - - except Exception as e: - logger.error(f"Failed to generate resume: {e}") - tb_str = traceback.format_exc() - logger.error(f"Traceback: {tb_str}") - if "RateLimitError" in str(e): - logger.warning("Rate limit error encountered, retrying...") - time.sleep(20) - else: - raise - - file_size = os.path.getsize(file_path_pdf) - max_file_size = 2 * 1024 * 1024 # 2 MB - logger.debug(f"Resume file size: {file_size} bytes") - if file_size > max_file_size: - logger.error(f"Resume file size exceeds 2 MB: {file_size} bytes") - raise ValueError("Resume file size exceeds the maximum limit of 2 MB.") - - allowed_extensions = {'.pdf', '.doc', '.docx'} - file_extension = os.path.splitext(file_path_pdf)[1].lower() - logger.debug(f"Resume file extension: {file_extension}") - if file_extension not in allowed_extensions: - logger.error(f"Invalid resume file format: {file_extension}") - raise ValueError("Resume file format is not allowed. Only PDF, DOC, and DOCX formats are supported.") - - try: - logger.debug(f"Uploading resume from path: {file_path_pdf}") - element.send_keys(os.path.abspath(file_path_pdf)) - job.pdf_path = os.path.abspath(file_path_pdf) - time.sleep(2) - logger.debug(f"Resume created and uploaded successfully: {file_path_pdf}") - except Exception as e: - tb_str = traceback.format_exc() - logger.error(f"Resume upload failed: {tb_str}") - raise Exception(f"Upload failed: \nTraceback:\n{tb_str}") - - def _create_and_upload_cover_letter(self, element: WebElement) -> None: - logger.debug("Creating and uploading cover letter") - cover_letter = self.gpt_answerer.answer_question_textual_wide_range("Write a cover letter") - with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as temp_pdf_file: - letter_path = temp_pdf_file.name - c = canvas.Canvas(letter_path, pagesize=letter) - _, height = letter - text_object = c.beginText(100, height - 100) - text_object.setFont("Helvetica", 12) - text_object.textLines(cover_letter) - c.drawText(text_object) - c.save() - element.send_keys(letter_path) - logger.debug("Cover letter created and uploaded successfully: %s", letter_path) + logger.debug(f"Uploading cover letter from path: {file_path_pdf}") + element.send_keys(os.path.abspath(file_path_pdf)) + job.cover_letter_path = os.path.abspath(file_path_pdf) + time.sleep(2) + logger.debug(f"Cover letter created and uploaded successfully: {file_path_pdf}") + except Exception as e: + tb_str = traceback.format_exc() + logger.error(f"Cover letter upload failed: {tb_str}") + raise Exception(f"Upload failed: \nTraceback:\n{tb_str}") def _fill_additional_questions(self) -> None: logger.debug("Filling additional questions") form_sections = self.driver.find_elements(By.CLASS_NAME, 'jobs-easy-apply-form-section__grouping') for section in form_sections: self._process_form_section(section) - def _process_form_section(self, section: WebElement) -> None: logger.debug("Processing form section") @@ -460,13 +571,15 @@ class LinkedInEasyApplier: if self._find_and_handle_date_question(section): logger.debug("Handled date question") return + if self._find_and_handle_dropdown_question(section): logger.debug("Handled dropdown question") return def _handle_terms_of_service(self, element: WebElement) -> bool: checkbox = element.find_elements(By.TAG_NAME, 'label') - if checkbox and any(term in checkbox[0].text.lower() for term in ['terms of service', 'privacy policy', 'terms of use']): + if checkbox and any( + term in checkbox[0].text.lower() for term in ['terms of service', 'privacy policy', 'terms of use']): checkbox[0].click() logger.debug("Clicked terms of service checkbox") return True @@ -478,7 +591,7 @@ class LinkedInEasyApplier: if radios: question_text = section.text.lower() options = [radio.text.lower() for radio in radios] - + existing_answer = None for item in self.all_data: if self._sanitize_text(question_text) in item['question'] and item['type'] == 'radio': @@ -502,31 +615,29 @@ class LinkedInEasyApplier: if text_fields: text_field = text_fields[0] - question_text = section.find_element(By.TAG_NAME, 'label').text.lower() + question_text = section.find_element(By.TAG_NAME, 'label').text.lower().strip() logger.debug(f"Found text field with label: {question_text}") is_numeric = self._is_numeric_field(text_field) logger.debug(f"Is the field numeric? {'Yes' if is_numeric else 'No'}") - if is_numeric: - question_type = 'numeric' - answer = self.gpt_answerer.answer_question_numeric(question_text) - logger.debug(f"Generated numeric answer: {answer}") - else: - question_type = 'textbox' - answer = self.gpt_answerer.answer_question_textual_wide_range(question_text) - logger.debug(f"Generated textual answer: {answer}") - existing_answer = None + question_type = 'numeric' if is_numeric else 'textbox' + for item in self.all_data: - if item['question'] == self._sanitize_text(question_text) and item['type'] == question_type: + + logger.debug( + f"Comparing sanitized stored question: '{self._sanitize_text(item['question'])}' and type: '{item.get('type')}' with current question: '{self._sanitize_text(question_text)}' and type: '{question_type}'") + + if self._sanitize_text(item['question']) == self._sanitize_text(question_text) and item.get( + 'type') == question_type: existing_answer = item logger.debug(f"Found existing answer in the data: {existing_answer['answer']}") break if existing_answer: self._enter_text(text_field, existing_answer['answer']) - logger.debug("Entered existing textbox answer.") + logger.debug("Entered existing answer into the textbox.") time.sleep(1) text_field.send_keys(Keys.ARROW_DOWN) @@ -534,9 +645,16 @@ class LinkedInEasyApplier: logger.debug("Selected first option from the dropdown.") return True + if is_numeric: + answer = self.gpt_answerer.answer_question_numeric(question_text) + logger.debug(f"Generated numeric answer: {answer}") + else: + answer = self.gpt_answerer.answer_question_textual_wide_range(question_text) + logger.debug(f"Generated textual answer: {answer}") + self._save_questions_to_json({'type': question_type, 'question': question_text, 'answer': answer}) self._enter_text(text_field, answer) - logger.debug("Entered new textbox answer and saved it to JSON.") + logger.debug("Entered new answer into the textbox and saved it to JSON.") time.sleep(1) text_field.send_keys(Keys.ARROW_DOWN) @@ -555,7 +673,6 @@ class LinkedInEasyApplier: answer_date = self.gpt_answerer.answer_question_date() answer_text = answer_date.strftime("%Y-%m-%d") - existing_answer = None for item in self.all_data: if self._sanitize_text(question_text) in item['question'] and item['type'] == 'date': @@ -574,56 +691,44 @@ class LinkedInEasyApplier: def _find_and_handle_dropdown_question(self, section: WebElement) -> bool: try: + question = section.find_element(By.CLASS_NAME, 'jobs-easy-apply-form-element') question_text = question.find_element(By.TAG_NAME, 'label').text.lower() logger.debug(f"Processing dropdown or combobox question: {question_text}") - try: - dropdown = question.find_element(By.TAG_NAME, 'select') + dropdowns = question.find_elements(By.TAG_NAME, 'select') + if dropdowns: + dropdown = dropdowns[0] select = Select(dropdown) options = [option.text for option in select.options] logger.debug(f"Dropdown options found: {options}") + current_selection = select.first_selected_option.text + logger.debug(f"Current selection: {current_selection}") + existing_answer = None for item in self.all_data: if self._sanitize_text(question_text) in item['question'] and item['type'] == 'dropdown': - existing_answer = item + existing_answer = item['answer'] break if existing_answer: - self._select_dropdown_option(dropdown, existing_answer['answer']) - logger.debug("Selected existing dropdown answer") + logger.debug(f"Found existing answer for question '{question_text}': {existing_answer}") + if current_selection != existing_answer: + logger.debug(f"Updating selection to: {existing_answer}") + self._select_dropdown_option(dropdown, existing_answer) return True + logger.debug(f"No existing answer found, querying model for: {question_text}") answer = self.gpt_answerer.answer_question_from_options(question_text, options) self._save_questions_to_json({'type': 'dropdown', 'question': question_text, 'answer': answer}) self._select_dropdown_option(dropdown, answer) - logger.debug("Selected new dropdown answer") - return True - - except NoSuchElementException: - combobox = question.find_element(By.TAG_NAME, 'input') - logger.debug(f"Found combobox with ID: {combobox.get_attribute('id')}") - - existing_answer = None - for item in self.all_data: - if self._sanitize_text(question_text) in item['question'] and item['type'] == 'combobox': - existing_answer = item - break - - if existing_answer: - self._enter_text(combobox, existing_answer['answer']) - logger.debug("Entered existing combobox answer") - return True - - answer = self.gpt_answerer.answer_question_textual_wide_range(question_text) - self._save_questions_to_json({'type': 'combobox', 'question': question_text, 'answer': answer}) - self._enter_text(combobox, answer) - logger.debug("Entered new combobox answer") + logger.debug(f"Selected new dropdown answer: {answer}") return True + return False except Exception as e: - logger.warning("Failed to handle dropdown or combobox question: %s", e) + logger.warning(f"Failed to handle dropdown or combobox question: {e}") return False def _is_numeric_field(self, field: WebElement) -> bool: @@ -677,7 +782,6 @@ class LinkedInEasyApplier: logger.error("Error saving questions data to JSON file: %s", tb_str) raise Exception(f"Error saving questions data to JSON file: \nTraceback:\n{tb_str}") - def _sanitize_text(self, text: str) -> str: sanitized_text = text.lower().strip().replace('"', '').replace('\\', '') sanitized_text = re.sub(r'[\x00-\x1F\x7F]', '', sanitized_text).replace('\n', ' ').replace('\r', '').rstrip(',') diff --git a/src/linkedIn_job_manager.py b/src/linkedIn_job_manager.py index 82602dc..88227c8 100644 --- a/src/linkedIn_job_manager.py +++ b/src/linkedIn_job_manager.py @@ -1,15 +1,16 @@ +import json import os import random import time -import traceback from itertools import product from pathlib import Path + from selenium.common.exceptions import NoSuchElementException from selenium.webdriver.common.by import By + import src.utils as utils from src.job import Job from src.linkedIn_easy_applier import LinkedInEasyApplier -import json from src.utils import logger @@ -33,6 +34,7 @@ class EnvironmentKeys: logger.debug("Read environment key %s as bool: %s", key, value) return value + class LinkedInJobManager: def __init__(self, driver): logger.debug("Initializing LinkedInJobManager") @@ -47,7 +49,6 @@ class LinkedInJobManager: self.title_blacklist = parameters.get('titleBlacklist', []) or [] self.positions = parameters.get('positions', []) self.locations = parameters.get('locations', []) - self.apply_once_at_company = parameters.get('applyOnceAtCompany', False) self.base_search_url = self.get_base_search_url(parameters) self.seen_jobs = [] resume_path = parameters.get('uploads', {}).get('resume', None) @@ -66,7 +67,8 @@ class LinkedInJobManager: def start_applying(self): logger.debug("Starting job application process") - self.easy_applier_component = LinkedInEasyApplier(self.driver, self.resume_path, self.set_old_answers, self.gpt_answerer, self.resume_generator_manager) + self.easy_applier_component = LinkedInEasyApplier(self.driver, self.resume_path, self.set_old_answers, + self.gpt_answerer, self.resume_generator_manager) searches = list(product(self.positions, self.locations)) random.shuffle(searches) page_sleep = 0 @@ -134,9 +136,13 @@ class LinkedInJobManager: time.sleep(sleep_time) page_sleep += 1 - def get_jobs_from_page(self): + """ + Функция для получения списка вакансий на текущей странице. + Если вакансии не найдены, возвращает пустой список. + """ try: + no_jobs_element = self.driver.find_element(By.CLASS_NAME, 'jobs-search-two-pane__no-results-banner--expand') if 'No matching jobs found' in no_jobs_element.text or 'unfortunately, things aren' in self.driver.page_source.lower(): utils.printyellow("No matching jobs found on this page.") @@ -151,7 +157,8 @@ class LinkedInJobManager: utils.scroll_slow(self.driver, job_results) utils.scroll_slow(self.driver, job_results, step=300, reverse=True) - job_list_elements = self.driver.find_elements(By.CLASS_NAME, 'scaffold-layout__list-container')[0].find_elements(By.CLASS_NAME, 'jobs-search-results__list-item') + job_list_elements = self.driver.find_elements(By.CLASS_NAME, 'scaffold-layout__list-container')[ + 0].find_elements(By.CLASS_NAME, 'jobs-search-results__list-item') if not job_list_elements: utils.printyellow("No job class elements found on page.") logger.debug("No job class elements found on page, skipping.") @@ -180,24 +187,19 @@ class LinkedInJobManager: job_results = self.driver.find_element(By.CLASS_NAME, "jobs-search-results-list") utils.scroll_slow(self.driver, job_results) utils.scroll_slow(self.driver, job_results, step=300, reverse=True) - job_list_elements = self.driver.find_elements(By.CLASS_NAME, 'scaffold-layout__list-container')[0].find_elements(By.CLASS_NAME, 'jobs-search-results__list-item') + job_list_elements = self.driver.find_elements(By.CLASS_NAME, 'scaffold-layout__list-container')[ + 0].find_elements(By.CLASS_NAME, 'jobs-search-results__list-item') if not job_list_elements: utils.printyellow("No job class elements found on page, moving to next page.") logger.debug("No job class elements found on page, skipping") return - job_list = [Job(*self.extract_job_information_from_tile(job_element)) for job_element in job_list_elements] + job_list = [Job(*self.extract_job_information_from_tile(job_element)) for job_element in job_list_elements] for job in job_list: if self.is_blacklisted(job.title, job.company, job.link): utils.printyellow(f"Blacklisted {job.title} at {job.company}, skipping...") logger.debug("Job blacklisted: %s at %s", job.title, job.company) self.write_to_file(job, "skipped") continue - if self.is_already_applied_to_job(job.title, job.company, job.link): - self.write_to_file(job, "skipped") - continue - if self.is_already_applied_to_company(job.company): - self.write_to_file(job, "skipped") - continue try: if job.apply_method not in {"Continue", "Applied", "Apply"}: self.easy_applier_component.job_apply(job) @@ -208,7 +210,7 @@ class LinkedInJobManager: utils.printred(f"Failed to apply for {job.title} at {job.company}: {e}") self.write_to_file(job, "failed") continue - + def write_to_file(self, job, file_name): logger.debug("Writing job application result to file: %s", file_name) pdf_path = Path(job.pdf_path).resolve() @@ -244,7 +246,8 @@ class LinkedInJobManager: url_parts = [] if parameters['remote']: url_parts.append("f_CF=f_WRA") - experience_levels = [str(i+1) for i, (level, v) in enumerate(parameters.get('experienceLevel', {}).items()) if v] + experience_levels = [str(i + 1) for i, (level, v) in enumerate(parameters.get('experienceLevel', {}).items()) if + v] if experience_levels: url_parts.append(f"f_E={','.join(experience_levels)}") url_parts.append(f"distance={parameters['distance']}") @@ -263,11 +266,12 @@ class LinkedInJobManager: full_url = f"?{base_url}{date_param}" logger.debug("Base search URL constructed: %s", full_url) return full_url - + def next_job_page(self, position, location, job_page): logger.debug("Navigating to next job page: %s in %s, page %d", position, location, job_page) - self.driver.get(f"https://www.linkedin.com/jobs/search/{self.base_search_url}&keywords={position}{location}&start={job_page * 25}") - + self.driver.get( + f"https://www.linkedin.com/jobs/search/{self.base_search_url}&keywords={position}{location}&start={job_page * 25}") + def extract_job_information_from_tile(self, job_tile): logger.debug("Extracting job information from tile") job_title, company, job_location, apply_method, link = "", "", "", "", "" @@ -287,45 +291,18 @@ class LinkedInJobManager: try: apply_method = job_tile.find_element(By.CLASS_NAME, 'job-card-container__apply-method').text except NoSuchElementException: - apply_method = "Applied" # Подразумеваем, что вакансия уже подана + apply_method = "Applied" utils.printyellow("Apply method not found, assuming 'Applied'.") logger.warning("Apply method not found, assuming 'Applied'.") return job_title, company, job_location, link, apply_method - + def is_blacklisted(self, job_title, company, link): logger.debug("Checking if job is blacklisted: %s at %s", job_title, company) job_title_words = job_title.lower().split(' ') title_blacklisted = any(word in job_title_words for word in self.title_blacklist) company_blacklisted = company.strip().lower() in (word.strip().lower() for word in self.company_blacklist) link_seen = link in self.seen_jobs - is_blacklisted = title_blacklisted or company_blacklisted or link_seen logger.debug("Job blacklisted status: %s", is_blacklisted) return is_blacklisted - - - def is_already_applied_to_job(self, job_title, company, link): - link_seen = link in self.seen_jobs - if link_seen: - utils.printyellow(f"Already applied to job: {job_title} at {company}, skipping...") - return link_seen - - def is_already_applied_to_company(self, company): - if not self.apply_once_at_company: - return False - - output_files = ["success.json"] - for file_name in output_files: - file_path = self.output_file_directory / file_name - if file_path.exists(): - with open(file_path, 'r', encoding='utf-8') as f: - try: - existing_data = json.load(f) - for applied_job in existing_data: - if applied_job['company'].strip().lower() == company.strip().lower(): - utils.printyellow(f"Already applied at {company} (once per company policy), skipping...") - return True - except json.JSONDecodeError: - continue - return False diff --git a/src/strings.py b/src/strings.py index f54abc1..16cb84e 100644 --- a/src/strings.py +++ b/src/strings.py @@ -181,7 +181,7 @@ Answer the following question based on the provided language skills. - Answer questions directly. - If it seems likely that you have the experience, even if not explicitly defined, answer as if you have the experience. - If unsure, respond with "I have no experience with that, but I learn fast" or "Not yet, but willing to learn." -- Keep the answer under 140 characters. +- Keep the answer under 140 characters. Do not add any additional languages what is not in my experience ## Example My resume: Fluent in Italian and English. @@ -238,7 +238,6 @@ This comprehensive overview will serve as a guideline for the recruitment proces # Job Description Summary""" - coverletter_template = """ Compose a brief and impactful cover letter based on the provided job description and resume. The letter should be no longer than three paragraphs and should be written in a professional, yet conversational tone. Avoid using any placeholders, and ensure that the letter flows naturally and is tailored to the job. @@ -371,7 +370,6 @@ Options: [1-2, 3-5, 6-10, 10+] ## """ - try_to_fix_template = """\ The objective is to fix the text of a form input on a web page. diff --git a/src/utils.py b/src/utils.py index 61c40f0..44d022f 100644 --- a/src/utils.py +++ b/src/utils.py @@ -1,23 +1,32 @@ +import logging import os import random import time from selenium import webdriver -import logging +log_file = "app_log.log" + +logging.basicConfig( + level=logging.DEBUG, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + handlers=[ + logging.FileHandler(log_file, mode='a', encoding='utf-8'), + logging.StreamHandler() + ], + force=True # This will reset the root logger's handlers and apply the new configuration +) -# Настройка логирования -logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') logger = logging.getLogger(__name__) - -# Отключаем логирование для selenium и urllib3 -logging.getLogger("selenium.webdriver.remote.remote_connection").setLevel(logging.WARNING) -logging.getLogger("urllib3").setLevel(logging.WARNING) -logging.getLogger("httpcore").setLevel(logging.WARNING) - +file_handler = logging.FileHandler(log_file, mode='a', encoding='utf-8') +formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') +file_handler.setFormatter(formatter) +logger.addHandler(file_handler) +logger.setLevel(logging.DEBUG) chromeProfilePath = os.path.join(os.getcwd(), "chrome_profile", "linkedin_profile") + def ensure_chrome_profile(): logger.debug("Ensuring Chrome profile exists at path: %s", chromeProfilePath) profile_dir = os.path.dirname(chromeProfilePath) @@ -29,51 +38,74 @@ def ensure_chrome_profile(): logger.debug("Created Chrome profile directory: %s", chromeProfilePath) return chromeProfilePath + def is_scrollable(element): scroll_height = element.get_attribute("scrollHeight") client_height = element.get_attribute("clientHeight") scrollable = int(scroll_height) > int(client_height) - logger.debug("Element scrollable check: scrollHeight=%s, clientHeight=%s, scrollable=%s", scroll_height, client_height, scrollable) + logger.debug("Element scrollable check: scrollHeight=%s, clientHeight=%s, scrollable=%s", scroll_height, + client_height, scrollable) return scrollable + def scroll_slow(driver, scrollable_element, start=0, end=3600, step=300, reverse=False): logger.debug("Starting slow scroll: start=%d, end=%d, step=%d, reverse=%s", start, end, step, reverse) + if reverse: start, end = end, start step = -step + if step == 0: logger.error("Step value cannot be zero.") raise ValueError("Step cannot be zero.") max_scroll_height = int(scrollable_element.get_attribute("scrollHeight")) + current_scroll_position = int(scrollable_element.get_attribute("scrollTop")) logger.debug("Max scroll height of the element: %d", max_scroll_height) + logger.debug("Current scroll position: %d", current_scroll_position) - if end > max_scroll_height: - logger.warning("End value exceeds the scroll height. Adjusting end to %d", max_scroll_height) - end = max_scroll_height + if reverse: + + if current_scroll_position < start: + start = current_scroll_position + logger.debug("Adjusted start position for upward scroll: %d", start) + else: + + if end > max_scroll_height: + logger.warning("End value exceeds the scroll height. Adjusting end to %d", max_scroll_height) + end = max_scroll_height script_scroll_to = "arguments[0].scrollTop = arguments[1];" + try: if scrollable_element.is_displayed(): if not is_scrollable(scrollable_element): logger.warning("The element is not scrollable.") print("The element is not scrollable.") return + if (step > 0 and start >= end) or (step < 0 and start <= end): logger.warning("No scrolling will occur due to incorrect start/end values.") print("No scrolling will occur due to incorrect start/end values.") - return - for position in range(start, end, step): + return + + position = start + while (step > 0 and position < end) or (step < 0 and position > end): try: driver.execute_script(script_scroll_to, scrollable_element, position) logger.debug("Scrolled to position: %d", position) except Exception as e: logger.error("Error during scrolling: %s", e) print(f"Error during scrolling: {e}") - time.sleep(random.uniform(1.0, 1.6)) + + position += step + step = max(10, abs(step) - 10) * (-1 if reverse else 1) + + time.sleep(random.uniform(0.6, 1.5)) + driver.execute_script(script_scroll_to, scrollable_element, end) logger.debug("Scrolled to final position: %d", end) - time.sleep(1) + time.sleep(0.5) else: logger.warning("The element is not visible.") print("The element is not visible.") @@ -81,7 +113,8 @@ def scroll_slow(driver, scrollable_element, start=0, end=3600, step=300, reverse logger.error("Exception occurred during scrolling: %s", e) print(f"Exception occurred: {e}") -def chromeBrowserOptions(): + +def chrome_browser_options(): logger.debug("Setting Chrome browser options") ensure_chrome_profile() options = webdriver.ChromeOptions() @@ -112,10 +145,10 @@ def chromeBrowserOptions(): options.add_experimental_option("prefs", prefs) if len(chromeProfilePath) > 0: - initialPath = os.path.dirname(chromeProfilePath) - profileDir = os.path.basename(chromeProfilePath) - options.add_argument('--user-data-dir=' + initialPath) - options.add_argument("--profile-directory=" + profileDir) + initial_path = os.path.dirname(chromeProfilePath) + profile_dir = os.path.basename(chromeProfilePath) + options.add_argument('--user-data-dir=' + initial_path) + options.add_argument("--profile-directory=" + profile_dir) logger.debug("Using Chrome profile directory: %s", chromeProfilePath) else: options.add_argument("--incognito") @@ -123,14 +156,16 @@ def chromeBrowserOptions(): return options + def printred(text): - RED = "\033[91m" - RESET = "\033[0m" + red = "\033[91m" + reset = "\033[0m" logger.debug("Printing text in red: %s", text) - print(f"{RED}{text}{RESET}") + print(f"{red}{text}{reset}") + def printyellow(text): - YELLOW = "\033[93m" - RESET = "\033[0m" + yellow = "\033[93m" + reset = "\033[0m" logger.debug("Printing text in yellow: %s", text) - print(f"{YELLOW}{text}{RESET}") + print(f"{yellow}{text}{reset}") From dd97ec7df1a835dee9a1bd76b62526d2af5e4da8 Mon Sep 17 00:00:00 2001 From: queukat <75810528+queukat@users.noreply.github.com> Date: Sat, 7 Sep 2024 16:28:00 +0200 Subject: [PATCH 52/97] Update linkedIn_job_manager.py del comment --- src/linkedIn_job_manager.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/linkedIn_job_manager.py b/src/linkedIn_job_manager.py index 4ca6b32..5b6e53b 100644 --- a/src/linkedIn_job_manager.py +++ b/src/linkedIn_job_manager.py @@ -138,10 +138,7 @@ class LinkedInJobManager: page_sleep += 1 def get_jobs_from_page(self): - """ - Функция для получения списка вакансий на текущей странице. - Если вакансии не найдены, возвращает пустой список. - """ + try: no_jobs_element = self.driver.find_element(By.CLASS_NAME, 'jobs-search-two-pane__no-results-banner--expand') From 58584def99926abbef458b11fd481b554447b781 Mon Sep 17 00:00:00 2001 From: queukat Date: Sun, 8 Sep 2024 17:32:07 +0300 Subject: [PATCH 53/97] 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 54/97] 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 55/97] 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 56/97] 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 6540bbbb40acc88ff0138e6cfc8754243e0fe032 Mon Sep 17 00:00:00 2001 From: queukat Date: Mon, 9 Sep 2024 18:04:42 +0300 Subject: [PATCH 57/97] 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 58/97] 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 59/97] 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 60/97] 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 61/97] 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 62/97] 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 63/97] 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 64/97] 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 65/97] 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 66/97] 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 From 08af24ea3b616da9acd4a54690e01e74af42f5c9 Mon Sep 17 00:00:00 2001 From: Manu Altieri Date: Tue, 10 Sep 2024 21:16:00 +0200 Subject: [PATCH 67/97] added resume upload --- .DS_Store | Bin 0 -> 6148 bytes resume.pdf | Bin 0 -> 18810 bytes src/linkedin-api.py | 145 ++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 140 insertions(+), 5 deletions(-) create mode 100644 .DS_Store create mode 100644 resume.pdf diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..f1a8e57f0d10732dc9e833146b222fb04be64e7d GIT binary patch literal 6148 zcmeHK%Wl&^6upy##vvd@0;IA)vcxtDX?PUGCQXw?C16n_SO5xkZCX>u6Ksc2MUk?H zZ{QbL^C9prtl-QeQtX5U8-(cG=*}6B@444=M)qWgi1jA^4pD=MEV#hRMKl{s+!wxP zB|UNp$YhR;(u0T}c@g*4qRoI&z$oyqDInh6EpjQvFQEMUD?SQ)@YYXh^3m($BRYc* z=?!s;a2-&x^41I1mjdg`W)^Y;^Z~g>G)AT%pM~8hWT~TK2!^PcQj6ZAxvi9ed zC$7>XI-q?T&=YcKkdI~`3pJ{E78g}wI?5C3Js2f(zstp_0XR}VHeY{rsx>AbzE2DLZPA)(}xeHD>HpUVX`{%+cKP3SD|T*0!D$n0%djC5c~h&_vimS z$y^x)i~|3a0<6+^`aMiZ@2zW-6ML-PXNwN7t8t+a RBQWzvK+0emqrhKP;1^65+amw~ literal 0 HcmV?d00001 diff --git a/resume.pdf b/resume.pdf new file mode 100644 index 0000000000000000000000000000000000000000..c01805e89c1684e79130151abc23bb80401583a9 GIT binary patch literal 18810 zcmc({WmsIv+65XQKydc}jk~+M1r6@O-7UBi+}#~QZ~_E(cemhfA-KzJlF6LRnKS2n z_qjhV4SQF=rM*jd*Q!-bA}1_L!$8XfP13b>x^+-^mNnMZ1I-Me2UzQwL348h=%fs- zj2%n>EI^YyfKJrZ!okoEc(>4XFcdb_w>B^Y@bW_2JJ=cOT0%R6q^l3cd=*7*+Mv4a z3qB5;hvSXZo)0r7C$^2~IB0Ap8<(-8)>=QCWEg z?0(nN9)Icelh)1Z%-7YCn!8Bzr7LUB?@sAwWnVpRAKciyREM|tXk}WdZJ1iAcRU+Y ztS3s55RR+%GoFsI8vX8ibE#DJC?B;TcXp_*oNZQGFA(f^LVK^QF5Mj2S!&#_HokIQ zT_@h{A@z*Z?-{e6<9G~-vQi5z&~p#Le6L&E2%O`%FSpaGE?s&`xovO8RlnkWc+z#B z3*SMde=Uww=|%|Asbc6VBhh)2_S7)n8nMH*Gi#-cf3)~8)0o`Zx-~JpUWDUp*-X2+ zpm$(&r%!Ysq`obwd7wAS#kw~Eq4G&+$?H9&*xo0&man*9%a$)3`KERn*YWPoICymm z&G2O_zjj}$We!=JNGx^X1^Tl&FdVsFW$*77U~y_9khGE?J-o(WR%x8;qCE{o-dQ7M z-mZ%jJJKb+1D7hh(hQJ2C13OiLYUi=y}BOVY{v19fy#W*u&fDoYs9W1TZ!-d=2|VI zq7AA6M2x%N#~058EY%Y?r2u(1mW6pg-%4kha;eeZk!Iw=`(d-`$TN83VM?stGQI4? zS<8bRM6+(tFUhEZGx)gNegVYs=YE5fY<0K)Jrv=B84iZAFBPZ4l zg}PRtc_mr5UY>mrS!I-uquWlJ$A4K>(Z4$}nhv^uJ?j=r60zUXP?<_t2n zBvieLwS_%I=xffSuhzlb0GIJaPL(>L`*66P(8hJ3`}L0LCu$THxWZ=Urp5IsX?HyA zd9@pb7|g~s*a4_!b zk0byo3JTbohI@=>N9vpjd4UY=hq|)^RZolP9R1=k8-piZ$L>QnaU3TGlF}WnF2iy)lxL?(}1Tzof`y0EctF|M92$0lX}<+Z)*dQt4y%~qJU08 zJl6oQB_ni(tkEZ>%Zj-yECFQ>FVs#yZebm=$1~(nG{Ue&!nN}HQcyRee=Xo!NFD=N zWpbzmW9@V$@aScl-mDL7zK2i<%SkXxv;nn6hMH8i(FG`?6djl?x1tA~ zp-cEoObufO#kuYN--R?|B z6WCDAyXF=WnsEeeUCzs)k8)`2bAa@nBj<#I@qOa`6OriitR?s z1+U%$;pyCxbHrt6<0jRLn<^J<1{1PJlcI@_kvciS+%S<)|Hzpe*j7`t%wIW;lY#ba zkQsBXY65t) zVi)E^RRC29(Xso!fx7Qj=BxlNxpr+D%(;+?jyl9JVIL{70*TZBCWx7F)&oXql}Zjc zVT!1I8lazmTudEBofHn)JK+U2<@a%4&;!@NH^b^_ZOjgjHWu$E4CYJ*C(EE=v=Uyx zs~b3r7s<7NUsmVBo7#uet#%oLQdibuhiiV>8?{Q>yz|XnO^scU^#(80vrlq_RhR$*Dx!{#O-^E-8$FgLM_gj7O_74>#^NLs({B(#1DmFq zRLGp;*CtGzBtioM_bS-x%_m(kOSX&jF0rGnLWJB1hFFRtsG?F(F~O%SHNYeVAXJ&s@Q%U{W3MMgueC1_gT zZyaj4$|Fy&`*@Cak2Y^nQdYC8CFnAJSQU3C0&}6GsX5VBPY&%6sBa5ve??K!d_wHA zJ@Gp2Fz7MM+%bo$+Yi3n6uDU%CI3;7zR5|oncq;CcWCk{*ddCPnzxNz$AIXCY>pa_ zFY-8{Fa+MWXpSf;3>N}M&ggDx5jsW$WmT4kJH_v6qMOv+CX?B@-{^?^v8Rj0(4&^> z`;ZI!@lVEP5%?7m31>cjx=H3384$&-A>_9WW${!!e^aEbS8D<&;O8&R+~#GMlkwp0 zjuhJ^8$&`T@GqKzTYg&|ZR6dbNBC%IKEe*;blJ2?!6;AAF0UPhlYdSCrU)HWQ+ql$ z?C%dp)!s}cS|}G-yy@;u*ALxX4IHEntDX^Tj*qCg?n^5O?oq|R9$xd!WXPjhN0b{ z$i~u^JcZMBtnd=o7@3_{Fs7#;;g918a6EN=q!GFOGz%zfijqGEaG+yB=~ku>L?>)J z!{pV&Z&`wV@2nLsydf-LGY=&R(({l1NXM@#p?XORj~k!*RvGSX3;MW8wHjpa0^=kh z7+N$7CtfHmPTGP?kqQ~Jg-2Y*@GeCzND<{2cG1)BJoj`XmAF&8s9@u^Due6{xk$ZH z07){cy@gS(7E8BLwCShP{EO(lP74DGO4J)@xzND|J*e@P7x8rRWTn0Y{bH)5MzX_x zGz_{i3-6R-<1J(?>j|P#>`n_N8-%t$*rMtSiAQJ`beG~rmr9vM*l+9BvzT^y2a1-T zcWmqq&9z}mIas(BOg(1ZF_49*<9|i!FE1TS=yO#OHXn2d}|A(`Z9L?X?mnWv(w%~;5FuVKD@L|$KH;^c3shL1$3&f&14d(qZ#4& zy;|DplEGAxptQid=z7mk-W7Z_5Eu9arfjQfD?7YhjbK;QE_*LlOXP#KWCICrq@oFvU?A}+y=;8dL1M+C zMJgN^5;#ECVXbC^WZfAQ{>)NW6V=0l-Y9qQ*nUuJkjq%d4AOA*&qY z5^D)xipW2-mmBZ(X7)$6^6Lg3z(ZKI}KRi;nrm?-kLFcgWm%!fqy?rslb_`y+G%q0Om)>aW6Ck8^7ln(8T>W&6=cM`uSkMo@o!vdAU z$gw##qtZuz7vj|nc#K%tEn`jf%deOb>GK8-S&!>nL@spTN^BohYgMV**=1ja=s#_P z9}Pe1gnM>ge~iVA&;^N?w(-2h__yZ&ZL(kay8Bjmpnb=ubpJSiHjKAS- zMHd@G0G*t!@sF#Wp_K!G=_dx4H?+5Qw9_}V2eAG$2w7V>0ORce&$t~3(xnXzOmzjV zodFv3Km#iSBY=&AQ41P~)BmXFIo~gQFK1`1uVCl^&;V8@EDE4gG<0?VXaVSitSzkV z6l`?$4FS(IfRH@{fbB=|yu3j853KQrS(Jg69>D%=ho%!{0)`o$t9&L37=dp%er5Pq z)eH`pgyLxadyUdVyV~xI3ytH6JEri=h6Boj!I4zrAp_1xFil#z*10m01P@icg zlkT|ch}o#s5W8`GiI~sZuk$guX0Iw4H^AeC6XoC`;xYK{NtGVLj*pw}lg8W5*fug< zK7&A)nNs{qj538)H-8?M)e+RP?vsTOd~F&Kp({lqlQ4VfuE|*r`@Ep<3{zN1SE3vR zFbwY#KB2IAbLrx%eKLZs)VeDJx=n7N?R!G2e z^N!#tt-k-5)b_eQcyXBg3U3+-$2eEsN7*%8rAKz7{-pcb&;oPR+q1-J*3JECZv)M7 zww9OsCfs$AqpUd{M@d)N)(~dtsh80|a-NK%pnWo1P1StPX%iu3NU8#syvqxmH}N(G zMQL&R=v$o#@{k-awFOCoq@oqY)IN{jo08X5b1$51V!Ei+U)L#^H*h$=pCfd&ypdL^ zI{5H-d+B<&aEfO+@R5QXW%#qOQCy`)?&gy7*9W6$q)c|smYwDh_5Mc*5k$TK4t2^? zRHbuextT6&V|Y)TT2D>B{GAJd2W}{y(#-Ml-CU#=SOhYuY}~zh@J0s!9k63F_J#8UTLG zCwYL@uQbnV#INb~uO-C(pB9DZdF}|D%fN{zX=-2(Tm^p4MXhI6?~m1j;qPS%={o3I zSR4ObpX~own10Lg9|%N-KN!THbDjaf#LUF_zo&JnvZaZ}EEeyMrdmwCc)}(XMS_Le zY?YOP#7rT{d=Z~PZ`BsN{ry|6h7ZI}_v!P+i46okRu>(tj1Rk~#rYNZB)9>Bd({Gd zW|u@^9%EJb9~}8=4X6XqdN6bOV&Hl#zHz8jsRdF5NUx8(tX!p?Enji(c&tzfLJ<`9 z(ISaEnVQ?CHi>7npPFYiIoS6~3cEu+usRIyUoI&3G2X)LzK7ENUT(Rp9o}M9q(dng zbhLLnuzvcE%L8Mr0_I@yfZoKb`w_$g#r5uXW`yRh_*`iBDl3rfovQj&FKqFc7xcsV z%zCNiHil@H7wpnqO?PR$*Tbeh)p_CA##g$gI-%tpwpR?`S?{e^ROgpTKXB~Aj9t9w zPV|yNqH)2LxIWBqRd!kA3%yk;qQ2jxX z??LZ9Z0qw*iit>-L*7hj5Hbb8-SfVSdZX6uL0v{ZpmsfO!#C%_o!wjTuB*TshKxJ>#$p`nBQ=S$%yw*$r`5Hf-m6 z4`=s+C?R6x$gbn=IP6HU?gfQcqaWqXZu#DtJu=3zx>LNN>5cY`kf+kCwRvMnrm%kp zDj111lF@c-6~DHGf@yV+@WJJlF@}Hs0k)d}5tjkkKmv6SHN5*FM(I?8gldUvF~2eJ z%PtSkgOpbg?zD%gg|-fUwsLBpa&ld+8@Un+l3<@Dj03;BCTT;jpX27$@dJjp7M?>c zvBO*r5iUOhJp_3hgbmI$-Z8{6{4vNeflfnM-%wB(VO1DQ@-cm*i4iY|9-pM6NYeLv zvb~x;VfV`eFj+jf<%Ws$`s#S(d5=+*BkIv+#>sZP*Lh%{-(#?bC?Lc0L#-Ej3Ef}h z#fz13L})g?)Aw0-;p8qKr_3idM{eEOLqIqq^hoxMbqa+vb;TWyx9_VDD$-={mo%Js zNjn6Hwbz9}Fu-RG<2=?aKGg-ouO;HOSnY)PFBc`=SL!GuIGJ&iEkshUgva)hF2EI-H=HIMJoJu|a%IXQ0<{k60sT!t+sZ1|&(g8}*6+*GMSAjWOVw{iC>H*%W= z1JtjhJ$hhvewesBS34iMb2Ay4f@6_RZ}G@Y6lE8$>LB}Fvv2qc&^d)l&U39 zUxxu7&wyJx7Az;bbuzL4j>&)&5wl^bo#y)?E0tE(SoDMK!kFe#SL1uZfmG>g?c`Um zh3>AT9NZ9v&LON~w+ni?wiq;Z3Mj!kvTlAET;; zmzUy`_2`Y_rs?%Q5fF_YMk)sU^^`kImQEC#yW+K&EK(~v+fA%W854ZoDr~A)b3%A0 zDXUo1?rCqsS1XVLEg>=q4Uiap7CVY7!l}HYRhr{O<7ef`W5tc#jrN70Z}L5T&L$*Q z^re#}o3r;k=i@WJ(JV5C6qL&t(srethj%Jv(=ee+X^J<%%g@pZFW3*xXXIDD&`QZn zj}MGsL z9;_VB25SDY*Rnn2<*1ma)3W)ZH(o`t2Kl)DAYQO9810F6a`lYa+~YgKR-V!kjBHNL zoRP$Vpb@#&*Kgdya){;*5R8apO``8|n*$(>QuZ(MI`DAy4_BjZ1yM$CQuOC9J0TtN zJm|;mI2Y;gTf_?e>YdNz8jPdnxBPd|(r`Hvi=-x6iFRaS0WO779j~SY(#%>oY_OL@ zZ~Cl1tO0cUAZ*$##cp~HwZB4y+10!o+?_pr+nO+5Zg_5v^`I{L4?&18_Yd7nYJ%+d zaFj(&^XBr@N7bY}OWs6{rkKV?auSyB*%~$Srl*WlJG&Od7>_viLy$i14TNt` z33D^xS=;sWNHTLV;&6J=cwDOUuEJhcFL5Q2dmT9Gd9ow+Q3T5vR>$rXL$AH}E7!8z z^$;!X$kZj^dwO&_INALuMVN~(P_DAEg)&Fhqy&ZZ#pvQ#EZ$|9WEv%t;}&yIAtno6 zvr{XkATx^j05T!y<-AWlsa>GKOR)Y5(wdQ!@mJcUS4AI|b&;GBmoR1mygq4%8=#C@ zf_-{oJI)EX=H31@{?%-BW?e~y^n=lvy?yrm!1i!_dU$?!aD1XNr!m`mD{AdIRgIOk zai!*{0fg(PbQE6ExtDeji7CQYc%&|NNQO*)ulX-x$67%SUXtaWqU!Yye<12hiIC`g_1afcWe=T1M9r2$N(4mBf{msl*K} zoD3aI^>t+o9Sv!eEFA1~rJienrjr0VYo_`FR>l^FK<|)F!NJf{8Ndh(Ng3)Im|7VF z7}+C1h34`B{8!H|e7T^>9 z(}#Yp@~=Mh54=mK;Hc;D1C=Yzo3Dgr7v%7scZGi^1HYGd+UDz?4LmV zkG*^b06WwF$@f-sv(T7z;oH%Si4i{`2|+CyEF_Ov7eXcpK?p{sScs#RtfJx53yyeK zMc8ZiP1t2f+#B7|hgK+n#x(S3+DbBkR3nx!JhdZ$5H>i`Q5wghSf!*J8&fniM`#S* z1icz2p=-)>rMampja}gJRdD20n*Dd4ox7H^JMF+QrXKZq5z@+&{Ik&Fdz|%GqL1L` z&G|405Ncd4VO~fcOMBCeqhywik5~`o^rW;a9!G1FBW%mkYctgP=>bfGq3<8wQ>GaW zJ-Kjaa`}OD$`Ui(K6!gyC7&ViGCwGFwq`%spFgcHHcN(HJ)A38)1y1TdT8Fkz*k4x zm>@X!^7cH==sp`1;=1Av<;~D7ns&o?mir=hRagg}F~?JUMOR+NxS@M)+H8c_o5))_ z)HY)aS9Q}9#OOX4$uHYrxY^0cxj3fZLuv@uqaFttKIHF+>F0i}%CBssu<-q<@-yqU zH(vTsH)m3+iz*pjbaLWNQYLttVUS}b{ecnE1RJL8aLMO{`T%SDM-$6*>&G?D;#=*m zhKb;f=7X?8-}d16eG*S(+~^K|LjQy~Xc_Go!dOW3j+#|<8_O##^Y;GLbCw~SS_ZJP0l6nQ?mXGymvj>2 z^#KnsiL}aFk{i=0uc5)oXtk0x>KyVZ1I<{t4d>QTUwavKFRj)?apNG@t(1(|A=>MN zEG%FT2WJF*pw&{-h@AxlGa|#6+RtEm=beY4Cadmj*uyxObH9!;1-yn1Y!fRDsn?Ka zg2W(glL%sG@T`6#tr509uUug)uW0t@uV{9|PFdqg9lub^-Yk;ubZkKsU%RB8zZNOC zoA9C6I(IGf(+9d`(as1kO*;2%iZm8B~9J@{6qGVy}^53_@K40doXLhtn+ z2p+qQ9*yjAt;YgC>!%`A4jxjMkXK~Gic4fIKzM6{U+5R04|UNi@E#7;QRnol#Z)CC zkkqx*luo1BC68pV+!!R%njVYB&>Dt_+)tz-$FiZ4WsUT+q81lM7)TmCVu^t!o%}1xb7#x#ihrXA!wlCdh z+$P?@u-VO)>tnmKhg!9eWFYeC+d6rI61-Zo_mCy;MVu1C#cJ_mlCX^~M8v zz}o%JzXI-oZ@{ZO{y8)2ger<1!qmVScU`j9Ql}?qg4J6rV=uNt3w_PvDs)IkVzU$d z{7%^Eqkqf0Ah79z!`}Zo#K#IPZmdg+)9)jP;Ep)8pk7iIzVCKI+qf_7Sj+J` zJQ6D?#OL6dVe)ViyVWV;Qfai#t5Y9z%%iOSu5f)r^lA|n|0>yW2E-AIfV21w%fFcVc2bstBxl(IBXB zUwRAjX!79s2us7(!6jCQIX*+LAC3+|!@@FS(t*8BiP(#l$6`5*FhpUw@V2jDSbHn* zVDxl!eCj@mlWDjaa?%6JUa~}=Ybf4D=`Ne&QgM%m`$HtB8$nNj8WZP*9!l6R z5}9d5H1@gq1L?M_Fw32!ylQBz_)qB{moHkasq9J?uk8iS7sAE5A3jMnwoP`f_IDXs zZ3PRmTjK6DVi`0+6FzmcBiVNIOMkI3Xo^RG$2-(*DW<$e`KSDtqWe6^brtlT1|t|_I~*~s)&R2Zp}h|>mc=F~)j3tIwzmbibS z75_h9dR+`n4Cf^@Vzr6d}5>5=Z))+joqm;_*9Qc zC1cZrqw(I`53L>--dDQMZHr4&QI_x*?s%giB&R|O5qm>+RCkztT>7*?i0#}q9yOCa zr{7s)3?gCYu(|XtzHKCaFF8Kl=M&qP_cl7#@I6|iJBbCc{d@V`Pr4pe%$Zltr)MM) zJezNZxO^{CE9%T34Zx6R7@6ZT@c64YbSbE@oX>cd886}Z;*;WS218+3Gp{GQhAQZk9dB< zMGjSwc1(r=AMn$$$6~;FQMwRUzya)Y$@^vA%8Wsz z!EU;2dCxuS`w86Pt`muG#Bx8E>yQ?NX9M@*dIV&8y0RaAlAnJ4oK2V2UYN^*kkw~I zHLbWWa7qos--2-w@Pi^qHPJcNBYLz7NOhS7z{YOKZ8myZ>a*8(X4LRmhnDywajIZ; z`J+m+N`*86o$wb*$1uX{>9;#nsTT025nObN@rIRoOk9EYxlfQ^_kBwL2-f}{d}|SJ z1Rsk}NPp);F;R=3@}-lt$Wp~7wcqGo&a)G&9W}MXsf?g5Ix{Td)vSCyJKy?6#1KYv zfI}DgwXK+Y(|a4jJsV;&2)Eu1_>Q%rMR7YLD8x(&HFqew??o7{m+^X)6D=7%@tR5) z^tU)V%u+5%Uwqz_hrFSa+XJU;=Fi9Ycn%t`oizcY=<4&k(d>u)BP{y?|4U*hE)oHYRQ zm=X-KnqDn<1N2)9k9P|xwx_rI?c*sI5uhf~RKx3Vk)03FNJh5A7>S zg!dFdDhJ?P3$GPE;ec}czd^pi^&o%G1Y6YhLgx0RSIeXCNGxV}`yto<%9sWXHwd#;HW>%K)2WhZQQyq3V*g~H*X8JUU z50h;+*qAaujHSBI_Am6@g7GirLBEkrYtjU{P8GZ%o^NwRR*jhJ9MPyq8#E@r=|J!8 z@Z`@T) zu&c`iw}rG|dEZuTdr^oS!2`*lTkVjh=!+E}jbP>ftltqrll&d$lo44&L*6H`{~ zR6O|NIdV`O#XP5~2b=*PRH$(%0y$j@(D|%J8AK7}hEL0aC*KUZH$_?Hz868&pwPK` zvkxpyhiQq_qlnAteFULxQQgMXp-egXlJHf&(f%ga@>o-x(JeRLb3Ppte)`?|XOBs& z?>2l0FHrP#E+$u_M^N;>GP$66Z_d7IhY+ktY1X$iCjIKK)H|?M zy0|!=G}!Otd9C?P{oL#WPt==_Qe$J*B8_OFwd=%!T97zqK|N`Hv?M9xIp3lSYK>wi zQQx?#PhGr&Cgs=>${}LiM8T>WOtte+OlO67w4O1|r5EQp%nf|FD_u-2S0cV-WDUm` z+F&R>(WuE*xkQ14M4cmHEew;Vh=9w-@Cq%-KGDlPsp1r*YgkKNS_pMWB?L{^OG=gw z#>jghM@0rl)tjT1z=~%`sVlg46%5|j>VV7hw~cjpqwT-I>x}g24GY&;3N1M28m$n@ z4O$x+m%3Fbs@pwOWRV5@_WHEuO=>a`Zh~L(sb?gy1KaXO`?YY-EZ))g62AV%#`o~x zP7$-2+r4xMMyooLXF5?#Xjkz3c;vgp8GN)yd_gw|Lavv!;$-mFe&^WEa`!759YGI{ z{L$q({yq!qS@f*?vBfBESvCl$-`j_jr|Hz!FiMY&m9Isp?sL#Q$DvzI0^U`3{4|ID zG%KC`2(*ECnFyDOnK5_3jLmb`d|d;pcX~N#<8WiS^HwpzHZft>M1=;$O!LN(=IeNw z8V!~pi;x7;qi2?;Go+m^WJQYJilV*lsa+R@Ko~csRts~0>(E1dQLm`xW!j4kxwc3WS0&ZU^kt>sDS|7G zM1vulp~^)D148Q+E*cUJ^0q}AqY;Hv-tNBPSBaz3Rz4HCcgx)k@om^XDRRoHE%^Mc zeq-1qOLR__Rx&R+o;rFzFg1DYGK_&I%gf6^MB;=)?Cte()R& zV{co8RIFOQj~wmkRWoJ8Bh2Rr6c%&s#Z4k%@I^WlF3l(f1-OEZY_m3ZW9{-AT9kxk zvRj0rqG~^7n;OpWsbqu5pe~Xs`%a7**R--j zWt+|}p$>Mx*K6fGxy@hUX~3DN7+7N&@bL+?g7Zo}tn%zl4-=p5$xLU?m+X|&a{)tqw&N*UjtyiP^F6TnNmd1vglOS#4kedQDkgW1|9=v+W}xp6~^ zj!MEBVvbSvw!tnKOBmV%!QxzG)1J=iF{Ug@KdvXv?HoR1M}&32s~xl{kY$`_ii_9> zV@1L#rnK_vTH?{rGoW>31xe||<)q+TY0q(wXX*Wg+Xc>t+zsWri<3=XZm5P= z%s9qQ*?Qh?L`oMI6sj9*WLkRk9(G&gb&}sP8^QZ3!Vw_nx?o)?!hK;m*Uzdp{=T@_ z0v*AeII5-UX=7+=`~4K3`f8+X(PO*4=c~n8`R-HGSN9yuSj5|J*S(w}7Wt47B;B`f ziQy_>2iNADl9c9O=6wbKc4RbZ@nLwqHQ1w%_v$m)A%Lsvh(Cd06df}L9}_VwnB)mx z8b(F^KQP75oZB;t$PDbrUt8%w()B-4i@(u=e=^0aKu-2|rWnZo{a=`3W~OJRm>HcwOXOj8%J0lCw#=`oe zOa@?m&woJG6Y%<6!*fl*Sm+f8q*DLydFKDphkvD* zp?{1v`9CSlPlRyUQ^RLDdyX!d2z71z~*k5#~l z?%eH)+erOR#@W-8=TmPb6p8;_pJ_+zL^R4-ar1}|Mc91=;y$OH8O@u8W0G@OOeUH# z+Vowg!$-7RFA{&QFAn!*-8Vj0dd}zU1gAZx9rxpGgq6I4nv+SH8a>|h#x8IV<=Ob} zcq^TZ53TIjZe?@%JnlE6Z&uYebix+33#tkj=9$lmRCXVJzZ;i4VCO+wZZ*|y-6=uNBAQr-n5s|3gWYa}SI z!R#T@>^&0)_xvUSgxt=2hOzvt?!w$g*6Xga=6F?>20X15dk3Bdss?T^ht9aM@6Ht! z+TX&hqCDc!G>xeSM2oN&t!NdCJ5_tTX4PNU^)#iBeP$WkHf=#gF; zjF+g$I#ON^IjbAYi>$y(VG7$O&l)HjW<4<>6~IH;9S6V(qF?}bRUA~+kdpmY943Sk zU^;QS7xm_Ah+Y}oz6599=@XRvov}~#1(U%3+{`(&c>36UJHSy3cVe#aMMJpF9_6b{ zRzzM8H2)=hp`;sDv8tercK})!Ld4)UN!K-*W{(G`?jqu}kXWl}naRl-u022H+k%=8 zY;%Fg_+?kI`p)I?7Hc7B4FaEY%b&1QB5#Zy?jm)=h+df-AsvC91|+(h!lbfa1pDew zNj-Xx93Wa*F$c-n#=IZ~$MQY&Q6!*>Y6Pr!rv-3>xxk;|1aW_nxKFHTg$R zT)1HMp|#TPN3Ziq=g|yns3|EN9x0OL7S({~b0Ef{rg^8*=)4kJPI|Shu|=yn@~vT` za(+C%XlhI8sI545rKzUN4tLTE>NYeFY5s~fSwlC;{ZU~(bnejBX ztgC^iA>6ldy%}NS@C~96%N{)+Xl^w|q~91MG<}h{>;y;PKTSlON_60h_70uu5&@+} zwgHA-Y7(y!f?z<^iMRb`!cBaT>vlr|MdR{zTi(O&xWx?DXO4IqzZqQ3lhSSiq3+C} ze~SEiN|5wqfmlM&E=ns3EJ~!0qHmTUlD`Gv25QqKh#T_aoxp}Qq7{4wCwy8`VMM2b zSkhA#Pe+p&)iDGedV37&VN)SZhv~fLvT-3^c>;|*SGXf7q!W6byU#37!{kUZIO_r! z!ffDKKn_RRh=`_HU|}v%m27By)f)emIQLaIK#YP75jMz|SX?_+30+z~6mb;ot3qri z<#25cUdpAD$dXpTl5Q$8oW@?I38~3v_72|CL9=}2P%_@6H`|=J4_Go4FfR6d`Myir zvha>vOwBw;stwKgG)fKMSFe}R-%&>mT)&L4LzkH1Aa%02N3fxeradi}o`5r$M1*8V zhl>!gp=RYkZ?Amy9P-sidL6o|`m&dh{a>cHJ4xP5zDkYlYFrE7ObTI-Yr47YSYH^9&t3xXY#W{g6T$NMU!O-?brnKV4#F*|mKpRi34M`X z_m`kHS_)FhJ9YC>%~2Zt8X~H0-^)7e->M&J+Bp5=3Z;^$tT-I&9At=>{WaBG#dfCd6}~y$-g= zke=4h7AtJ<*;F-B*HML=nux-3u_IrP zP4Ky^lUoTnzUY%vP*%>N<IQh#&eb$2W_^Et#)SJZ2>erTcC^~gQRe(%yFW(QA?)%GIsKtB!Taf^&= zPOVEu&~6JoR8X1@8hAaL3pYUKO z2z9D$A#^pjJ*WlN9rP3%^;c+7IqZpfFtM~U6dxYksl~nF=#W1pG+VGIPJA(*hnaZ-Sc<;~MA7Oi#R2ATxx3^FK z-Dq!Azbgvjpr+sgdumpv8RCFNwY=RGZpOSq+wTIq%< zxmr5Lv+bh1crRI@Ht9Y{gU)U0qdS4pN4<5EJA>I3WfvG+V-H=AMDgu@@hOlBwa&!q zJy50+BX?U4r3Umq+ZqhHt%;6Dc2Ch9yzW1_KC8B7cy5(Z1*m`nJDY3zB|C`7O);=o8f!R9B~PpLPeEZ1GPH)6`9GU&3}`4OID)UP=&VZEvj<}<;q zOmSTTId58-W*@hbrz5u0MOZ%dfZtzsn1+O}`@O%;oN1KkbEvCTTrp4Sa4W}*#8zSR zNHw6~Vss9UyaQpd2kD~_PYJUZeDII~I6*hny)aw0|8Sgp@m?*HYU*8qSJ~{b49){b zCn^*`__oi_AXan_^GmE-?Dvh#)i2i=QV6ML+X@LzgQkP#QgoXi8r#6kOocS=aXxHw zaO=y?Lw#79UlDvOXmlAVqaxZ!h8~fruBySo==IR?4JLUnK7Xr*$Na2UB|-~`bu%Qq!ntFV*nlaKo#>12kCK2aHjlbaBXLRQ3gtCeVWcwYPfM2V|bkX@fX$iJiG zA8_Fr`!LZnbNq>pf0vE_cO4bWzv-y{0UdL&{zXT{OwaKrI_3ayu>4g=C8s1Hr7G~B z(D6@f{8u3r&9frsrA#`m-=$PPV5Cf|pRw@I@_wS=zv5tE%pU@+ zpYp9=sQk|o|Ax2!jQPj<{~^WyZzVA^Gye%8Wnp1r0T=;(+8Ee4o>`#hAAr>#HhLxwb|A6w zR~rjG8<5!etBs!idBXnH#>Bz;HyblOJ8&lbH6086GdJ~D8yg49zuOqt*qHyleGE*j z9DlEa5xAuNTX`(>&tk&A*1^K?JgfiB#z_A+8yhp*-|Apv26AfumY0o<1-O>{wLEqP z=6^4bf$2ZmfD7hd^D;0oKacCbl*hot&i1!-z@hQCI@tdHOc~gi=zsehKq)LxvugL_ z`-~J!T@8U_13)KlZ4Es4^apJvVP#|uJOuTF!jcdM9)sd$HsoLi{)b@E)iq*ZVr1rE xH)3U>H)PW@Fl5y?&}TH_h5p}3fafTI`dE7h;JE)WC>hv+MM0C0h{%dU|35{Ppxgie literal 0 HcmV?d00001 diff --git a/src/linkedin-api.py b/src/linkedin-api.py index 37f727d..cb38de7 100644 --- a/src/linkedin-api.py +++ b/src/linkedin-api.py @@ -1,7 +1,7 @@ from typing import Dict, List from linkedin_api import Linkedin from typing import Optional, Union, Literal -from urllib.parse import quote, urlencode +from urllib.parse import quote, urlencode, parse_qs, urlparse import logging import json @@ -353,10 +353,134 @@ class LinkedInEvolvedAPI(Linkedin): # Push the commit to the repository and create a pull request to the v3 branch. + def create_request_pdf(self, filename: str) -> str | None: + """ + Create a PDF file with the request data. + :param filename: Name of the file + :type filename: str | None + :return: URL of the file uploaded to the LinkedIn. + """ + cookies = self.client.session.cookies.get_dict() + 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 = { + 'action': 'requestUrl' + } + + res = self._post( + f"/voyagerJobsDashAmbryUploadUrls", + headers=headers, + cookies=cookies, + json={"contentType":"PDF","filename":"200.pdf","maxSizeBytes":18810}, + params=default_params + ) + + + match res.status_code: + case 200: + parse_res = res.json() + url = parse_res['data']['value'] + logging.info(url) + return url + case _: + self.logger.error("Failed to create a request PDF") + return None + + def upload_resume_via_ambry(self, url: str, cv_path: str) -> bool | str: + """ + Upload resume via Ambry. + :param url: URL of the file uploaded to the LinkedIn. + :type url: str + :param raw_cv: Raw CV data + :type raw_cv: bytes + :return: PDF hash.pdf or false + """ + binary_cv: bytes = self.file_to_binary(cv_path) + + cookies = self.client.session.cookies.get_dict() + 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" + + ambry_url = url.replace("https://www.linkedin.com", "") + + res = self._post( + ambry_url, + base_request=True, + headers=headers, + cookies=cookies, + data=binary_cv, + ) + + match res.status_code: + case 201: + return res.headers['Location'] + case _: + self.logger.error("Failed to upload resume via Ambry") + return False + + def confirm_upload_resume(self, cv_hash: str) -> bool: + """ + Upload resume. + :param cv_hash: PDF hash + :type cv_hash: str + :return: True if success, False if failed + """ + + cookies = self.client.session.cookies.get_dict() + 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" + + json_data = {'entityUrn': f'urn:li:fsd_resume:{cv_hash}'} + res = self._post( + "/voyagerJobsDashResumes", + headers=headers, + cookies=cookies, + json=json_data, + ) + + match res.status_code: + case 201: + return True + case _: + self.logger.error("Failed to upload resume") + return False + + def file_to_binary(self, file_path): + with open(file_path, 'rb') as file: + binary_data = file.read() + return binary_data + def set_job_as_applied(self, job_id: str) -> None: self.already_applied_jobs.append(job_id) - + def upload_linkedin_resume(self, cv_path: str) -> str | bool: + url = self.create_request_pdf("resume.pdf") + if url: + cv_hash = self.upload_resume_via_ambry(url, cv_path) + if cv_hash: + self.confirm_upload_resume(cv_hash) + return cv_hash + return False @@ -364,12 +488,22 @@ class LinkedInEvolvedAPI(Linkedin): ## EXAMPLE USAGE if __name__ == "__main__": - api: LinkedInEvolvedAPI = LinkedInEvolvedAPI(username="", password="") + + 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}") - continue + + resume: str = api.upload_linkedin_resume("resume.pdf") + if isinstance(resume, bool): + logging.error("Failed to upload resume") + continue + elif isinstance(resume, str): + logging.info(f"Resume uploaded with hash {resume}") + else: + logging.error("Unknown error") + continue + if job_id in api.already_applied_jobs: logging.info(f"Already applied to job {job_id}, skipping it") @@ -378,6 +512,7 @@ if __name__ == "__main__": fields = api.get_fields_for_easy_apply(job_id) for field in fields: print(field) + break From c85a2025f655801ac928cf4bd31bd209d26f5409 Mon Sep 17 00:00:00 2001 From: Manu Altieri Date: Tue, 10 Sep 2024 21:16:11 +0200 Subject: [PATCH 68/97] added resume upload --- resume.pdf | Bin 18810 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 resume.pdf diff --git a/resume.pdf b/resume.pdf deleted file mode 100644 index c01805e89c1684e79130151abc23bb80401583a9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 18810 zcmc({WmsIv+65XQKydc}jk~+M1r6@O-7UBi+}#~QZ~_E(cemhfA-KzJlF6LRnKS2n z_qjhV4SQF=rM*jd*Q!-bA}1_L!$8XfP13b>x^+-^mNnMZ1I-Me2UzQwL348h=%fs- zj2%n>EI^YyfKJrZ!okoEc(>4XFcdb_w>B^Y@bW_2JJ=cOT0%R6q^l3cd=*7*+Mv4a z3qB5;hvSXZo)0r7C$^2~IB0Ap8<(-8)>=QCWEg z?0(nN9)Icelh)1Z%-7YCn!8Bzr7LUB?@sAwWnVpRAKciyREM|tXk}WdZJ1iAcRU+Y ztS3s55RR+%GoFsI8vX8ibE#DJC?B;TcXp_*oNZQGFA(f^LVK^QF5Mj2S!&#_HokIQ zT_@h{A@z*Z?-{e6<9G~-vQi5z&~p#Le6L&E2%O`%FSpaGE?s&`xovO8RlnkWc+z#B z3*SMde=Uww=|%|Asbc6VBhh)2_S7)n8nMH*Gi#-cf3)~8)0o`Zx-~JpUWDUp*-X2+ zpm$(&r%!Ysq`obwd7wAS#kw~Eq4G&+$?H9&*xo0&man*9%a$)3`KERn*YWPoICymm z&G2O_zjj}$We!=JNGx^X1^Tl&FdVsFW$*77U~y_9khGE?J-o(WR%x8;qCE{o-dQ7M z-mZ%jJJKb+1D7hh(hQJ2C13OiLYUi=y}BOVY{v19fy#W*u&fDoYs9W1TZ!-d=2|VI zq7AA6M2x%N#~058EY%Y?r2u(1mW6pg-%4kha;eeZk!Iw=`(d-`$TN83VM?stGQI4? zS<8bRM6+(tFUhEZGx)gNegVYs=YE5fY<0K)Jrv=B84iZAFBPZ4l zg}PRtc_mr5UY>mrS!I-uquWlJ$A4K>(Z4$}nhv^uJ?j=r60zUXP?<_t2n zBvieLwS_%I=xffSuhzlb0GIJaPL(>L`*66P(8hJ3`}L0LCu$THxWZ=Urp5IsX?HyA zd9@pb7|g~s*a4_!b zk0byo3JTbohI@=>N9vpjd4UY=hq|)^RZolP9R1=k8-piZ$L>QnaU3TGlF}WnF2iy)lxL?(}1Tzof`y0EctF|M92$0lX}<+Z)*dQt4y%~qJU08 zJl6oQB_ni(tkEZ>%Zj-yECFQ>FVs#yZebm=$1~(nG{Ue&!nN}HQcyRee=Xo!NFD=N zWpbzmW9@V$@aScl-mDL7zK2i<%SkXxv;nn6hMH8i(FG`?6djl?x1tA~ zp-cEoObufO#kuYN--R?|B z6WCDAyXF=WnsEeeUCzs)k8)`2bAa@nBj<#I@qOa`6OriitR?s z1+U%$;pyCxbHrt6<0jRLn<^J<1{1PJlcI@_kvciS+%S<)|Hzpe*j7`t%wIW;lY#ba zkQsBXY65t) zVi)E^RRC29(Xso!fx7Qj=BxlNxpr+D%(;+?jyl9JVIL{70*TZBCWx7F)&oXql}Zjc zVT!1I8lazmTudEBofHn)JK+U2<@a%4&;!@NH^b^_ZOjgjHWu$E4CYJ*C(EE=v=Uyx zs~b3r7s<7NUsmVBo7#uet#%oLQdibuhiiV>8?{Q>yz|XnO^scU^#(80vrlq_RhR$*Dx!{#O-^E-8$FgLM_gj7O_74>#^NLs({B(#1DmFq zRLGp;*CtGzBtioM_bS-x%_m(kOSX&jF0rGnLWJB1hFFRtsG?F(F~O%SHNYeVAXJ&s@Q%U{W3MMgueC1_gT zZyaj4$|Fy&`*@Cak2Y^nQdYC8CFnAJSQU3C0&}6GsX5VBPY&%6sBa5ve??K!d_wHA zJ@Gp2Fz7MM+%bo$+Yi3n6uDU%CI3;7zR5|oncq;CcWCk{*ddCPnzxNz$AIXCY>pa_ zFY-8{Fa+MWXpSf;3>N}M&ggDx5jsW$WmT4kJH_v6qMOv+CX?B@-{^?^v8Rj0(4&^> z`;ZI!@lVEP5%?7m31>cjx=H3384$&-A>_9WW${!!e^aEbS8D<&;O8&R+~#GMlkwp0 zjuhJ^8$&`T@GqKzTYg&|ZR6dbNBC%IKEe*;blJ2?!6;AAF0UPhlYdSCrU)HWQ+ql$ z?C%dp)!s}cS|}G-yy@;u*ALxX4IHEntDX^Tj*qCg?n^5O?oq|R9$xd!WXPjhN0b{ z$i~u^JcZMBtnd=o7@3_{Fs7#;;g918a6EN=q!GFOGz%zfijqGEaG+yB=~ku>L?>)J z!{pV&Z&`wV@2nLsydf-LGY=&R(({l1NXM@#p?XORj~k!*RvGSX3;MW8wHjpa0^=kh z7+N$7CtfHmPTGP?kqQ~Jg-2Y*@GeCzND<{2cG1)BJoj`XmAF&8s9@u^Due6{xk$ZH z07){cy@gS(7E8BLwCShP{EO(lP74DGO4J)@xzND|J*e@P7x8rRWTn0Y{bH)5MzX_x zGz_{i3-6R-<1J(?>j|P#>`n_N8-%t$*rMtSiAQJ`beG~rmr9vM*l+9BvzT^y2a1-T zcWmqq&9z}mIas(BOg(1ZF_49*<9|i!FE1TS=yO#OHXn2d}|A(`Z9L?X?mnWv(w%~;5FuVKD@L|$KH;^c3shL1$3&f&14d(qZ#4& zy;|DplEGAxptQid=z7mk-W7Z_5Eu9arfjQfD?7YhjbK;QE_*LlOXP#KWCICrq@oFvU?A}+y=;8dL1M+C zMJgN^5;#ECVXbC^WZfAQ{>)NW6V=0l-Y9qQ*nUuJkjq%d4AOA*&qY z5^D)xipW2-mmBZ(X7)$6^6Lg3z(ZKI}KRi;nrm?-kLFcgWm%!fqy?rslb_`y+G%q0Om)>aW6Ck8^7ln(8T>W&6=cM`uSkMo@o!vdAU z$gw##qtZuz7vj|nc#K%tEn`jf%deOb>GK8-S&!>nL@spTN^BohYgMV**=1ja=s#_P z9}Pe1gnM>ge~iVA&;^N?w(-2h__yZ&ZL(kay8Bjmpnb=ubpJSiHjKAS- zMHd@G0G*t!@sF#Wp_K!G=_dx4H?+5Qw9_}V2eAG$2w7V>0ORce&$t~3(xnXzOmzjV zodFv3Km#iSBY=&AQ41P~)BmXFIo~gQFK1`1uVCl^&;V8@EDE4gG<0?VXaVSitSzkV z6l`?$4FS(IfRH@{fbB=|yu3j853KQrS(Jg69>D%=ho%!{0)`o$t9&L37=dp%er5Pq z)eH`pgyLxadyUdVyV~xI3ytH6JEri=h6Boj!I4zrAp_1xFil#z*10m01P@icg zlkT|ch}o#s5W8`GiI~sZuk$guX0Iw4H^AeC6XoC`;xYK{NtGVLj*pw}lg8W5*fug< zK7&A)nNs{qj538)H-8?M)e+RP?vsTOd~F&Kp({lqlQ4VfuE|*r`@Ep<3{zN1SE3vR zFbwY#KB2IAbLrx%eKLZs)VeDJx=n7N?R!G2e z^N!#tt-k-5)b_eQcyXBg3U3+-$2eEsN7*%8rAKz7{-pcb&;oPR+q1-J*3JECZv)M7 zww9OsCfs$AqpUd{M@d)N)(~dtsh80|a-NK%pnWo1P1StPX%iu3NU8#syvqxmH}N(G zMQL&R=v$o#@{k-awFOCoq@oqY)IN{jo08X5b1$51V!Ei+U)L#^H*h$=pCfd&ypdL^ zI{5H-d+B<&aEfO+@R5QXW%#qOQCy`)?&gy7*9W6$q)c|smYwDh_5Mc*5k$TK4t2^? zRHbuextT6&V|Y)TT2D>B{GAJd2W}{y(#-Ml-CU#=SOhYuY}~zh@J0s!9k63F_J#8UTLG zCwYL@uQbnV#INb~uO-C(pB9DZdF}|D%fN{zX=-2(Tm^p4MXhI6?~m1j;qPS%={o3I zSR4ObpX~own10Lg9|%N-KN!THbDjaf#LUF_zo&JnvZaZ}EEeyMrdmwCc)}(XMS_Le zY?YOP#7rT{d=Z~PZ`BsN{ry|6h7ZI}_v!P+i46okRu>(tj1Rk~#rYNZB)9>Bd({Gd zW|u@^9%EJb9~}8=4X6XqdN6bOV&Hl#zHz8jsRdF5NUx8(tX!p?Enji(c&tzfLJ<`9 z(ISaEnVQ?CHi>7npPFYiIoS6~3cEu+usRIyUoI&3G2X)LzK7ENUT(Rp9o}M9q(dng zbhLLnuzvcE%L8Mr0_I@yfZoKb`w_$g#r5uXW`yRh_*`iBDl3rfovQj&FKqFc7xcsV z%zCNiHil@H7wpnqO?PR$*Tbeh)p_CA##g$gI-%tpwpR?`S?{e^ROgpTKXB~Aj9t9w zPV|yNqH)2LxIWBqRd!kA3%yk;qQ2jxX z??LZ9Z0qw*iit>-L*7hj5Hbb8-SfVSdZX6uL0v{ZpmsfO!#C%_o!wjTuB*TshKxJ>#$p`nBQ=S$%yw*$r`5Hf-m6 z4`=s+C?R6x$gbn=IP6HU?gfQcqaWqXZu#DtJu=3zx>LNN>5cY`kf+kCwRvMnrm%kp zDj111lF@c-6~DHGf@yV+@WJJlF@}Hs0k)d}5tjkkKmv6SHN5*FM(I?8gldUvF~2eJ z%PtSkgOpbg?zD%gg|-fUwsLBpa&ld+8@Un+l3<@Dj03;BCTT;jpX27$@dJjp7M?>c zvBO*r5iUOhJp_3hgbmI$-Z8{6{4vNeflfnM-%wB(VO1DQ@-cm*i4iY|9-pM6NYeLv zvb~x;VfV`eFj+jf<%Ws$`s#S(d5=+*BkIv+#>sZP*Lh%{-(#?bC?Lc0L#-Ej3Ef}h z#fz13L})g?)Aw0-;p8qKr_3idM{eEOLqIqq^hoxMbqa+vb;TWyx9_VDD$-={mo%Js zNjn6Hwbz9}Fu-RG<2=?aKGg-ouO;HOSnY)PFBc`=SL!GuIGJ&iEkshUgva)hF2EI-H=HIMJoJu|a%IXQ0<{k60sT!t+sZ1|&(g8}*6+*GMSAjWOVw{iC>H*%W= z1JtjhJ$hhvewesBS34iMb2Ay4f@6_RZ}G@Y6lE8$>LB}Fvv2qc&^d)l&U39 zUxxu7&wyJx7Az;bbuzL4j>&)&5wl^bo#y)?E0tE(SoDMK!kFe#SL1uZfmG>g?c`Um zh3>AT9NZ9v&LON~w+ni?wiq;Z3Mj!kvTlAET;; zmzUy`_2`Y_rs?%Q5fF_YMk)sU^^`kImQEC#yW+K&EK(~v+fA%W854ZoDr~A)b3%A0 zDXUo1?rCqsS1XVLEg>=q4Uiap7CVY7!l}HYRhr{O<7ef`W5tc#jrN70Z}L5T&L$*Q z^re#}o3r;k=i@WJ(JV5C6qL&t(srethj%Jv(=ee+X^J<%%g@pZFW3*xXXIDD&`QZn zj}MGsL z9;_VB25SDY*Rnn2<*1ma)3W)ZH(o`t2Kl)DAYQO9810F6a`lYa+~YgKR-V!kjBHNL zoRP$Vpb@#&*Kgdya){;*5R8apO``8|n*$(>QuZ(MI`DAy4_BjZ1yM$CQuOC9J0TtN zJm|;mI2Y;gTf_?e>YdNz8jPdnxBPd|(r`Hvi=-x6iFRaS0WO779j~SY(#%>oY_OL@ zZ~Cl1tO0cUAZ*$##cp~HwZB4y+10!o+?_pr+nO+5Zg_5v^`I{L4?&18_Yd7nYJ%+d zaFj(&^XBr@N7bY}OWs6{rkKV?auSyB*%~$Srl*WlJG&Od7>_viLy$i14TNt` z33D^xS=;sWNHTLV;&6J=cwDOUuEJhcFL5Q2dmT9Gd9ow+Q3T5vR>$rXL$AH}E7!8z z^$;!X$kZj^dwO&_INALuMVN~(P_DAEg)&Fhqy&ZZ#pvQ#EZ$|9WEv%t;}&yIAtno6 zvr{XkATx^j05T!y<-AWlsa>GKOR)Y5(wdQ!@mJcUS4AI|b&;GBmoR1mygq4%8=#C@ zf_-{oJI)EX=H31@{?%-BW?e~y^n=lvy?yrm!1i!_dU$?!aD1XNr!m`mD{AdIRgIOk zai!*{0fg(PbQE6ExtDeji7CQYc%&|NNQO*)ulX-x$67%SUXtaWqU!Yye<12hiIC`g_1afcWe=T1M9r2$N(4mBf{msl*K} zoD3aI^>t+o9Sv!eEFA1~rJienrjr0VYo_`FR>l^FK<|)F!NJf{8Ndh(Ng3)Im|7VF z7}+C1h34`B{8!H|e7T^>9 z(}#Yp@~=Mh54=mK;Hc;D1C=Yzo3Dgr7v%7scZGi^1HYGd+UDz?4LmV zkG*^b06WwF$@f-sv(T7z;oH%Si4i{`2|+CyEF_Ov7eXcpK?p{sScs#RtfJx53yyeK zMc8ZiP1t2f+#B7|hgK+n#x(S3+DbBkR3nx!JhdZ$5H>i`Q5wghSf!*J8&fniM`#S* z1icz2p=-)>rMampja}gJRdD20n*Dd4ox7H^JMF+QrXKZq5z@+&{Ik&Fdz|%GqL1L` z&G|405Ncd4VO~fcOMBCeqhywik5~`o^rW;a9!G1FBW%mkYctgP=>bfGq3<8wQ>GaW zJ-Kjaa`}OD$`Ui(K6!gyC7&ViGCwGFwq`%spFgcHHcN(HJ)A38)1y1TdT8Fkz*k4x zm>@X!^7cH==sp`1;=1Av<;~D7ns&o?mir=hRagg}F~?JUMOR+NxS@M)+H8c_o5))_ z)HY)aS9Q}9#OOX4$uHYrxY^0cxj3fZLuv@uqaFttKIHF+>F0i}%CBssu<-q<@-yqU zH(vTsH)m3+iz*pjbaLWNQYLttVUS}b{ecnE1RJL8aLMO{`T%SDM-$6*>&G?D;#=*m zhKb;f=7X?8-}d16eG*S(+~^K|LjQy~Xc_Go!dOW3j+#|<8_O##^Y;GLbCw~SS_ZJP0l6nQ?mXGymvj>2 z^#KnsiL}aFk{i=0uc5)oXtk0x>KyVZ1I<{t4d>QTUwavKFRj)?apNG@t(1(|A=>MN zEG%FT2WJF*pw&{-h@AxlGa|#6+RtEm=beY4Cadmj*uyxObH9!;1-yn1Y!fRDsn?Ka zg2W(glL%sG@T`6#tr509uUug)uW0t@uV{9|PFdqg9lub^-Yk;ubZkKsU%RB8zZNOC zoA9C6I(IGf(+9d`(as1kO*;2%iZm8B~9J@{6qGVy}^53_@K40doXLhtn+ z2p+qQ9*yjAt;YgC>!%`A4jxjMkXK~Gic4fIKzM6{U+5R04|UNi@E#7;QRnol#Z)CC zkkqx*luo1BC68pV+!!R%njVYB&>Dt_+)tz-$FiZ4WsUT+q81lM7)TmCVu^t!o%}1xb7#x#ihrXA!wlCdh z+$P?@u-VO)>tnmKhg!9eWFYeC+d6rI61-Zo_mCy;MVu1C#cJ_mlCX^~M8v zz}o%JzXI-oZ@{ZO{y8)2ger<1!qmVScU`j9Ql}?qg4J6rV=uNt3w_PvDs)IkVzU$d z{7%^Eqkqf0Ah79z!`}Zo#K#IPZmdg+)9)jP;Ep)8pk7iIzVCKI+qf_7Sj+J` zJQ6D?#OL6dVe)ViyVWV;Qfai#t5Y9z%%iOSu5f)r^lA|n|0>yW2E-AIfV21w%fFcVc2bstBxl(IBXB zUwRAjX!79s2us7(!6jCQIX*+LAC3+|!@@FS(t*8BiP(#l$6`5*FhpUw@V2jDSbHn* zVDxl!eCj@mlWDjaa?%6JUa~}=Ybf4D=`Ne&QgM%m`$HtB8$nNj8WZP*9!l6R z5}9d5H1@gq1L?M_Fw32!ylQBz_)qB{moHkasq9J?uk8iS7sAE5A3jMnwoP`f_IDXs zZ3PRmTjK6DVi`0+6FzmcBiVNIOMkI3Xo^RG$2-(*DW<$e`KSDtqWe6^brtlT1|t|_I~*~s)&R2Zp}h|>mc=F~)j3tIwzmbibS z75_h9dR+`n4Cf^@Vzr6d}5>5=Z))+joqm;_*9Qc zC1cZrqw(I`53L>--dDQMZHr4&QI_x*?s%giB&R|O5qm>+RCkztT>7*?i0#}q9yOCa zr{7s)3?gCYu(|XtzHKCaFF8Kl=M&qP_cl7#@I6|iJBbCc{d@V`Pr4pe%$Zltr)MM) zJezNZxO^{CE9%T34Zx6R7@6ZT@c64YbSbE@oX>cd886}Z;*;WS218+3Gp{GQhAQZk9dB< zMGjSwc1(r=AMn$$$6~;FQMwRUzya)Y$@^vA%8Wsz z!EU;2dCxuS`w86Pt`muG#Bx8E>yQ?NX9M@*dIV&8y0RaAlAnJ4oK2V2UYN^*kkw~I zHLbWWa7qos--2-w@Pi^qHPJcNBYLz7NOhS7z{YOKZ8myZ>a*8(X4LRmhnDywajIZ; z`J+m+N`*86o$wb*$1uX{>9;#nsTT025nObN@rIRoOk9EYxlfQ^_kBwL2-f}{d}|SJ z1Rsk}NPp);F;R=3@}-lt$Wp~7wcqGo&a)G&9W}MXsf?g5Ix{Td)vSCyJKy?6#1KYv zfI}DgwXK+Y(|a4jJsV;&2)Eu1_>Q%rMR7YLD8x(&HFqew??o7{m+^X)6D=7%@tR5) z^tU)V%u+5%Uwqz_hrFSa+XJU;=Fi9Ycn%t`oizcY=<4&k(d>u)BP{y?|4U*hE)oHYRQ zm=X-KnqDn<1N2)9k9P|xwx_rI?c*sI5uhf~RKx3Vk)03FNJh5A7>S zg!dFdDhJ?P3$GPE;ec}czd^pi^&o%G1Y6YhLgx0RSIeXCNGxV}`yto<%9sWXHwd#;HW>%K)2WhZQQyq3V*g~H*X8JUU z50h;+*qAaujHSBI_Am6@g7GirLBEkrYtjU{P8GZ%o^NwRR*jhJ9MPyq8#E@r=|J!8 z@Z`@T) zu&c`iw}rG|dEZuTdr^oS!2`*lTkVjh=!+E}jbP>ftltqrll&d$lo44&L*6H`{~ zR6O|NIdV`O#XP5~2b=*PRH$(%0y$j@(D|%J8AK7}hEL0aC*KUZH$_?Hz868&pwPK` zvkxpyhiQq_qlnAteFULxQQgMXp-egXlJHf&(f%ga@>o-x(JeRLb3Ppte)`?|XOBs& z?>2l0FHrP#E+$u_M^N;>GP$66Z_d7IhY+ktY1X$iCjIKK)H|?M zy0|!=G}!Otd9C?P{oL#WPt==_Qe$J*B8_OFwd=%!T97zqK|N`Hv?M9xIp3lSYK>wi zQQx?#PhGr&Cgs=>${}LiM8T>WOtte+OlO67w4O1|r5EQp%nf|FD_u-2S0cV-WDUm` z+F&R>(WuE*xkQ14M4cmHEew;Vh=9w-@Cq%-KGDlPsp1r*YgkKNS_pMWB?L{^OG=gw z#>jghM@0rl)tjT1z=~%`sVlg46%5|j>VV7hw~cjpqwT-I>x}g24GY&;3N1M28m$n@ z4O$x+m%3Fbs@pwOWRV5@_WHEuO=>a`Zh~L(sb?gy1KaXO`?YY-EZ))g62AV%#`o~x zP7$-2+r4xMMyooLXF5?#Xjkz3c;vgp8GN)yd_gw|Lavv!;$-mFe&^WEa`!759YGI{ z{L$q({yq!qS@f*?vBfBESvCl$-`j_jr|Hz!FiMY&m9Isp?sL#Q$DvzI0^U`3{4|ID zG%KC`2(*ECnFyDOnK5_3jLmb`d|d;pcX~N#<8WiS^HwpzHZft>M1=;$O!LN(=IeNw z8V!~pi;x7;qi2?;Go+m^WJQYJilV*lsa+R@Ko~csRts~0>(E1dQLm`xW!j4kxwc3WS0&ZU^kt>sDS|7G zM1vulp~^)D148Q+E*cUJ^0q}AqY;Hv-tNBPSBaz3Rz4HCcgx)k@om^XDRRoHE%^Mc zeq-1qOLR__Rx&R+o;rFzFg1DYGK_&I%gf6^MB;=)?Cte()R& zV{co8RIFOQj~wmkRWoJ8Bh2Rr6c%&s#Z4k%@I^WlF3l(f1-OEZY_m3ZW9{-AT9kxk zvRj0rqG~^7n;OpWsbqu5pe~Xs`%a7**R--j zWt+|}p$>Mx*K6fGxy@hUX~3DN7+7N&@bL+?g7Zo}tn%zl4-=p5$xLU?m+X|&a{)tqw&N*UjtyiP^F6TnNmd1vglOS#4kedQDkgW1|9=v+W}xp6~^ zj!MEBVvbSvw!tnKOBmV%!QxzG)1J=iF{Ug@KdvXv?HoR1M}&32s~xl{kY$`_ii_9> zV@1L#rnK_vTH?{rGoW>31xe||<)q+TY0q(wXX*Wg+Xc>t+zsWri<3=XZm5P= z%s9qQ*?Qh?L`oMI6sj9*WLkRk9(G&gb&}sP8^QZ3!Vw_nx?o)?!hK;m*Uzdp{=T@_ z0v*AeII5-UX=7+=`~4K3`f8+X(PO*4=c~n8`R-HGSN9yuSj5|J*S(w}7Wt47B;B`f ziQy_>2iNADl9c9O=6wbKc4RbZ@nLwqHQ1w%_v$m)A%Lsvh(Cd06df}L9}_VwnB)mx z8b(F^KQP75oZB;t$PDbrUt8%w()B-4i@(u=e=^0aKu-2|rWnZo{a=`3W~OJRm>HcwOXOj8%J0lCw#=`oe zOa@?m&woJG6Y%<6!*fl*Sm+f8q*DLydFKDphkvD* zp?{1v`9CSlPlRyUQ^RLDdyX!d2z71z~*k5#~l z?%eH)+erOR#@W-8=TmPb6p8;_pJ_+zL^R4-ar1}|Mc91=;y$OH8O@u8W0G@OOeUH# z+Vowg!$-7RFA{&QFAn!*-8Vj0dd}zU1gAZx9rxpGgq6I4nv+SH8a>|h#x8IV<=Ob} zcq^TZ53TIjZe?@%JnlE6Z&uYebix+33#tkj=9$lmRCXVJzZ;i4VCO+wZZ*|y-6=uNBAQr-n5s|3gWYa}SI z!R#T@>^&0)_xvUSgxt=2hOzvt?!w$g*6Xga=6F?>20X15dk3Bdss?T^ht9aM@6Ht! z+TX&hqCDc!G>xeSM2oN&t!NdCJ5_tTX4PNU^)#iBeP$WkHf=#gF; zjF+g$I#ON^IjbAYi>$y(VG7$O&l)HjW<4<>6~IH;9S6V(qF?}bRUA~+kdpmY943Sk zU^;QS7xm_Ah+Y}oz6599=@XRvov}~#1(U%3+{`(&c>36UJHSy3cVe#aMMJpF9_6b{ zRzzM8H2)=hp`;sDv8tercK})!Ld4)UN!K-*W{(G`?jqu}kXWl}naRl-u022H+k%=8 zY;%Fg_+?kI`p)I?7Hc7B4FaEY%b&1QB5#Zy?jm)=h+df-AsvC91|+(h!lbfa1pDew zNj-Xx93Wa*F$c-n#=IZ~$MQY&Q6!*>Y6Pr!rv-3>xxk;|1aW_nxKFHTg$R zT)1HMp|#TPN3Ziq=g|yns3|EN9x0OL7S({~b0Ef{rg^8*=)4kJPI|Shu|=yn@~vT` za(+C%XlhI8sI545rKzUN4tLTE>NYeFY5s~fSwlC;{ZU~(bnejBX ztgC^iA>6ldy%}NS@C~96%N{)+Xl^w|q~91MG<}h{>;y;PKTSlON_60h_70uu5&@+} zwgHA-Y7(y!f?z<^iMRb`!cBaT>vlr|MdR{zTi(O&xWx?DXO4IqzZqQ3lhSSiq3+C} ze~SEiN|5wqfmlM&E=ns3EJ~!0qHmTUlD`Gv25QqKh#T_aoxp}Qq7{4wCwy8`VMM2b zSkhA#Pe+p&)iDGedV37&VN)SZhv~fLvT-3^c>;|*SGXf7q!W6byU#37!{kUZIO_r! z!ffDKKn_RRh=`_HU|}v%m27By)f)emIQLaIK#YP75jMz|SX?_+30+z~6mb;ot3qri z<#25cUdpAD$dXpTl5Q$8oW@?I38~3v_72|CL9=}2P%_@6H`|=J4_Go4FfR6d`Myir zvha>vOwBw;stwKgG)fKMSFe}R-%&>mT)&L4LzkH1Aa%02N3fxeradi}o`5r$M1*8V zhl>!gp=RYkZ?Amy9P-sidL6o|`m&dh{a>cHJ4xP5zDkYlYFrE7ObTI-Yr47YSYH^9&t3xXY#W{g6T$NMU!O-?brnKV4#F*|mKpRi34M`X z_m`kHS_)FhJ9YC>%~2Zt8X~H0-^)7e->M&J+Bp5=3Z;^$tT-I&9At=>{WaBG#dfCd6}~y$-g= zke=4h7AtJ<*;F-B*HML=nux-3u_IrP zP4Ky^lUoTnzUY%vP*%>N<IQh#&eb$2W_^Et#)SJZ2>erTcC^~gQRe(%yFW(QA?)%GIsKtB!Taf^&= zPOVEu&~6JoR8X1@8hAaL3pYUKO z2z9D$A#^pjJ*WlN9rP3%^;c+7IqZpfFtM~U6dxYksl~nF=#W1pG+VGIPJA(*hnaZ-Sc<;~MA7Oi#R2ATxx3^FK z-Dq!Azbgvjpr+sgdumpv8RCFNwY=RGZpOSq+wTIq%< zxmr5Lv+bh1crRI@Ht9Y{gU)U0qdS4pN4<5EJA>I3WfvG+V-H=AMDgu@@hOlBwa&!q zJy50+BX?U4r3Umq+ZqhHt%;6Dc2Ch9yzW1_KC8B7cy5(Z1*m`nJDY3zB|C`7O);=o8f!R9B~PpLPeEZ1GPH)6`9GU&3}`4OID)UP=&VZEvj<}<;q zOmSTTId58-W*@hbrz5u0MOZ%dfZtzsn1+O}`@O%;oN1KkbEvCTTrp4Sa4W}*#8zSR zNHw6~Vss9UyaQpd2kD~_PYJUZeDII~I6*hny)aw0|8Sgp@m?*HYU*8qSJ~{b49){b zCn^*`__oi_AXan_^GmE-?Dvh#)i2i=QV6ML+X@LzgQkP#QgoXi8r#6kOocS=aXxHw zaO=y?Lw#79UlDvOXmlAVqaxZ!h8~fruBySo==IR?4JLUnK7Xr*$Na2UB|-~`bu%Qq!ntFV*nlaKo#>12kCK2aHjlbaBXLRQ3gtCeVWcwYPfM2V|bkX@fX$iJiG zA8_Fr`!LZnbNq>pf0vE_cO4bWzv-y{0UdL&{zXT{OwaKrI_3ayu>4g=C8s1Hr7G~B z(D6@f{8u3r&9frsrA#`m-=$PPV5Cf|pRw@I@_wS=zv5tE%pU@+ zpYp9=sQk|o|Ax2!jQPj<{~^WyZzVA^Gye%8Wnp1r0T=;(+8Ee4o>`#hAAr>#HhLxwb|A6w zR~rjG8<5!etBs!idBXnH#>Bz;HyblOJ8&lbH6086GdJ~D8yg49zuOqt*qHyleGE*j z9DlEa5xAuNTX`(>&tk&A*1^K?JgfiB#z_A+8yhp*-|Apv26AfumY0o<1-O>{wLEqP z=6^4bf$2ZmfD7hd^D;0oKacCbl*hot&i1!-z@hQCI@tdHOc~gi=zsehKq)LxvugL_ z`-~J!T@8U_13)KlZ4Es4^apJvVP#|uJOuTF!jcdM9)sd$HsoLi{)b@E)iq*ZVr1rE xH)3U>H)PW@Fl5y?&}TH_h5p}3fafTI`dE7h;JE)WC>hv+MM0C0h{%dU|35{Ppxgie From d9ffc7542ce60fe915e91dd81779a3088cae10ec Mon Sep 17 00:00:00 2001 From: Manu Altieri Date: Tue, 10 Sep 2024 21:25:19 +0200 Subject: [PATCH 69/97] added resume upload --- src/linkedin-api.py | 138 ++++++++++++++++++++------------------------ 1 file changed, 62 insertions(+), 76 deletions(-) diff --git a/src/linkedin-api.py b/src/linkedin-api.py index 3c007b2..cb38de7 100644 --- a/src/linkedin-api.py +++ b/src/linkedin-api.py @@ -1,66 +1,58 @@ -<<<<<<< HEAD from typing import Dict, List from linkedin_api import Linkedin from typing import Optional, Union, Literal from urllib.parse import quote, urlencode, parse_qs, urlparse -======= ->>>>>>> upstream/v3 import logging -from typing import Dict, List -from typing import Optional, Union, Literal -from urllib.parse import urlencode - -from linkedin_api import Linkedin +import json # 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. @@ -162,21 +154,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 @@ -189,12 +181,14 @@ 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}", @@ -223,26 +217,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'] @@ -250,18 +244,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) @@ -271,11 +265,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": [ # { @@ -355,10 +349,9 @@ class LinkedInEvolvedAPI(Linkedin): # } # ], # "trackingId": "" - # } + #} # Push the commit to the repository and create a pull request to the v3 branch. -<<<<<<< HEAD def create_request_pdf(self, filename: str) -> str | None: """ @@ -476,13 +469,10 @@ class LinkedInEvolvedAPI(Linkedin): with open(file_path, 'rb') as file: binary_data = file.read() return binary_data -======= ->>>>>>> upstream/v3 def set_job_as_applied(self, job_id: str) -> None: self.already_applied_jobs.append(job_id) -<<<<<<< HEAD def upload_linkedin_resume(self, cv_path: str) -> str | bool: url = self.create_request_pdf("resume.pdf") if url: @@ -501,14 +491,6 @@ 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) -======= - -## 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) ->>>>>>> upstream/v3 for job in jobs: job_id: str = job["job_id"] @@ -532,3 +514,7 @@ if __name__ == "__main__": print(field) break + + + + \ No newline at end of file From 5420b6b80dce5c666f1ef7573ef06d51607b80b2 Mon Sep 17 00:00:00 2001 From: Shivam Sareen Date: Tue, 10 Sep 2024 20:20:44 -0700 Subject: [PATCH 70/97] Resolved dependency conflict --- requirements.txt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 11127a8..9f271ac 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ langchain==0.2.11 langchain-community==0.2.10 -langchain-core==0.2.24 +langchain-core===0.2.36 langchain-openai==0.1.17 langchain-text-splitters==0.2.2 langsmith==0.1.93 @@ -14,9 +14,10 @@ click git+https://github.com/feder-cr/lib_resume_builder_AIHawk.git linkedin-api pdfminer.six==20221105 +jsonschema inputimeout==1.0.4 langchain-ollama==0.1.3 -langchain-anthropic==0.1.3 +langchain-anthropic langchain-google-genai==1.0.10 jsonschema==4.23.0 jsonschema-specifications==2023.12.1 From ff24e276c18c8230ac9e20cb57954f06436208e0 Mon Sep 17 00:00:00 2001 From: Shivam Sareen Date: Tue, 10 Sep 2024 22:01:21 -0700 Subject: [PATCH 71/97] Update gitignore --- .gitignore | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index c5c01ec..0a20e2e 100644 --- a/.gitignore +++ b/.gitignore @@ -152,4 +152,6 @@ mono_crash.* data_folder/output/* generated_cv/* chrome_profile/* -answers.json \ No newline at end of file +answers.json + +virtual/* \ No newline at end of file From a4846a8c6f4ee10e33928305cf5aefb82067ad49 Mon Sep 17 00:00:00 2001 From: blackms Date: Wed, 11 Sep 2024 18:48:46 +0200 Subject: [PATCH 72/97] First version with one issue of test suite --- requirements.txt | 1 + src/linkedIn_easy_applier.py | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/requirements.txt b/requirements.txt index 11127a8..dedcfa3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -23,3 +23,4 @@ jsonschema-specifications==2023.12.1 httpx~=0.27.2 python-dotenv~=1.0.1 PyYAML~=6.0.2 +pytest>=8.3.3 diff --git a/src/linkedIn_easy_applier.py b/src/linkedIn_easy_applier.py index cf64245..1d734e8 100644 --- a/src/linkedIn_easy_applier.py +++ b/src/linkedIn_easy_applier.py @@ -76,6 +76,20 @@ class LinkedInEasyApplier: logger.error("Failed to return to job page after %d attempts. Cannot apply for the job.", max_attempts) raise Exception( f"Redirected to LinkedIn Premium page and failed to return after {max_attempts} attempts. Job application aborted.") + + def apply_to_job(self, job: Any) -> None: + """ + Starts the process of applying to a job. + :param job: A job object with the job details. + :return: None + """ + logger.debug(f"Applying to job: {job}") + try: + self.job_apply(job) + logger.info(f"Successfully applied to job: {job.title}") + except Exception as e: + logger.error(f"Failed to apply to job: {job.title}, error: {str(e)}") + raise e def job_apply(self, job: Any): logger.debug("Starting job application for job: %s", job) From 6eaf521db6b30af6863363ad4f5ef1ff166a723c Mon Sep 17 00:00:00 2001 From: blackms Date: Wed, 11 Sep 2024 18:52:17 +0200 Subject: [PATCH 73/97] Adding tests folder --- tests/__init__.py | 0 tests/test_job_application_profile.py | 153 +++++++++++++++++++++++ tests/test_linkedIn_authenticator.py | 158 ++++++++++++++++++++++++ tests/test_linkedIn_bot_facade.py | 14 +++ tests/test_linkedIn_easy_applier.py | 97 +++++++++++++++ tests/test_linkedIn_job_manager.py | 168 ++++++++++++++++++++++++++ tests/test_utils.py | 96 +++++++++++++++ 7 files changed, 686 insertions(+) create mode 100644 tests/__init__.py create mode 100644 tests/test_job_application_profile.py create mode 100644 tests/test_linkedIn_authenticator.py create mode 100644 tests/test_linkedIn_bot_facade.py create mode 100644 tests/test_linkedIn_easy_applier.py create mode 100644 tests/test_linkedIn_job_manager.py create mode 100644 tests/test_utils.py diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_job_application_profile.py b/tests/test_job_application_profile.py new file mode 100644 index 0000000..91cc2a6 --- /dev/null +++ b/tests/test_job_application_profile.py @@ -0,0 +1,153 @@ +import pytest +from src.job_application_profile import JobApplicationProfile + +@pytest.fixture +def valid_yaml(): + """Valid YAML string for initializing JobApplicationProfile.""" + return """ + self_identification: + gender: Male + pronouns: He/Him + veteran: No + disability: No + ethnicity: Asian + legal_authorization: + eu_work_authorization: "Yes" + us_work_authorization: "Yes" + requires_us_visa: "No" + legally_allowed_to_work_in_us: "Yes" + requires_us_sponsorship: "No" + requires_eu_visa: "No" + legally_allowed_to_work_in_eu: "Yes" + requires_eu_sponsorship: "No" + work_preferences: + remote_work: "Yes" + in_person_work: "No" + open_to_relocation: "Yes" + willing_to_complete_assessments: "Yes" + willing_to_undergo_drug_tests: "Yes" + willing_to_undergo_background_checks: "Yes" + availability: + notice_period: "2 weeks" + salary_expectations: + salary_range_usd: "80000-120000" + """ + +@pytest.fixture +def missing_field_yaml(): + """YAML string missing a required field (self_identification).""" + return """ + legal_authorization: + eu_work_authorization: "Yes" + us_work_authorization: "Yes" + requires_us_visa: "No" + legally_allowed_to_work_in_us: "Yes" + requires_us_sponsorship: "No" + requires_eu_visa: "No" + legally_allowed_to_work_in_eu: "Yes" + requires_eu_sponsorship: "No" + work_preferences: + remote_work: "Yes" + in_person_work: "No" + open_to_relocation: "Yes" + willing_to_complete_assessments: "Yes" + willing_to_undergo_drug_tests: "Yes" + willing_to_undergo_background_checks: "Yes" + availability: + notice_period: "2 weeks" + salary_expectations: + salary_range_usd: "80000-120000" + """ + +@pytest.fixture +def invalid_type_yaml(): + """YAML string with an invalid type for a field.""" + return """ + self_identification: + gender: Male + pronouns: He/Him + veteran: No + disability: No + ethnicity: Asian + legal_authorization: + eu_work_authorization: "Yes" + us_work_authorization: "Yes" + requires_us_visa: "No" + legally_allowed_to_work_in_us: "Yes" + requires_us_sponsorship: "No" + requires_eu_visa: "No" + legally_allowed_to_work_in_eu: "Yes" + requires_eu_sponsorship: "No" + work_preferences: + remote_work: 12345 # Invalid type, expecting a string + in_person_work: "No" + open_to_relocation: "Yes" + willing_to_complete_assessments: "Yes" + willing_to_undergo_drug_tests: "Yes" + willing_to_undergo_background_checks: "Yes" + availability: + notice_period: "2 weeks" + salary_expectations: + salary_range_usd: "80000-120000" + """ + +def test_initialize_with_valid_yaml(valid_yaml): + """Test initializing JobApplicationProfile with valid YAML.""" + profile = JobApplicationProfile(valid_yaml) + + # Check that the profile fields are correctly initialized + assert profile.self_identification.gender == "Male" + assert profile.self_identification.pronouns == "He/Him" + assert profile.legal_authorization.eu_work_authorization == "Yes" + assert profile.work_preferences.remote_work == "Yes" + assert profile.availability.notice_period == "2 weeks" + assert profile.salary_expectations.salary_range_usd == "80000-120000" + +def test_initialize_with_missing_field(missing_field_yaml): + """Test initializing JobApplicationProfile with missing required fields.""" + with pytest.raises(KeyError) as excinfo: + JobApplicationProfile(missing_field_yaml) + assert "self_identification" in str(excinfo.value) + +def test_initialize_with_invalid_yaml(): + """Test initializing JobApplicationProfile with invalid YAML.""" + invalid_yaml_str = """ + self_identification: + gender: Male + pronouns: He/Him + veteran: No + disability: No + ethnicity: Asian + legal_authorization: + eu_work_authorization: "Yes" + us_work_authorization: "Yes" + requires_us_visa: "No" + legally_allowed_to_work_in_us: "Yes" + requires_us_sponsorship: "No" + requires_eu_visa: "No" + legally_allowed_to_work_in_eu: "Yes" + requires_eu_sponsorship: "No" + work_preferences: + remote_work: "Yes" + in_person_work: "No" + availability: + notice_period: "2 weeks" + salary_expectations: + salary_range_usd: "80000-120000" + """ # Missing fields in work_preferences + + with pytest.raises(TypeError): + JobApplicationProfile(invalid_yaml_str) + +def test_str_representation(valid_yaml): + """Test the string representation of JobApplicationProfile.""" + profile = JobApplicationProfile(valid_yaml) + profile_str = str(profile) + + assert "Self Identification:" in profile_str + assert "Legal Authorization:" in profile_str + assert "Work Preferences:" in profile_str + assert "Availability:" in profile_str + assert "Salary Expectations:" in profile_str + assert "Male" in profile_str + assert "80000-120000" in profile_str diff --git a/tests/test_linkedIn_authenticator.py b/tests/test_linkedIn_authenticator.py new file mode 100644 index 0000000..6277fc0 --- /dev/null +++ b/tests/test_linkedIn_authenticator.py @@ -0,0 +1,158 @@ +import pytest +from selenium.webdriver.common.by import By +from selenium.webdriver.support.ui import WebDriverWait +from selenium.webdriver.support import expected_conditions as EC +from src.linkedIn_authenticator import LinkedInAuthenticator +from selenium.common.exceptions import NoSuchElementException, TimeoutException + + +@pytest.fixture +def mock_driver(mocker): + """Fixture to mock the Selenium WebDriver.""" + return mocker.Mock() + + +@pytest.fixture +def authenticator(mock_driver): + """Fixture to initialize LinkedInAuthenticator with a mocked driver.""" + return LinkedInAuthenticator(mock_driver) + + +def test_set_secrets(authenticator): + """Test setting secrets (email, password).""" + authenticator.set_secrets("test@example.com", "password123") + assert authenticator.email == "test@example.com" + assert authenticator.password == "password123" + + +def test_start_logged_in(mocker, authenticator): + """Test starting LinkedIn when already logged in.""" + mocker.patch.object(authenticator, 'is_logged_in', return_value=True) + mocker.patch.object(authenticator.driver, 'get') + mocker.patch("time.sleep") # Avoid waiting during the test + + authenticator.start() + + authenticator.driver.get.assert_called_with('https://www.linkedin.com/feed') + authenticator.is_logged_in.assert_called_once() + assert authenticator.driver.get.call_count == 1 + + +def test_start_not_logged_in(mocker, authenticator): + """Test starting LinkedIn when not logged in.""" + mocker.patch.object(authenticator, 'is_logged_in', return_value=False) + mocker.patch.object(authenticator, 'handle_login') + mocker.patch.object(authenticator.driver, 'get') + mocker.patch("time.sleep") + + authenticator.start() + + authenticator.driver.get.assert_called_with('https://www.linkedin.com/feed') + authenticator.handle_login.assert_called_once() + + +def test_handle_login(mocker, authenticator): + """Test handling the LinkedIn login process.""" + mocker.patch.object(authenticator.driver, 'get') + mocker.patch.object(authenticator, 'enter_credentials') + mocker.patch.object(authenticator, 'submit_login_form') + mocker.patch.object(authenticator, 'handle_security_check') + + # Mock current_url as a regular return value, not PropertyMock + mocker.patch.object(authenticator.driver, 'current_url', return_value='https://www.linkedin.com/login') + + authenticator.handle_login() + + authenticator.driver.get.assert_called_with('https://www.linkedin.com/login') + authenticator.enter_credentials.assert_called_once() + authenticator.submit_login_form.assert_called_once() + authenticator.handle_security_check.assert_called_once() + + +def test_enter_credentials_success(mocker, authenticator): + """Test entering credentials.""" + email_mock = mocker.Mock() + password_mock = mocker.Mock() + + mocker.patch.object(WebDriverWait, 'until', return_value=email_mock) + mocker.patch.object(authenticator.driver, 'find_element', return_value=password_mock) + + authenticator.set_secrets("test@example.com", "password123") + authenticator.enter_credentials() + + email_mock.send_keys.assert_called_once_with("test@example.com") + password_mock.send_keys.assert_called_once_with("password123") + + +def test_enter_credentials_timeout(mocker, authenticator): + """Test entering credentials with a TimeoutException.""" + mocker.patch.object(WebDriverWait, 'until', side_effect=TimeoutException) + + authenticator.set_secrets("test@example.com", "password123") + + authenticator.enter_credentials() + + authenticator.driver.find_element.assert_not_called() # Password input should not be accessed if email fails + + +def test_submit_login_form_success(mocker, authenticator): + """Test submitting the login form.""" + login_button_mock = mocker.Mock() + mocker.patch.object(authenticator.driver, 'find_element', return_value=login_button_mock) + + authenticator.submit_login_form() + + login_button_mock.click.assert_called_once() + + +def test_submit_login_form_no_button(mocker, authenticator): + """Test submitting the login form when the login button is not found.""" + mocker.patch.object(authenticator.driver, 'find_element', side_effect=NoSuchElementException) + + authenticator.submit_login_form() + + authenticator.driver.find_element.assert_called_once_with(By.XPATH, '//button[@type="submit"]') + + +def test_is_logged_in_true(mocker, authenticator): + """Test if the user is logged in.""" + buttons_mock = mocker.Mock() + buttons_mock.text = "Start a post" + mocker.patch.object(WebDriverWait, 'until') + mocker.patch.object(authenticator.driver, 'find_elements', return_value=[buttons_mock]) + + assert authenticator.is_logged_in() is True + + +def test_is_logged_in_false(mocker, authenticator): + """Test if the user is not logged in.""" + mocker.patch.object(WebDriverWait, 'until') + mocker.patch.object(authenticator.driver, 'find_elements', return_value=[]) + + assert authenticator.is_logged_in() is False + + +def test_handle_security_check_success(mocker, authenticator): + """Test handling security check successfully.""" + mocker.patch.object(WebDriverWait, 'until', side_effect=[ + mocker.Mock(), # Security checkpoint detection + mocker.Mock() # Security check completion + ]) + + authenticator.handle_security_check() + + # Verify WebDriverWait is called with EC.url_contains for both the challenge and feed + WebDriverWait(authenticator.driver, 10).until.assert_any_call(mocker.ANY) + WebDriverWait(authenticator.driver, 300).until.assert_any_call(mocker.ANY) + + + +def test_handle_security_check_timeout(mocker, authenticator): + """Test handling security check timeout.""" + mocker.patch.object(WebDriverWait, 'until', side_effect=TimeoutException) + + authenticator.handle_security_check() + + # Verify WebDriverWait is called with EC.url_contains for the challenge + WebDriverWait(authenticator.driver, 10).until.assert_any_call(mocker.ANY) + diff --git a/tests/test_linkedIn_bot_facade.py b/tests/test_linkedIn_bot_facade.py new file mode 100644 index 0000000..787d99a --- /dev/null +++ b/tests/test_linkedIn_bot_facade.py @@ -0,0 +1,14 @@ +import pytest +# from src.linkedIn_job_manager import JobManager + +@pytest.fixture +def job_manager(): + """Fixture for JobManager.""" + return None # Replace with valid instance or mock later + +def test_bot_functionality(job_manager): + """Test LinkedIn bot facade.""" + # Example: test job manager interacts with the bot facade correctly + job = {"title": "Software Engineer"} + # job_manager.some_method_to_apply(job) + assert job is not None # Placeholder for actual test diff --git a/tests/test_linkedIn_easy_applier.py b/tests/test_linkedIn_easy_applier.py new file mode 100644 index 0000000..7000a41 --- /dev/null +++ b/tests/test_linkedIn_easy_applier.py @@ -0,0 +1,97 @@ +import pytest +from unittest import mock +from src.linkedIn_easy_applier import LinkedInEasyApplier + + +@pytest.fixture +def mock_driver(): + """Fixture to mock Selenium WebDriver.""" + return mock.Mock() + + +@pytest.fixture +def mock_gpt_answerer(): + """Fixture to mock GPT Answerer.""" + return mock.Mock() + + +@pytest.fixture +def mock_resume_generator_manager(): + """Fixture to mock Resume Generator Manager.""" + return mock.Mock() + + +@pytest.fixture +def easy_applier(mock_driver, mock_gpt_answerer, mock_resume_generator_manager): + """Fixture to initialize LinkedInEasyApplier with mocks.""" + return LinkedInEasyApplier( + driver=mock_driver, + resume_dir="/path/to/resume", + set_old_answers=[('Question 1', 'Answer 1', 'Type 1')], + gpt_answerer=mock_gpt_answerer, + resume_generator_manager=mock_resume_generator_manager + ) + + +def test_initialization(mocker, easy_applier): + """Test that LinkedInEasyApplier is initialized correctly.""" + # Mock os.path.exists to return True + mocker.patch('os.path.exists', return_value=True) + + easy_applier = LinkedInEasyApplier( + driver=mocker.Mock(), + resume_dir="/path/to/resume", + set_old_answers=[('Question 1', 'Answer 1', 'Type 1')], + gpt_answerer=mocker.Mock(), + resume_generator_manager=mocker.Mock() + ) + + assert easy_applier.resume_path == "/path/to/resume" + assert len(easy_applier.set_old_answers) == 1 + assert easy_applier.gpt_answerer is not None + assert easy_applier.resume_generator_manager is not None + + +def test_apply_to_job_success(mocker, easy_applier): + """Test successfully applying to a job.""" + mock_job = mock.Mock() + + # Mock job_apply so we don't actually try to apply + mocker.patch.object(easy_applier, 'job_apply') + + easy_applier.apply_to_job(mock_job) + easy_applier.job_apply.assert_called_once_with(mock_job) + + +def test_apply_to_job_failure(mocker, easy_applier): + """Test failure while applying to a job.""" + mock_job = mock.Mock() + mocker.patch.object(easy_applier, 'job_apply', + side_effect=Exception("Test error")) + + with pytest.raises(Exception, match="Test error"): + easy_applier.apply_to_job(mock_job) + + easy_applier.job_apply.assert_called_once_with(mock_job) + + +def test_check_for_premium_redirect_no_redirect(mocker, easy_applier): + """Test that check_for_premium_redirect works when there's no redirect.""" + mock_job = mock.Mock() + easy_applier.driver.current_url = "https://www.linkedin.com/jobs/view/1234" + + easy_applier.check_for_premium_redirect(mock_job) + easy_applier.driver.get.assert_not_called() + + +def test_check_for_premium_redirect_with_redirect(mocker, easy_applier): + """Test that check_for_premium_redirect handles LinkedIn Premium redirects.""" + mock_job = mock.Mock() + easy_applier.driver.current_url = "https://www.linkedin.com/premium" + mock_job.link = "https://www.linkedin.com/jobs/view/1234" + + with pytest.raises(Exception, match="Redirected to LinkedIn Premium page and failed to return"): + easy_applier.check_for_premium_redirect(mock_job) + + # Verify that it attempted to return to the job page 3 times + assert easy_applier.driver.get.call_count == 3 diff --git a/tests/test_linkedIn_job_manager.py b/tests/test_linkedIn_job_manager.py new file mode 100644 index 0000000..0b4121e --- /dev/null +++ b/tests/test_linkedIn_job_manager.py @@ -0,0 +1,168 @@ +from src.job import Job +from unittest import mock +from pathlib import Path +import os +import pytest +from src.linkedIn_job_manager import LinkedInJobManager +from selenium.common.exceptions import NoSuchElementException + + +@pytest.fixture +def job_manager(mocker): + """Fixture to create a LinkedInJobManager instance with mocked driver.""" + mock_driver = mocker.Mock() + return LinkedInJobManager(mock_driver) + + +def test_initialization(job_manager): + """Test LinkedInJobManager initialization.""" + assert job_manager.driver is not None + assert job_manager.set_old_answers == set() + assert job_manager.easy_applier_component is None + + +def test_set_parameters(mocker, job_manager): + """Test setting parameters for the LinkedInJobManager.""" + # Mocking os.path.exists to return True for the resume path + mocker.patch('pathlib.Path.exists', return_value=True) + + params = { + 'company_blacklist': ['Company A', 'Company B'], + 'title_blacklist': ['Intern', 'Junior'], + 'positions': ['Software Engineer', 'Data Scientist'], + 'locations': ['New York', 'San Francisco'], + 'apply_once_at_company': True, + 'uploads': {'resume': '/path/to/resume'}, # Resume path provided here + 'outputFileDirectory': '/path/to/output', + 'job_applicants_threshold': { + 'min_applicants': 5, + 'max_applicants': 50 + }, + 'remote': False, + 'distance': 50, + 'date': {'all time': True} + } + + job_manager.set_parameters(params) + + # Normalize paths to handle platform differences (e.g., Windows vs Unix-like systems) + assert str(job_manager.resume_path) == os.path.normpath('/path/to/resume') + assert str(job_manager.output_file_directory) == os.path.normpath( + '/path/to/output') + + +def next_job_page(self, position, location, job_page): + logger.debug("Navigating to next job page: %s in %s, page %d", + position, location, job_page) + self.driver.get( + f"https://www.linkedin.com/jobs/search/{self.base_search_url}&keywords={position}&location={location}&start={job_page * 25}") + + +def test_get_jobs_from_page_no_jobs(mocker, job_manager): + """Test get_jobs_from_page when no jobs are found.""" + mocker.patch.object(job_manager.driver, 'find_element', + side_effect=NoSuchElementException) + + jobs = job_manager.get_jobs_from_page() + assert jobs == [] + + +def test_get_jobs_from_page_with_jobs(mocker, job_manager): + """Test get_jobs_from_page when job elements are found.""" + # Mock the no_jobs_element to behave correctly + mock_no_jobs_element = mocker.Mock() + mock_no_jobs_element.text = "No matching jobs found" + + # Mocking the find_element to return the mock no_jobs_element + mocker.patch.object(job_manager.driver, 'find_element', + return_value=mock_no_jobs_element) + + # Mock the page_source + mocker.patch.object(job_manager.driver, 'page_source', + return_value="some page content") + + # Ensure jobs are returned as empty list due to "No matching jobs found" + jobs = job_manager.get_jobs_from_page() + assert jobs == [] # No jobs expected due to "No matching jobs found" + + +def test_apply_jobs_with_no_jobs(mocker, job_manager): + """Test apply_jobs when no jobs are found.""" + # Mocking find_element to return a mock element that simulates no jobs + mock_element = mocker.Mock() + mock_element.text = "No matching jobs found" + + # Mock the driver to simulate the page source + mocker.patch.object(job_manager.driver, 'page_source', return_value="") + + # Mock the driver to return the mock element when find_element is called + mocker.patch.object(job_manager.driver, 'find_element', + return_value=mock_element) + + # Call apply_jobs and ensure no exceptions are raised + job_manager.apply_jobs() + + # Ensure it attempted to find the job results list + assert job_manager.driver.find_element.call_count == 1 + + +def test_apply_jobs_with_jobs(mocker, job_manager): + """Test apply_jobs when jobs are present.""" + + # Mock no_jobs_element to simulate the absence of "No matching jobs found" banner + no_jobs_element = mocker.Mock() + no_jobs_element.text = "" # Empty text means "No matching jobs found" is not present + mocker.patch.object(job_manager.driver, 'find_element', + return_value=no_jobs_element) + + # Mock the page_source to simulate what the page looks like when jobs are present + mocker.patch.object(job_manager.driver, 'page_source', + return_value="some job content") + + # Mock the outer find_elements (scaffold-layout__list-container) + container_mock = mocker.Mock() + + # Mock the inner find_elements to return job list items + job_element_mock = mocker.Mock() + # Simulating two job items + job_elements_list = [job_element_mock, job_element_mock] + + # Return the container mock, which itself returns the job elements list + container_mock.find_elements.return_value = job_elements_list + mocker.patch.object(job_manager.driver, 'find_elements', + return_value=[container_mock]) + + # Mock the extract_job_information_from_tile method to return sample job info + mocker.patch.object(job_manager, 'extract_job_information_from_tile', return_value=( + "Title", "Company", "Location", "Apply", "Link")) + + # Mock other methods like is_blacklisted, is_already_applied_to_job, and is_already_applied_to_company + mocker.patch.object(job_manager, 'is_blacklisted', return_value=False) + mocker.patch.object( + job_manager, 'is_already_applied_to_job', return_value=False) + mocker.patch.object( + job_manager, 'is_already_applied_to_company', return_value=False) + + # Mock the LinkedInEasyApplier component + job_manager.easy_applier_component = mocker.Mock() + + # Mock the output_file_directory as a valid Path object + job_manager.output_file_directory = Path("/mocked/path/to/output") + + # Mock Path.exists() to always return True (so no actual file system interaction is needed) + mocker.patch.object(Path, 'exists', return_value=True) + + # Mock the open function to prevent actual file writing + mock_open = mocker.mock_open() + mocker.patch('builtins.open', mock_open) + + # Run the apply_jobs method + job_manager.apply_jobs() + + # Assertions + assert job_manager.driver.find_elements.call_count == 1 + # Called for each job element + assert job_manager.extract_job_information_from_tile.call_count == 2 + # Called for each job element + assert job_manager.easy_applier_component.job_apply.call_count == 2 + mock_open.assert_called() # Ensure that the open function was called diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..efe3645 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,96 @@ +# tests/test_utils.py +import pytest +import os +import time +from unittest import mock +from selenium.webdriver.remote.webelement import WebElement +from src.utils import ensure_chrome_profile, is_scrollable, scroll_slow, chrome_browser_options, printred, printyellow + +# Mocking logging to avoid actual file writing +@pytest.fixture(autouse=True) +def mock_logger(mocker): + mocker.patch("src.utils.logger") + +# Test ensure_chrome_profile function +def test_ensure_chrome_profile(mocker): + mocker.patch("os.path.exists", return_value=False) # Pretend directory doesn't exist + mocker.patch("os.makedirs") # Mock making directories + + # Call the function + profile_path = ensure_chrome_profile() + + # Verify that os.makedirs was called twice to create the directory + assert profile_path.endswith("linkedin_profile") + assert os.path.exists.called + assert os.makedirs.called + +# Test is_scrollable function +def test_is_scrollable(mocker): + mock_element = mocker.Mock(spec=WebElement) + mock_element.get_attribute.side_effect = lambda attr: "1000" if attr == "scrollHeight" else "500" + + # Call the function + scrollable = is_scrollable(mock_element) + + # Check the expected outcome + assert scrollable is True + mock_element.get_attribute.assert_any_call("scrollHeight") + mock_element.get_attribute.assert_any_call("clientHeight") + +# Test scroll_slow function +def test_scroll_slow(mocker): + mock_driver = mocker.Mock() + mock_element = mocker.Mock(spec=WebElement) + + # Mock element's attributes for scrolling + mock_element.get_attribute.side_effect = lambda attr: "2000" if attr == "scrollHeight" else "0" + mock_element.is_displayed.return_value = True + mocker.patch("time.sleep") # Mock time.sleep to avoid waiting + + # Call the function + scroll_slow(mock_driver, mock_element, start=0, end=1000, step=100, reverse=False) + + # Ensure that scrolling happened multiple times + assert mock_driver.execute_script.called + mock_element.is_displayed.assert_called_once() + +def test_scroll_slow_element_not_scrollable(mocker): + mock_driver = mocker.Mock() + mock_element = mocker.Mock(spec=WebElement) + + # Mock the attributes so the element is not scrollable + mock_element.get_attribute.side_effect = lambda attr: "1000" if attr == "scrollHeight" else "1000" + mock_element.is_displayed.return_value = True + + scroll_slow(mock_driver, mock_element, start=0, end=1000, step=100) + + # Ensure it detected non-scrollable element + mock_driver.execute_script.assert_not_called() + +# Test chrome_browser_options function +def test_chrome_browser_options(mocker): + mocker.patch("src.utils.ensure_chrome_profile") + mocker.patch("os.path.dirname", return_value="/mocked/path") + mocker.patch("os.path.basename", return_value="profile_directory") + + mock_options = mocker.Mock() + + mocker.patch("selenium.webdriver.ChromeOptions", return_value=mock_options) + + # Call the function + options = chrome_browser_options() + + # Ensure options were set + assert mock_options.add_argument.called + assert options == mock_options + +# Test printred and printyellow functions +def test_printred(mocker): + mocker.patch("builtins.print") + printred("Test") + print.assert_called_once_with("\033[91mTest\033[0m") + +def test_printyellow(mocker): + mocker.patch("builtins.print") + printyellow("Test") + print.assert_called_once_with("\033[93mTest\033[0m") From 2c1322b27087762cedff1b61e6bad2816ee249cb Mon Sep 17 00:00:00 2001 From: blackms Date: Wed, 11 Sep 2024 19:01:29 +0200 Subject: [PATCH 74/97] pytest config missing --- pytest.ini | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 pytest.ini diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..b58955c --- /dev/null +++ b/pytest.ini @@ -0,0 +1,5 @@ +[pytest] +minversion = 6.0 +addopts = --strict-markers --tb=short --cov=src --cov-report=term-missing +testpaths = + tests \ No newline at end of file From f62ee694c43e64af8fdc6344a3363d1016fce908 Mon Sep 17 00:00:00 2001 From: azjz92 <57454209+azjz92@users.noreply.github.com> Date: Thu, 12 Sep 2024 18:59:18 -0400 Subject: [PATCH 75/97] updated resume in data folder example to actually work, was out of date --- data_folder_example/config.yaml | 17 +-- data_folder_example/plain_text_resume.yaml | 140 ++++++++++----------- 2 files changed, 74 insertions(+), 83 deletions(-) diff --git a/data_folder_example/config.yaml b/data_folder_example/config.yaml index 6b2da50..e3f898f 100644 --- a/data_folder_example/config.yaml +++ b/data_folder_example/config.yaml @@ -1,7 +1,7 @@ remote: true -experience_level: - internship: true +experienceLevel: + internship: false entry: true associate: true mid-senior level: true @@ -19,22 +19,23 @@ jobTypes: date: all time: false - month: true + month: false week: false 24 hours: true positions: - - Software Tester + - Software engineer or "python" locations: - - USA + - london + - copenhagen -apply_once_at_company: [true/false] +apply_once_at_company: true distance: 100 company_blacklist: - - Noir + - wayfair - Crossover title_blacklist: @@ -43,7 +44,7 @@ title_blacklist: job_applicants_threshold: min_applicants: 0 - max_applicants: 100 + max_applicants: 30 llm_model_type: openai llm_model: 'gpt-4o' diff --git a/data_folder_example/plain_text_resume.yaml b/data_folder_example/plain_text_resume.yaml index 012a5b8..9a4199f 100644 --- a/data_folder_example/plain_text_resume.yaml +++ b/data_folder_example/plain_text_resume.yaml @@ -1,133 +1,123 @@ personal_information: - 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/" + name: "solid" + surname: "snake" + date_of_birth: "12/01/1861" + country: "Ireland" + city: "Dublin" + address: "12 Fox road" + phone_prefix: "+1" + phone: "7819117091" + email: "hi@gmail.com" + github: "https://github.com/lol" + linkedin: "https://www.linkedin.com/in/thezucc/" + education_details: - 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: - Computer Networks: "30/30" - Advanced Algorithms: "30/30" - Database Systems: "30/30" - Embedded Systems: "30/30" - Artificial Intelligence: "30/30" + institution: "Bob academy" + field_of_study: "Bobs Engineering" + final_evaluation_grade: "4.0" + year_of_completion: "2023" + start_date: "2022" + additional_info: + exam: + Algorithms: "A" + Linear Algebra: "A" + Database Systems: "A" + Operating Systems: "A-" + Web Development: "A" experience_details: - - position: "Senior Software Engineer" - company: "TechSolutions" - employment_period: "01/2018 - Present" - location: "Rome, Italy" - industry: "Software Development" + - position: "X" + company: "Y." + employment_period: "06/2019 - Present" + location: "San Francisco, CA" + industry: "Technology" key_responsibilities: - - 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%" + - responsibility: "Developed web applications using React and Node.js" + - responsibility: "Collaborated with cross-functional teams to design and implement new features" + - responsibility: "Troubleshot and resolved complex software issues" skills_acquired: - - "Software architecture" - - "Team leadership" - - "Performance optimization" - + - "React" + - "Node.js" + - "Software Troubleshooting" - position: "Software Developer" company: "Innovatech" employment_period: "06/2015 - 12/2017" location: "Milan, Italy" industry: "Technology" key_responsibilities: - - 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" + - responsibility: "Developed and maintained web applications using modern technologies" + - responsibility: "Collaborated with UX/UI designers to enhance user experience" + - responsibility: "Implemented automated testing procedures to ensure code quality" skills_acquired: - "Web development" - "User experience design" - "Automated testing" - - position: "Junior Developer" company: "StartUp Hub" employment_period: "01/2014 - 05/2015" location: "Florence, Italy" industry: "Startups" key_responsibilities: - - 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" + - responsibility: "Assisted in the development of mobile applications and web platforms" + - responsibility: "Participated in code reviews and contributed to software design discussions" + - responsibility: "Resolved bugs and implemented feature enhancements" skills_acquired: - "Mobile app development" - "Code reviews" - "Bug fixing" - projects: - - 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" + - name: "X" + description: "Y blah blah blah " + link: "https://github.com/haveagoodday" + + achievements: - - 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" + - name: "Employee of the Month" + description: "Recognized for exceptional performance and contributions to the team." + - name: "Hackathon Winner" + description: "Won first place in a national hackathon competition." certifications: - - 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" + #- "Certified Scrum Master" + #- "AWS Certified Solutions Architect" languages: - - language: "Italian" - proficiency: "Native" - language: "English" proficiency: "Fluent" + - language: "Spanish" + proficiency: "Intermediate" interests: - - "Cloud Computing" + - "Machine Learning" - "Cybersecurity" - - "IoT Development" - - "Artificial Intelligence" - - "Data Privacy" + - "Open Source Projects" + - "Digital Marketing" + - "Entrepreneurship" availability: - notice_period: "2 months" + notice_period: "2 weeks" salary_expectations: salary_range_usd: "90000 - 110000" self_identification: - gender: "Male" - pronouns: "He/Him" + gender: "Female" + pronouns: "She/Her" veteran: "No" disability: "No" - ethnicity: "White" + ethnicity: "Asian" legal_authorization: eu_work_authorization: "Yes" - us_work_authorization: "No" - requires_us_visa: "Yes" + us_work_authorization: "Yes" + requires_us_visa: "No" requires_us_sponsorship: "Yes" requires_eu_visa: "No" legally_allowed_to_work_in_eu: "Yes" - legally_allowed_to_work_in_us: "No" + legally_allowed_to_work_in_us: "Yes" requires_eu_sponsorship: "No" work_preferences: From 82fc6beddecf41895b745d56585a8c03b0d5d53d Mon Sep 17 00:00:00 2001 From: queukat Date: Fri, 13 Sep 2024 02:32:23 +0300 Subject: [PATCH 76/97] fixed easy apply button, make some refactoring and fixed saiving cover letters --- src/linkedIn_easy_applier.py | 161 ++++++++++++++++++++++++++--------- src/linkedIn_job_manager.py | 127 ++++++++++++--------------- src/utils.py | 5 ++ 3 files changed, 181 insertions(+), 112 deletions(-) diff --git a/src/linkedIn_easy_applier.py b/src/linkedIn_easy_applier.py index 1d734e8..dd33beb 100644 --- a/src/linkedIn_easy_applier.py +++ b/src/linkedIn_easy_applier.py @@ -24,7 +24,7 @@ from src.utils import logger class LinkedInEasyApplier: def __init__(self, driver: Any, resume_dir: Optional[str], set_old_answers: List[Tuple[str, str, str]], - gpt_answerer: Any, resume_generator_manager): + gpt_answerer: Any, resume_generator_manager, parameters: dict): logger.debug("Initializing LinkedInEasyApplier") if resume_dir is None or not os.path.exists(resume_dir): resume_dir = None @@ -125,10 +125,20 @@ class LinkedInEasyApplier: job.set_recruiter_link(recruiter_link) logger.debug("Recruiter link set: %s", recruiter_link) - logger.debug("Attempting to click 'Easy Apply' button") - actions = ActionChains(self.driver) - actions.move_to_element(easy_apply_button).click().perform() - logger.debug("'Easy Apply' button clicked successfully") + # Try clicking the "Easy Apply" button + try: + logger.debug("Attempting to click 'Easy Apply' button using ActionChains") + actions = ActionChains(self.driver) + actions.move_to_element(easy_apply_button).click().perform() + logger.debug("'Easy Apply' button clicked successfully") + except Exception as e: + logger.warning(f"Failed to click 'Easy Apply' button using ActionChains: {e}, trying JavaScript click") + try: + self.driver.execute_script("arguments[0].click();", easy_apply_button) + logger.debug("'Easy Apply' button clicked successfully via JavaScript") + except Exception as js_error: + logger.error(f"Failed to click 'Easy Apply' button via JavaScript: {js_error}") + raise logger.debug("Passing job information to GPT Answerer") self.gpt_answerer.set_job(job) @@ -150,73 +160,112 @@ class LinkedInEasyApplier: def _find_easy_apply_button(self, job: Any) -> WebElement: logger.debug("Searching for 'Easy Apply' button") attempt = 0 + timeout = 8 search_methods = [ + { + 'description': "'aria-label' containing 'Easy Apply to' and with data-job-id attribute", + 'xpath': '//button[contains(@aria-label, "Easy Apply to") and contains(@data-job-id, "")]' + }, { 'description': "find all 'Easy Apply' buttons using find_elements", 'find_elements': True, - 'xpath': '//button[contains(@class, "jobs-apply-button") and contains(., "Easy Apply")]' + 'xpath': '//button[contains(@class, "jobs-apply-button") and contains(., "Easy Apply") and contains(@data-job-id, "")]' }, { - 'description': "'aria-label' containing 'Easy Apply to'", - 'xpath': '//button[contains(@aria-label, "Easy Apply to")]' - }, - { - 'description': "button text search", - 'xpath': '//button[contains(text(), "Easy Apply") or contains(text(), "Apply now")]' + 'description': "button text search with data-job-id attribute", + 'xpath': '//button[contains(text(), "Easy Apply") or contains(text(), "Apply now") and contains(@data-job-id, "")]' } ] - while attempt < 2: + while attempt < 3: self.check_for_premium_redirect(job) self._scroll_page() + try: + logger.info("Removing focus from the active element") + self.driver.execute_script("document.activeElement.blur();") + time.sleep(1) + + logger.info("Clicking on body to reset focus via JavaScript") + try: + self.driver.execute_script("document.querySelector('body').focus();") + except Exception as e: + logger.warning(f"Failed to reset focus via body: {e}") + + time.sleep(1) + + logger.info("Clicking on html to reset focus via JavaScript") + try: + self.driver.execute_script("document.querySelector('html').focus();") + except Exception as e: + logger.warning(f"Failed to reset focus via html: {e}") + + except Exception as e: + logger.warning(f"Failed to remove focus from the active element: {e}") + for method in search_methods: try: - logger.debug(f"Attempting search using {method['description']}") + logger.info(f"Attempt {attempt + 1}: Searching for 'Easy Apply' button using {method['description']}") if method.get('find_elements'): - buttons = self.driver.find_elements(By.XPATH, method['xpath']) if buttons: for index, button in enumerate(buttons): try: + WebDriverWait(self.driver, timeout).until(EC.visibility_of(button)) + WebDriverWait(self.driver, timeout).until(EC.element_to_be_clickable(button)) + logger.info(f"Found 'Easy Apply' button {index + 1}, attempting to click") + + self.driver.execute_script("arguments[0].scrollIntoView(true);", button) + time.sleep(1) + if button.is_enabled() and button.is_displayed(): + return button + else: + raise Exception(f"Button {index + 1} is not enabled or not displayed") - WebDriverWait(self.driver, 10).until(EC.visibility_of(button)) - WebDriverWait(self.driver, 10).until(EC.element_to_be_clickable(button)) - logger.debug(f"Found 'Easy Apply' button {index + 1}, attempting to click") - return button except Exception as e: logger.warning(f"Button {index + 1} found but not clickable: {e}") else: raise TimeoutException("No 'Easy Apply' buttons found") else: - - button = WebDriverWait(self.driver, 10).until( + button = WebDriverWait(self.driver, timeout).until( EC.presence_of_element_located((By.XPATH, method['xpath'])) ) - WebDriverWait(self.driver, 10).until(EC.visibility_of(button)) - WebDriverWait(self.driver, 10).until(EC.element_to_be_clickable(button)) - logger.debug("Found 'Easy Apply' button, attempting to click") - return button + WebDriverWait(self.driver, timeout).until(EC.visibility_of(button)) + WebDriverWait(self.driver, timeout).until(EC.element_to_be_clickable(button)) + logger.info("Found 'Easy Apply' button, attempting to click") + + self.driver.execute_script("arguments[0].scrollIntoView(true);", button) + time.sleep(1) + if button.is_enabled() and button.is_displayed(): + return button + else: + raise Exception("Button is not enabled or not displayed") except TimeoutException: logger.warning(f"Timeout during search using {method['description']}") except Exception as e: - logger.warning( - f"Failed to click 'Easy Apply' button using {method['description']} on attempt {attempt + 1}: {e}") + logger.warning(f"Failed to click 'Easy Apply' button using {method['description']} on attempt {attempt + 1}: {e}") self.check_for_premium_redirect(job) if attempt == 0: - logger.debug("Refreshing page to retry finding 'Easy Apply' button") + logger.info("Refreshing page and clicking on body to retry finding 'Easy Apply' button") self.driver.refresh() time.sleep(random.randint(3, 5)) + + try: + body_element = self.driver.find_element(By.TAG_NAME, 'body') + body_element.click() + logger.info("Clicked on body element to reset the page state") + except Exception as e: + logger.warning(f"Failed to click on body element: {e}") + attempt += 1 - page_source = self.driver.page_source - logger.error("No clickable 'Easy Apply' button found after 2 attempts. Page source:\n%s", page_source) + logger.error("No clickable 'Easy Apply' button found after 2 attempts.") raise Exception("No clickable 'Easy Apply' button found") def _get_job_description(self) -> str: @@ -752,12 +801,24 @@ class LinkedInEasyApplier: if dropdowns: dropdown = dropdowns[0] select = Select(dropdown) - options = [option.text for option in select.options] + options = [option.text for option in select.options if option.text != "Select an option"] 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}") + try: + question_text = question.find_element(By.TAG_NAME, 'label').text.lower().strip() + except NoSuchElementException: + logger.warning("Label not found, trying to extract question text from or other elements") + + try: + question_text = question.find_element(By.CSS_SELECTOR, + 'span[aria-hidden="true"]').text.lower().strip() + except NoSuchElementException: + + question_text = section.get_attribute('data-test-text-entity-list-form-title') or "unknown question" + question_text = question_text.lower().strip() + + logger.debug(f"Processing dropdown question: {question_text}") current_selection = select.first_selected_option.text logger.debug(f"Current selection: {current_selection}") @@ -772,14 +833,14 @@ class LinkedInEasyApplier: logger.debug(f"Found existing answer for question '{question_text}': {existing_answer}") if current_selection != existing_answer: logger.debug(f"Updating selection to: {existing_answer}") - self._select_dropdown_option(dropdown, existing_answer) + self._select_dropdown_option(select, existing_answer) return True logger.debug(f"No existing answer found, querying model for: {question_text}") answer = self.gpt_answerer.answer_question_from_options(question_text, options) self._save_questions_to_json({'type': 'dropdown', 'question': question_text, 'answer': answer}) - self._select_dropdown_option(dropdown, answer) + self._select_dropdown_option(select, answer) logger.debug(f"Selected new dropdown answer: {answer}") return True @@ -794,6 +855,15 @@ class LinkedInEasyApplier: logger.warning(f"Failed to handle dropdown or combobox question: {e}", exc_info=True) return False + + def _select_dropdown_option(self, select: Select, text: str) -> None: + + try: + select.select_by_visible_text(text) + logger.debug(f"Selected option: {text}") + except Exception as e: + logger.error(f"Failed to select option '{text}': {e}") + def _is_numeric_field(self, field: WebElement) -> bool: field_type = field.get_attribute('type').lower() field_id = field.get_attribute("id").lower() @@ -814,15 +884,26 @@ class LinkedInEasyApplier: return radios[-1].find_element(By.TAG_NAME, 'label').click() - def _select_dropdown_option(self, element: WebElement, text: str) -> None: - logger.debug("Selecting dropdown option: %s", text) - select = Select(element) - select.select_by_visible_text(text) def _save_questions_to_json(self, question_data: dict) -> None: + """ + Save question data to a JSON file, with filtering to exclude company-specific or unsuitable questions. + + Args: + question_data (dict): The question and answer data to be saved. + """ output_file = 'answers.json' question_data['question'] = self._sanitize_text(question_data['question']) logger.debug("Saving question data to JSON: %s", question_data) + + # List of keywords to exclude certain questions from being saved + exclusion_keywords = ["why us", "summary"] + + # Check if the question contains any exclusion keywords + if any(keyword in question_data['question'].lower() for keyword in exclusion_keywords): + logger.info(f"Skipping saving question due to company-specific keywords: {question_data['question']}") + return # Skip saving this question if it's company-specific + try: try: with open(output_file, 'r') as f: @@ -836,7 +917,9 @@ class LinkedInEasyApplier: except FileNotFoundError: logger.warning("JSON file not found, creating new file") data = [] + data.append(question_data) + with open(output_file, 'w') as f: json.dump(data, f, indent=4) logger.debug("Question data saved successfully to JSON") diff --git a/src/linkedIn_job_manager.py b/src/linkedIn_job_manager.py index 1e1db0a..778be4f 100644 --- a/src/linkedIn_job_manager.py +++ b/src/linkedIn_job_manager.py @@ -72,10 +72,28 @@ class LinkedInJobManager: logger.debug("Setting resume generator manager") self.resume_generator_manager = resume_generator_manager + def wait_or_skip(self, time_left): + """Method for waiting or skipping the sleep time based on user input""" + if time_left > 0: + try: + user_input = inputimeout( + 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': + 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) + def start_applying(self): logger.debug("Starting job application process") self.easy_applier_component = LinkedInEasyApplier(self.driver, self.resume_path, self.set_old_answers, - self.gpt_answerer, self.resume_generator_manager) + self.gpt_answerer, self.resume_generator_manager, + self.parameters) searches = list(product(self.positions, self.locations)) random.shuffle(searches) page_sleep = 0 @@ -116,39 +134,15 @@ class LinkedInJobManager: time_left = minimum_page_time - time.time() - # Ask user if they want to skip waiting, with timeout - if time_left > 0: - try: - user_input = inputimeout( - 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': - 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) + # Use the wait_or_skip function for sleeping + self.wait_or_skip(time_left) minimum_page_time = time.time() + minimum_time if page_sleep % 5 == 0: sleep_time = random.randint(5, 34) - try: - user_input = inputimeout( - 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': - 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) + # Use the wait_or_skip function for extended sleep + self.wait_or_skip(sleep_time) page_sleep += 1 except Exception as e: logger.error("Unexpected error during job search: %s", e) @@ -157,38 +151,15 @@ class LinkedInJobManager: time_left = minimum_page_time - time.time() - if time_left > 0: - try: - user_input = inputimeout( - 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': - 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) + # Use the wait_or_skip function again before moving to the next search + self.wait_or_skip(time_left) minimum_page_time = time.time() + minimum_time if page_sleep % 5 == 0: sleep_time = random.randint(50, 90) - try: - user_input = inputimeout( - prompt=f"Sleeping for {sleep_time / 60} minutes. Press 'y' to skip waiting: ", - timeout=60).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) + # Use the wait_or_skip function for a longer sleep period + self.wait_or_skip(sleep_time) page_sleep += 1 def get_jobs_from_page(self): @@ -207,7 +178,7 @@ class LinkedInJobManager: try: 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, 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') @@ -265,23 +236,32 @@ class LinkedInJobManager: # 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}") + applicants_text = element.text.strip().lower() + logger.debug(f"Checking element text: {applicants_text}") - # Extract numeric digits from the text (e.g., "70 applicants" -> "70") + # Look for keywords indicating the presence of applicants count + if "applicant" in applicants_text: + logger.info(f"Applicants text found: {applicants_text}") + + # Try to find numeric value in the text, such as "27 applicants" or "over 100 applicants" 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 + applicants_count = int(applicants_count) # Convert the extracted number to an integer + logger.info(f"Extracted numeric applicants count: {applicants_count}") + + # Handle case with "over X applicants" + if "over" in applicants_text: + applicants_count += 1 + logger.info(f"Adjusted applicants count for 'over': {applicants_count}") + + logger.info(f"Final applicants count: {applicants_count}") + else: + logger.warning(f"Applicants count could not be extracted from text: {applicants_text}") + + break # Stop after finding the first valid applicants count element + else: + logger.info(f"Skipping element as it does not contain 'applicant': {applicants_text}") # Check if applicants_count is valid (not None) before performing comparisons if applicants_count is not None: @@ -291,13 +271,13 @@ class LinkedInJobManager: 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.") + f"Applicants count not found for {job.title} at {job.company}, but 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( @@ -370,7 +350,8 @@ class LinkedInJobManager: url_parts = [] if parameters['remote']: url_parts.append("f_CF=f_WRA") - experience_levels = [str(i + 1) for i, (level, v) in enumerate(parameters.get('experience_level', {}).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)}") diff --git a/src/utils.py b/src/utils.py index 0cd2c87..974787e 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 eae841136fccdbd4b96bb8a1c21e60f35f68e588 Mon Sep 17 00:00:00 2001 From: queukat Date: Fri, 13 Sep 2024 02:33:58 +0300 Subject: [PATCH 77/97] fixed easy apply button, make some refactoring and fixed saiving cover letters --- src/linkedIn_easy_applier.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/linkedIn_easy_applier.py b/src/linkedIn_easy_applier.py index dd33beb..cd5170e 100644 --- a/src/linkedIn_easy_applier.py +++ b/src/linkedIn_easy_applier.py @@ -24,7 +24,7 @@ from src.utils import logger class LinkedInEasyApplier: def __init__(self, driver: Any, resume_dir: Optional[str], set_old_answers: List[Tuple[str, str, str]], - gpt_answerer: Any, resume_generator_manager, parameters: dict): + 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 From f77706d5793495d0ae393fbff1e2d5ef336cf671 Mon Sep 17 00:00:00 2001 From: tapas-joshi Date: Thu, 12 Sep 2024 21:30:35 -0400 Subject: [PATCH 78/97] Loguru Integration: Better logs --- app_config.py | 1 + main.py | 25 +- requirements.txt | 1 + resume_yaml_generator.py | 15 +- src/job.py | 12 +- src/job_application_profile.py | 62 ++-- src/linkedIn_authenticator.py | 29 +- src/linkedIn_bot_facade.py | 12 +- src/linkedIn_easy_applier.py | 213 ++++------- src/linkedIn_job_manager.py | 183 +++++----- src/linkedin-api.py | 17 +- src/llm/llm_manager.py | 569 +++++++++++++++++++++++++++++ src/utils.py | 82 ++--- tests/test_linkedIn_job_manager.py | 4 +- 14 files changed, 851 insertions(+), 374 deletions(-) create mode 100644 app_config.py create mode 100644 src/llm/llm_manager.py diff --git a/app_config.py b/app_config.py new file mode 100644 index 0000000..75684d1 --- /dev/null +++ b/app_config.py @@ -0,0 +1 @@ +MINIMUM_LOG_LEVEL="DEBUG" \ No newline at end of file diff --git a/main.py b/main.py index 047724b..4457c60 100644 --- a/main.py +++ b/main.py @@ -7,14 +7,15 @@ 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 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.llm.llm_manager import GPTAnswerer from src.linkedIn_authenticator import LinkedInAuthenticator from src.linkedIn_bot_facade import LinkedInBotFacade from src.linkedIn_job_manager import LinkedInJobManager from src.job_application_profile import JobApplicationProfile +from loguru import logger # Suppress stderr sys.stderr = open(os.devnull, 'w') @@ -181,7 +182,7 @@ def create_and_run_bot(email, password, parameters, llm_api_key): bot.start_login() bot.start_apply() except WebDriverException as e: - print(f"WebDriver error occurred: {e}") + logger.error(f"WebDriver error occurred: {e}") except Exception as e: raise RuntimeError(f"Error running the bot: {str(e)}") @@ -201,20 +202,20 @@ def main(resume: Path = None): 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") + logger.error(f"Configuration error: {str(ce)}") + logger.error(f"Refer to the configuration guide for troubleshooting: https://github.com/feder-cr/LinkedIn_AIHawk_automatic_job_application/blob/main/readme.md#configuration {str(ce)}") 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") + logger.error(f"File not found: {str(fnf)}") + logger.error("Ensure all required files are present in the data folder.") + logger.error("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)}") + logger.error(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") + logger.error("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") + logger.error(f"An unexpected error occurred: {str(e)}") + logger.error("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 70cdb62..e037c54 100644 --- a/requirements.txt +++ b/requirements.txt @@ -25,3 +25,4 @@ httpx~=0.27.2 python-dotenv~=1.0.1 PyYAML~=6.0.2 pytest>=8.3.3 +loguru==0.7.2 \ No newline at end of file diff --git a/resume_yaml_generator.py b/resume_yaml_generator.py index 336a23d..fd38d56 100644 --- a/resume_yaml_generator.py +++ b/resume_yaml_generator.py @@ -6,6 +6,7 @@ from typing import Dict, Any import re from jsonschema import validate, ValidationError from pdfminer.high_level import extract_text +from loguru import logger def load_yaml(file_path: str) -> Dict[str, Any]: with open(file_path, 'r') as file: @@ -119,7 +120,7 @@ def generate_report(validation_result: Dict[str, Any], output_file: str): report += "YAML is not valid. Errors:\n" report += validation_result["errors"] + "\n" - print(report) + logger.debug(report) def pdf_to_text(pdf_path: str) -> str: return extract_text(pdf_path) @@ -137,24 +138,24 @@ def main(): # Check if input is PDF or TXT if args.input.lower().endswith('.pdf'): resume_text = pdf_to_text(args.input) - print(f"PDF resume converted to text successfully.") + logger.debug(f"PDF resume converted to text successfully.") else: resume_text = load_resume_text(args.input) generated_yaml = generate_yaml_from_resume(resume_text, schema, api_key) save_yaml(generated_yaml, args.output) - print(f"Resume YAML generated and saved to {args.output}") + logger.debug(f"Resume YAML generated and saved to {args.output}") validation_result = validate_yaml(generated_yaml, schema) if validation_result["valid"]: - print("YAML is valid and conforms to the schema.") + logger.debug("YAML is valid and conforms to the schema.") else: - print("YAML is not valid. Errors:") - print(validation_result["errors"]) + logger.error("YAML is not valid. Errors:") + logger.error(validation_result["errors"]) except Exception as e: - print(f"An error occurred: {e}") + logger.error(f"An error occurred: {e}") if __name__ == "__main__": main() diff --git a/src/job.py b/src/job.py index 39b2371..ff72d47 100644 --- a/src/job.py +++ b/src/job.py @@ -1,6 +1,6 @@ from dataclasses import dataclass -from src.utils import logger +from loguru import logger @dataclass @@ -16,22 +16,22 @@ class Job: recruiter_link: str = "" def set_summarize_job_description(self, summarize_job_description): - logger.debug("Setting summarized job description: %s", summarize_job_description) + logger.debug(f"Setting summarized job description: {summarize_job_description}") self.summarize_job_description = summarize_job_description def set_job_description(self, description): - logger.debug("Setting job description: %s", description) + logger.debug(f"Setting job description: {description}") self.description = description def set_recruiter_link(self, recruiter_link): - logger.debug("Setting recruiter link: %s", recruiter_link) + logger.debug(f"Setting recruiter link: {recruiter_link}") self.recruiter_link = recruiter_link def formatted_job_information(self): """ Formats the job information as a markdown string. """ - logger.debug("Formatting job information for job: %s at %s", self.title, self.company) + logger.debug(f"Formatting job information for job: {self.title} at {self.company}") job_information = f""" # Job Description ## Job Information @@ -44,5 +44,5 @@ class Job: {self.description or 'No description provided.'} """ formatted_information = job_information.strip() - logger.debug("Formatted job information: %s", formatted_information) + logger.debug(f"Formatted job information: {formatted_information}") return formatted_information diff --git a/src/job_application_profile.py b/src/job_application_profile.py index 5330c2b..62385db 100644 --- a/src/job_application_profile.py +++ b/src/job_application_profile.py @@ -2,7 +2,7 @@ from dataclasses import dataclass import yaml -from src.utils import logger +from loguru import logger @dataclass @@ -58,106 +58,106 @@ class JobApplicationProfile: logger.debug("Initializing JobApplicationProfile with provided YAML string") try: data = yaml.safe_load(yaml_str) - logger.debug("YAML data successfully parsed: %s", data) + logger.debug(f"YAML data successfully parsed: {data}") except yaml.YAMLError as e: - logger.error("Error parsing YAML file: %s", e) + logger.error(f"Error parsing YAML file: {e}") raise ValueError("Error parsing YAML file.") from e except Exception as e: - logger.error("Unexpected error occurred while parsing the YAML file: %s", e) + logger.error(f"Unexpected error occurred while parsing the YAML file: {e}") raise RuntimeError("An unexpected error occurred while parsing the YAML file.") from e if not isinstance(data, dict): - logger.error("YAML data must be a dictionary, received: %s", type(data)) + logger.error(f"YAML data must be a dictionary, received: {type(data)}") raise TypeError("YAML data must be a dictionary.") # Process self_identification try: logger.debug("Processing self_identification") self.self_identification = SelfIdentification(**data['self_identification']) - logger.debug("self_identification processed: %s", self.self_identification) + logger.debug(f"self_identification processed: {self.self_identification}") except KeyError as e: - logger.error("Required field %s is missing in self_identification data.", e) + logger.error(f"Required field {e} is missing in self_identification data.") raise KeyError(f"Required field {e} is missing in self_identification data.") from e except TypeError as e: - logger.error("Error in self_identification data: %s", e) + logger.error(f"Error in self_identification data: {e}") raise TypeError(f"Error in self_identification data: {e}") from e except AttributeError as e: - logger.error("Attribute error in self_identification processing: %s", e) + logger.error(f"Attribute error in self_identification processing: {e}") raise AttributeError("Attribute error in self_identification processing.") from e except Exception as e: - logger.error("An unexpected error occurred while processing self_identification: %s", e) + logger.error(f"An unexpected error occurred while processing self_identification: {e}") raise RuntimeError("An unexpected error occurred while processing self_identification.") from e # Process legal_authorization try: logger.debug("Processing legal_authorization") self.legal_authorization = LegalAuthorization(**data['legal_authorization']) - logger.debug("legal_authorization processed: %s", self.legal_authorization) + logger.debug(f"legal_authorization processed: {self.legal_authorization}") except KeyError as e: - logger.error("Required field %s is missing in legal_authorization data.", e) + logger.error(f"Required field {e} is missing in legal_authorization data.") raise KeyError(f"Required field {e} is missing in legal_authorization data.") from e except TypeError as e: - logger.error("Error in legal_authorization data: %s", e) + logger.error(f"Error in legal_authorization data: {e}") raise TypeError(f"Error in legal_authorization data: {e}") from e except AttributeError as e: - logger.error("Attribute error in legal_authorization processing: %s", e) + logger.error(f"Attribute error in legal_authorization processing: {e}") raise AttributeError("Attribute error in legal_authorization processing.") from e except Exception as e: - logger.error("An unexpected error occurred while processing legal_authorization: %s", e) + logger.error(f"An unexpected error occurred while processing legal_authorization: {e}") raise RuntimeError("An unexpected error occurred while processing legal_authorization.") from e # Process work_preferences try: logger.debug("Processing work_preferences") self.work_preferences = WorkPreferences(**data['work_preferences']) - logger.debug("work_preferences processed: %s", self.work_preferences) + logger.debug(f"Work_preferences processed: {self.work_preferences}") except KeyError as e: - logger.error("Required field %s is missing in work_preferences data.", e) + logger.error(f"Required field {e} is missing in work_preferences data.") raise KeyError(f"Required field {e} is missing in work_preferences data.") from e except TypeError as e: - logger.error("Error in work_preferences data: %s", e) + logger.error(f"Error in work_preferences data: {e}") raise TypeError(f"Error in work_preferences data: {e}") from e except AttributeError as e: - logger.error("Attribute error in work_preferences processing: %s", e) + logger.error(f"Attribute error in work_preferences processing: {e}") raise AttributeError("Attribute error in work_preferences processing.") from e except Exception as e: - logger.error("An unexpected error occurred while processing work_preferences: %s", e) + logger.error(f"An unexpected error occurred while processing work_preferences: {e}") raise RuntimeError("An unexpected error occurred while processing work_preferences.") from e # Process availability try: logger.debug("Processing availability") self.availability = Availability(**data['availability']) - logger.debug("availability processed: %s", self.availability) + logger.debug(f"Availability processed: {self.availability}") except KeyError as e: - logger.error("Required field %s is missing in availability data.", e) + logger.error(f"Required field {e} is missing in availability data.") raise KeyError(f"Required field {e} is missing in availability data.") from e except TypeError as e: - logger.error("Error in availability data: %s", e) + logger.error(f"Error in availability data: {e}") raise TypeError(f"Error in availability data: {e}") from e except AttributeError as e: - logger.error("Attribute error in availability processing: %s", e) + logger.error(f"Attribute error in availability processing: {e}") raise AttributeError("Attribute error in availability processing.") from e except Exception as e: - logger.error("An unexpected error occurred while processing availability: %s", e) + logger.error(f"An unexpected error occurred while processing availability: {e}") raise RuntimeError("An unexpected error occurred while processing availability.") from e # Process salary_expectations try: logger.debug("Processing salary_expectations") self.salary_expectations = SalaryExpectations(**data['salary_expectations']) - logger.debug("salary_expectations processed: %s", self.salary_expectations) + logger.debug(f"salary_expectations processed: {self.salary_expectations}") except KeyError as e: - logger.error("Required field %s is missing in salary_expectations data.", e) + logger.error(f"Required field {e} is missing in salary_expectations data.") raise KeyError(f"Required field {e} is missing in salary_expectations data.") from e except TypeError as e: - logger.error("Error in salary_expectations data: %s", e) + logger.error(f"Error in salary_expectations data: {e}") raise TypeError(f"Error in salary_expectations data: {e}") from e except AttributeError as e: - logger.error("Attribute error in salary_expectations processing: %s", e) + logger.error(f"Attribute error in salary_expectations processing: {e}") raise AttributeError("Attribute error in salary_expectations processing.") from e except Exception as e: - logger.error("An unexpected error occurred while processing salary_expectations: %s", e) + logger.error(f"An unexpected error occurred while processing salary_expectations: {e}") raise RuntimeError("An unexpected error occurred while processing salary_expectations.") from e logger.debug("JobApplicationProfile initialization completed successfully.") @@ -173,5 +173,5 @@ class JobApplicationProfile: f"Work Preferences:\n{format_dataclass(self.work_preferences)}\n\n" f"Availability: {self.availability.notice_period}\n\n" f"Salary Expectations: {self.salary_expectations.salary_range_usd}\n\n") - logger.debug("String representation generated: %s", formatted_str) + logger.debug(f"String representation generated: {formatted_str}") return formatted_str diff --git a/src/linkedIn_authenticator.py b/src/linkedIn_authenticator.py index 6c49dfc..9030314 100644 --- a/src/linkedIn_authenticator.py +++ b/src/linkedIn_authenticator.py @@ -6,7 +6,7 @@ from selenium.webdriver.common.by import By from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.support.ui import WebDriverWait -from src.utils import logger +from loguru import logger class LinkedInAuthenticator: @@ -15,12 +15,12 @@ class LinkedInAuthenticator: self.driver = driver self.email = "" self.password = "" - logger.debug("LinkedInAuthenticator initialized with driver: %s", driver) + logger.debug(f"LinkedInAuthenticator initialized with driver: {driver}") def set_secrets(self, email, password): self.email = email self.password = password - logger.debug("Secrets set with email: %s", email) + logger.debug(f"Secrets set with email: {email}") def start(self): logger.info("Starting Chrome browser to log in to LinkedIn.") @@ -40,13 +40,13 @@ class LinkedInAuthenticator: logger.info("Navigating to the LinkedIn login page...") self.driver.get("https://www.linkedin.com/login") if 'feed' in self.driver.current_url: - print("User is already logged in.") + logger.debug("User is already logged in.") return try: self.enter_credentials() self.submit_login_form() except NoSuchElementException as e: - logger.error("Could not log in to LinkedIn. Element not found: %s", e) + logger.error(f"Could not log in to LinkedIn. Element not found: {e}") time.sleep(random.uniform(3, 5)) self.handle_security_check() @@ -57,13 +57,13 @@ class LinkedInAuthenticator: EC.presence_of_element_located((By.ID, "username")) ) email_field.send_keys(self.email) - logger.debug("Email entered: %s", self.email) + logger.debug(f"Email entered: {self.email}") password_field = self.driver.find_element(By.ID, "password") password_field.send_keys(self.password) logger.debug("Password entered.") except TimeoutException: logger.error("Login form not found. Aborting login.") - print("Login form not found. Aborting login.") + logger.error("Login form not found. Aborting login.") def submit_login_form(self): try: @@ -73,7 +73,6 @@ class LinkedInAuthenticator: logger.debug("Login form submitted.") except NoSuchElementException: logger.error("Login button not found. Please verify the page structure.") - print("Login button not found. Please verify the page structure.") def handle_security_check(self): try: @@ -82,22 +81,19 @@ class LinkedInAuthenticator: EC.url_contains('https://www.linkedin.com/checkpoint/challengesV2/') ) logger.warning("Security checkpoint detected. Please complete the challenge.") - print("Security checkpoint detected. Please complete the challenge.") WebDriverWait(self.driver, 300).until( EC.url_contains('https://www.linkedin.com/feed/') ) logger.info("Security check completed") - print("Security check completed") except TimeoutException: - logger.error("Security check not completed within the timeout.") - print("Security check not completed. Please try again later.") + logger.error("Security check not completed. Please try again later.") def is_logged_in(self): # target_url = 'https://www.linkedin.com/feed' # # # Navigate to the target URL if not already there # if self.driver.current_url != target_url: - # logger.debug("Navigating to target URL: %s", target_url) + # logger.debug(f"Navigating to target URL: {target_url}") # self.driver.get(target_url) try: @@ -109,10 +105,10 @@ class LinkedInAuthenticator: # Check for the presence of the "Start a post" button buttons = self.driver.find_elements(By.CLASS_NAME, 'share-box-feed-entry__trigger') - logger.debug("Found %d 'Start a post' buttons", len(buttons)) + logger.debug(f"Found {len(buttons)} 'Start a post' buttons") for i, button in enumerate(buttons): - logger.debug("Button %d text: %s", i + 1, button.text.strip()) + logger.debug(f"Button {i + 1} text: {button.text.strip()}") if any(button.text.strip().lower() == 'start a post' for button in buttons): logger.info("Found 'Start a post' button indicating user is logged in.") @@ -132,11 +128,10 @@ class LinkedInAuthenticator: def wait_for_page_load(self, timeout=10): try: - logger.debug("Waiting for page to load with timeout: %s seconds", timeout) + logger.debug(f"Waiting for page to load with timeout: {timeout} seconds") WebDriverWait(self.driver, timeout).until( lambda d: d.execute_script('return document.readyState') == 'complete' ) logger.debug("Page load completed.") except TimeoutException: logger.error("Page load timed out.") - print("Page load timed out.") diff --git a/src/linkedIn_bot_facade.py b/src/linkedIn_bot_facade.py index 2f1732c..b910ec4 100644 --- a/src/linkedIn_bot_facade.py +++ b/src/linkedIn_bot_facade.py @@ -1,4 +1,4 @@ -from src.utils import logger +from loguru import logger class LinkedInBotState: @@ -16,10 +16,10 @@ class LinkedInBotState: self.logged_in = False def validate_state(self, required_keys): - logger.debug("Validating LinkedInBotState with required keys: %s", required_keys) + logger.debug(f"Validating LinkedInBotState with required keys: {required_keys}") for key in required_keys: if not getattr(self, key): - logger.error("State validation failed: %s is not set", key) + logger.error(f"State validation failed: {key} is not set") raise ValueError(f"{key.replace('_', ' ').capitalize()} must be set before proceeding.") logger.debug("State validation passed") @@ -87,11 +87,11 @@ class LinkedInBotFacade: logger.debug("Apply process started successfully") def _validate_non_empty(self, value, name): - logger.debug("Validating that %s is not empty", name) + logger.debug(f"Validating that {name} is not empty") if not value: - logger.error("Validation failed: %s is empty", name) + logger.error(f"Validation failed: {name} is empty") raise ValueError(f"{name} cannot be empty.") - logger.debug("Validation passed for %s", name) + logger.debug(f"Validation passed for {name}") def _ensure_job_profile_and_resume_set(self): logger.debug("Ensuring job profile and resume are set") diff --git a/src/linkedIn_easy_applier.py b/src/linkedIn_easy_applier.py index cd5170e..82c60fc 100644 --- a/src/linkedIn_easy_applier.py +++ b/src/linkedIn_easy_applier.py @@ -19,7 +19,7 @@ from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.support.ui import Select, WebDriverWait import src.utils as utils -from src.utils import logger +from loguru import logger class LinkedInEasyApplier: @@ -39,7 +39,7 @@ class LinkedInEasyApplier: def _load_questions_from_json(self) -> List[dict]: output_file = 'answers.json' - logger.debug("Loading questions from JSON file: %s", output_file) + logger.debug(f"Loading questions from JSON file: {output_file}") try: with open(output_file, 'r') as f: try: @@ -56,7 +56,7 @@ class LinkedInEasyApplier: return [] except Exception: tb_str = traceback.format_exc() - logger.error("Error loading questions data from JSON file: %s", tb_str) + logger.error(f"Error loading questions data from JSON file: {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): @@ -73,7 +73,7 @@ class LinkedInEasyApplier: current_url = self.driver.current_url if "linkedin.com/premium" in current_url: - logger.error("Failed to return to job page after %d attempts. Cannot apply for the job.", max_attempts) + logger.error(f"Failed to return to job page after {max_attempts} attempts. Cannot apply for the job.") raise Exception( f"Redirected to LinkedIn Premium page and failed to return after {max_attempts} attempts. Job application aborted.") @@ -92,13 +92,13 @@ class LinkedInEasyApplier: raise e def job_apply(self, job: Any): - logger.debug("Starting job application for job: %s", job) + logger.debug(f"Starting job application for job: {job}") try: self.driver.get(job.link) - logger.debug("Navigated to job link: %s", job.link) + logger.debug(f"Navigated to job link: {job.link}") except Exception as e: - logger.error("Failed to navigate to job link: %s, error: %s", job.link, str(e)) + logger.error(f"Failed to navigate to job link: {job.link}, error: {str(e)}") raise time.sleep(random.uniform(3, 5)) @@ -118,39 +118,29 @@ class LinkedInEasyApplier: logger.debug("Retrieving job description") job_description = self._get_job_description() job.set_job_description(job_description) - logger.debug("Job description set: %s", job_description[:100]) + logger.debug(f"Job description set: {job_description[:100]}") logger.debug("Retrieving recruiter link") recruiter_link = self._get_job_recruiter() job.set_recruiter_link(recruiter_link) - logger.debug("Recruiter link set: %s", recruiter_link) + logger.debug(f"Recruiter link set: {recruiter_link}") - # Try clicking the "Easy Apply" button - try: - logger.debug("Attempting to click 'Easy Apply' button using ActionChains") - actions = ActionChains(self.driver) - actions.move_to_element(easy_apply_button).click().perform() - logger.debug("'Easy Apply' button clicked successfully") - except Exception as e: - logger.warning(f"Failed to click 'Easy Apply' button using ActionChains: {e}, trying JavaScript click") - try: - self.driver.execute_script("arguments[0].click();", easy_apply_button) - logger.debug("'Easy Apply' button clicked successfully via JavaScript") - except Exception as js_error: - logger.error(f"Failed to click 'Easy Apply' button via JavaScript: {js_error}") - raise + logger.debug("Attempting to click 'Easy Apply' button") + actions = ActionChains(self.driver) + actions.move_to_element(easy_apply_button).click().perform() + logger.debug("'Easy Apply' button clicked successfully") logger.debug("Passing job information to GPT Answerer") self.gpt_answerer.set_job(job) logger.debug("Filling out application form") self._fill_application_form(job) - logger.debug("Job application process completed successfully for job: %s", job) + logger.debug(f"Job application process completed successfully for job: {job}") except Exception as e: tb_str = traceback.format_exc() - logger.error("Failed to apply to job: %s. Error traceback: %s", job, tb_str) + logger.error(f"Failed to apply to job: {job}, error: {tb_str}") logger.debug("Discarding application due to failure") self._discard_application() @@ -160,112 +150,73 @@ class LinkedInEasyApplier: def _find_easy_apply_button(self, job: Any) -> WebElement: logger.debug("Searching for 'Easy Apply' button") attempt = 0 - timeout = 8 search_methods = [ - { - 'description': "'aria-label' containing 'Easy Apply to' and with data-job-id attribute", - 'xpath': '//button[contains(@aria-label, "Easy Apply to") and contains(@data-job-id, "")]' - }, { 'description': "find all 'Easy Apply' buttons using find_elements", 'find_elements': True, - 'xpath': '//button[contains(@class, "jobs-apply-button") and contains(., "Easy Apply") and contains(@data-job-id, "")]' + 'xpath': '//button[contains(@class, "jobs-apply-button") and contains(., "Easy Apply")]' }, { - 'description': "button text search with data-job-id attribute", - 'xpath': '//button[contains(text(), "Easy Apply") or contains(text(), "Apply now") and contains(@data-job-id, "")]' + 'description': "'aria-label' containing 'Easy Apply to'", + 'xpath': '//button[contains(@aria-label, "Easy Apply to")]' + }, + { + 'description': "button text search", + 'xpath': '//button[contains(text(), "Easy Apply") or contains(text(), "Apply now")]' } ] - while attempt < 3: + while attempt < 2: self.check_for_premium_redirect(job) self._scroll_page() - try: - logger.info("Removing focus from the active element") - self.driver.execute_script("document.activeElement.blur();") - time.sleep(1) - - logger.info("Clicking on body to reset focus via JavaScript") - try: - self.driver.execute_script("document.querySelector('body').focus();") - except Exception as e: - logger.warning(f"Failed to reset focus via body: {e}") - - time.sleep(1) - - logger.info("Clicking on html to reset focus via JavaScript") - try: - self.driver.execute_script("document.querySelector('html').focus();") - except Exception as e: - logger.warning(f"Failed to reset focus via html: {e}") - - except Exception as e: - logger.warning(f"Failed to remove focus from the active element: {e}") - for method in search_methods: try: - logger.info(f"Attempt {attempt + 1}: Searching for 'Easy Apply' button using {method['description']}") + logger.debug(f"Attempting search using {method['description']}") if method.get('find_elements'): + buttons = self.driver.find_elements(By.XPATH, method['xpath']) if buttons: for index, button in enumerate(buttons): try: - WebDriverWait(self.driver, timeout).until(EC.visibility_of(button)) - WebDriverWait(self.driver, timeout).until(EC.element_to_be_clickable(button)) - logger.info(f"Found 'Easy Apply' button {index + 1}, attempting to click") - - self.driver.execute_script("arguments[0].scrollIntoView(true);", button) - time.sleep(1) - if button.is_enabled() and button.is_displayed(): - return button - else: - raise Exception(f"Button {index + 1} is not enabled or not displayed") + WebDriverWait(self.driver, 10).until(EC.visibility_of(button)) + WebDriverWait(self.driver, 10).until(EC.element_to_be_clickable(button)) + logger.debug(f"Found 'Easy Apply' button {index + 1}, attempting to click") + return button except Exception as e: logger.warning(f"Button {index + 1} found but not clickable: {e}") else: raise TimeoutException("No 'Easy Apply' buttons found") else: - button = WebDriverWait(self.driver, timeout).until( + + button = WebDriverWait(self.driver, 10).until( EC.presence_of_element_located((By.XPATH, method['xpath'])) ) - WebDriverWait(self.driver, timeout).until(EC.visibility_of(button)) - WebDriverWait(self.driver, timeout).until(EC.element_to_be_clickable(button)) - logger.info("Found 'Easy Apply' button, attempting to click") - - self.driver.execute_script("arguments[0].scrollIntoView(true);", button) - time.sleep(1) - if button.is_enabled() and button.is_displayed(): - return button - else: - raise Exception("Button is not enabled or not displayed") + WebDriverWait(self.driver, 10).until(EC.visibility_of(button)) + WebDriverWait(self.driver, 10).until(EC.element_to_be_clickable(button)) + logger.debug("Found 'Easy Apply' button, attempting to click") + return button except TimeoutException: logger.warning(f"Timeout during search using {method['description']}") except Exception as e: - logger.warning(f"Failed to click 'Easy Apply' button using {method['description']} on attempt {attempt + 1}: {e}") + logger.warning( + f"Failed to click 'Easy Apply' button using {method['description']} on attempt {attempt + 1}: {e}") self.check_for_premium_redirect(job) if attempt == 0: - logger.info("Refreshing page and clicking on body to retry finding 'Easy Apply' button") + logger.debug("Refreshing page to retry finding 'Easy Apply' button") self.driver.refresh() time.sleep(random.randint(3, 5)) - - try: - body_element = self.driver.find_element(By.TAG_NAME, 'body') - body_element.click() - logger.info("Clicked on body element to reset the page state") - except Exception as e: - logger.warning(f"Failed to click on body element: {e}") - attempt += 1 - logger.error("No clickable 'Easy Apply' button found after 2 attempts.") + page_source = self.driver.page_source + logger.error(f"No clickable 'Easy Apply' button found after 2 attempts. Page source:\n{page_source}") raise Exception("No clickable 'Easy Apply' button found") def _get_job_description(self) -> str: @@ -285,11 +236,11 @@ class LinkedInEasyApplier: return description except NoSuchElementException: tb_str = traceback.format_exc() - logger.error("Job description not found: %s", tb_str) + logger.error(f"Job description not found: {tb_str}") raise Exception(f"Job description not found: \nTraceback:\n{tb_str}") except Exception: tb_str = traceback.format_exc() - logger.error("Error getting Job description: %s", tb_str) + logger.error(f"Error getting Job description: {tb_str}") raise Exception(f"Error getting Job description: \nTraceback:\n{tb_str}") def _get_job_recruiter(self): @@ -306,13 +257,13 @@ class LinkedInEasyApplier: if recruiter_elements: recruiter_element = recruiter_elements[0] recruiter_link = recruiter_element.get_attribute('href') - logger.debug("Job recruiter link retrieved successfully: %s", recruiter_link) + logger.debug(f"Job recruiter link retrieved successfully: {recruiter_link}") return recruiter_link else: logger.debug("No recruiter link found in the hiring team section") return "" except Exception as e: - logger.warning("Failed to retrieve recruiter information: %s", e) + logger.warning(f"Failed to retrieve recruiter information: {e}") return "" def _scroll_page(self) -> None: @@ -322,7 +273,7 @@ class LinkedInEasyApplier: utils.scroll_slow(self.driver, scrollable_element, step=300, reverse=True) def _fill_application_form(self, job): - logger.debug("Filling out application form for job: %s", job) + logger.debug(f"Filling out application form for job: {job}") while True: self.fill_up(job) if self._next_or_submit(): @@ -352,13 +303,13 @@ class LinkedInEasyApplier: By.XPATH, "//label[contains(.,'to stay up to date with their page.')]") follow_checkbox.click() except Exception as e: - logger.warning("Failed to unfollow company: %s", e) + logger.debug(f"Failed to unfollow company: {e}") def _check_for_errors(self) -> None: logger.debug("Checking for form errors") error_elements = self.driver.find_elements(By.CLASS_NAME, 'artdeco-inline-feedback--error') if error_elements: - logger.error("Form submission failed with errors: %s", [e.text for e in error_elements]) + logger.error(f"Form submission failed with errors: {error_elements}") raise Exception(f"Failed answering or file upload. {str([e.text for e in error_elements])}") def _discard_application(self) -> None: @@ -369,10 +320,10 @@ class LinkedInEasyApplier: self.driver.find_elements(By.CLASS_NAME, 'artdeco-modal__confirm-dialog-btn')[0].click() time.sleep(random.uniform(3, 5)) except Exception as e: - logger.warning("Failed to discard application: %s", e) + logger.warning(f"Failed to discard application: {e}") def fill_up(self, job) -> None: - logger.debug("Filling up form sections for job: %s", job) + logger.debug(f"Filling up form sections for job: {job}") try: easy_apply_content = WebDriverWait(self.driver, 10).until( @@ -435,7 +386,7 @@ class LinkedInEasyApplier: def _is_upload_field(self, element: WebElement) -> bool: is_upload = bool(element.find_elements(By.XPATH, ".//input[@type='file']")) - logger.debug("Element is upload field: %s", is_upload) + logger.debug(f"Element is upload field: {is_upload}") return is_upload def _handle_upload_fields(self, element: WebElement, job) -> None: @@ -801,24 +752,12 @@ class LinkedInEasyApplier: if dropdowns: dropdown = dropdowns[0] select = Select(dropdown) - options = [option.text for option in select.options if option.text != "Select an option"] + options = [option.text for option in select.options] logger.debug(f"Dropdown options found: {options}") - try: - question_text = question.find_element(By.TAG_NAME, 'label').text.lower().strip() - except NoSuchElementException: - logger.warning("Label not found, trying to extract question text from or other elements") - - try: - question_text = question.find_element(By.CSS_SELECTOR, - 'span[aria-hidden="true"]').text.lower().strip() - except NoSuchElementException: - - question_text = section.get_attribute('data-test-text-entity-list-form-title') or "unknown question" - question_text = question_text.lower().strip() - - logger.debug(f"Processing dropdown question: {question_text}") + 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}") @@ -833,14 +772,14 @@ class LinkedInEasyApplier: logger.debug(f"Found existing answer for question '{question_text}': {existing_answer}") if current_selection != existing_answer: logger.debug(f"Updating selection to: {existing_answer}") - self._select_dropdown_option(select, existing_answer) + self._select_dropdown_option(dropdown, existing_answer) return True logger.debug(f"No existing answer found, querying model for: {question_text}") answer = self.gpt_answerer.answer_question_from_options(question_text, options) self._save_questions_to_json({'type': 'dropdown', 'question': question_text, 'answer': answer}) - self._select_dropdown_option(select, answer) + self._select_dropdown_option(dropdown, answer) logger.debug(f"Selected new dropdown answer: {answer}") return True @@ -855,55 +794,35 @@ class LinkedInEasyApplier: logger.warning(f"Failed to handle dropdown or combobox question: {e}", exc_info=True) return False - - def _select_dropdown_option(self, select: Select, text: str) -> None: - - try: - select.select_by_visible_text(text) - logger.debug(f"Selected option: {text}") - except Exception as e: - logger.error(f"Failed to select option '{text}': {e}") - def _is_numeric_field(self, field: WebElement) -> bool: field_type = field.get_attribute('type').lower() field_id = field.get_attribute("id").lower() is_numeric = 'numeric' in field_id or field_type == 'number' or ('text' == field_type and 'numeric' in field_id) - logger.debug("Field type: %s, Field ID: %s, Is numeric: %s", field_type, field_id, is_numeric) + logger.debug(f"Field type: {field_type}, Field ID: {field_id}, Is numeric: {is_numeric}") return is_numeric def _enter_text(self, element: WebElement, text: str) -> None: - logger.debug("Entering text: %s", text) + logger.debug(f"Entering text: {text}") element.clear() element.send_keys(text) def _select_radio(self, radios: List[WebElement], answer: str) -> None: - logger.debug("Selecting radio option: %s", answer) + logger.debug(f"Selecting radio option: {answer}") for radio in radios: if answer in radio.text.lower(): radio.find_element(By.TAG_NAME, 'label').click() return radios[-1].find_element(By.TAG_NAME, 'label').click() + def _select_dropdown_option(self, element: WebElement, text: str) -> None: + logger.debug(f"Selecting dropdown option: {text}") + select = Select(element) + select.select_by_visible_text(text) def _save_questions_to_json(self, question_data: dict) -> None: - """ - Save question data to a JSON file, with filtering to exclude company-specific or unsuitable questions. - - Args: - question_data (dict): The question and answer data to be saved. - """ output_file = 'answers.json' question_data['question'] = self._sanitize_text(question_data['question']) - logger.debug("Saving question data to JSON: %s", question_data) - - # List of keywords to exclude certain questions from being saved - exclusion_keywords = ["why us", "summary"] - - # Check if the question contains any exclusion keywords - if any(keyword in question_data['question'].lower() for keyword in exclusion_keywords): - logger.info(f"Skipping saving question due to company-specific keywords: {question_data['question']}") - return # Skip saving this question if it's company-specific - + logger.debug(f"Saving question data to JSON: {question_data}") try: try: with open(output_file, 'r') as f: @@ -917,19 +836,17 @@ class LinkedInEasyApplier: except FileNotFoundError: logger.warning("JSON file not found, creating new file") data = [] - data.append(question_data) - with open(output_file, 'w') as f: json.dump(data, f, indent=4) logger.debug("Question data saved successfully to JSON") except Exception: tb_str = traceback.format_exc() - logger.error("Error saving questions data to JSON file: %s", tb_str) + logger.error(f"Error saving questions data to JSON file: {tb_str}") raise Exception(f"Error saving questions data to JSON file: \nTraceback:\n{tb_str}") def _sanitize_text(self, text: str) -> str: sanitized_text = text.lower().strip().replace('"', '').replace('\\', '') sanitized_text = re.sub(r'[\x00-\x1F\x7F]', '', sanitized_text).replace('\n', ' ').replace('\r', '').rstrip(',') - logger.debug("Sanitized text: %s", sanitized_text) + logger.debug(f"Sanitized text: {sanitized_text}") return sanitized_text diff --git a/src/linkedIn_job_manager.py b/src/linkedIn_job_manager.py index 778be4f..b608c07 100644 --- a/src/linkedIn_job_manager.py +++ b/src/linkedIn_job_manager.py @@ -12,7 +12,7 @@ from selenium.webdriver.common.by import By import src.utils as utils from src.job import Job from src.linkedIn_easy_applier import LinkedInEasyApplier -from src.utils import logger +from loguru import logger class EnvironmentKeys: @@ -20,19 +20,18 @@ class EnvironmentKeys: logger.debug("Initializing EnvironmentKeys") self.skip_apply = self._read_env_key_bool("SKIP_APPLY") self.disable_description_filter = self._read_env_key_bool("DISABLE_DESCRIPTION_FILTER") - logger.debug("EnvironmentKeys initialized: skip_apply=%s, disable_description_filter=%s", - self.skip_apply, self.disable_description_filter) + logger.debug(f"EnvironmentKeys initialized: skip_apply={self.skip_apply}, disable_description_filter={self.disable_description_filter}") @staticmethod def _read_env_key(key: str) -> str: value = os.getenv(key, "") - logger.debug("Read environment key %s: %s", key, value) + logger.debug(f"Read environment key {key}: {value}") return value @staticmethod def _read_env_key_bool(key: str) -> bool: value = os.getenv(key) == "True" - logger.debug("Read environment key %s as bool: %s", key, value) + logger.debug(f"Read environment key {key} as bool: {value}") return value @@ -72,28 +71,10 @@ class LinkedInJobManager: logger.debug("Setting resume generator manager") self.resume_generator_manager = resume_generator_manager - def wait_or_skip(self, time_left): - """Method for waiting or skipping the sleep time based on user input""" - if time_left > 0: - try: - user_input = inputimeout( - 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': - 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) - def start_applying(self): logger.debug("Starting job application process") self.easy_applier_component = LinkedInEasyApplier(self.driver, self.resume_path, self.set_old_answers, - self.gpt_answerer, self.resume_generator_manager, - self.parameters) + self.gpt_answerer, self.resume_generator_manager) searches = list(product(self.positions, self.locations)) random.shuffle(searches) page_sleep = 0 @@ -103,21 +84,21 @@ class LinkedInJobManager: for position, location in searches: location_url = "&location=" + location job_page_number = -1 - utils.printyellow(f"Starting the search for {position} in {location}.") + logger.debug(f"Starting the search for {position} in {location}.") try: while True: page_sleep += 1 job_page_number += 1 - utils.printyellow(f"Going to job page {job_page_number}") + logger.debug(f"Going to job page {job_page_number}") self.next_job_page(position, location_url, job_page_number) time.sleep(random.uniform(1.5, 3.5)) - utils.printyellow("Starting the application process for this page...") + logger.debug("Starting the application process for this page...") try: jobs = self.get_jobs_from_page() if not jobs: - utils.printyellow("No more jobs found on this page. Exiting loop.") + logger.debug("No more jobs found on this page. Exiting loop.") break except Exception as e: logger.error(f"Failed to retrieve jobs: {e}") @@ -126,40 +107,77 @@ class LinkedInJobManager: try: self.apply_jobs() except Exception as e: - logger.error("Error during job application: %s", e) - utils.printred(f"Error during job application: {e}") + logger.error(f"Error during job application: {e}") continue - utils.printyellow("Applying to jobs on this page has been completed!") + logger.debug("Applying to jobs on this page has been completed!") time_left = minimum_page_time - time.time() - # Use the wait_or_skip function for sleeping - self.wait_or_skip(time_left) + # Ask user if they want to skip waiting, with timeout + if time_left > 0: + try: + user_input = inputimeout( + 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': + logger.debug("User chose to skip waiting.") + else: + logger.debug(f"Sleeping for {time_left} seconds as user chose not to skip.") + time.sleep(time_left) minimum_page_time = time.time() + minimum_time if page_sleep % 5 == 0: sleep_time = random.randint(5, 34) - # Use the wait_or_skip function for extended sleep - self.wait_or_skip(sleep_time) + try: + user_input = inputimeout( + 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': + logger.debug("User chose to skip waiting.") + else: + logger.debug(f"Sleeping for {sleep_time} seconds.") + 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}") + logger.error(f"Unexpected error during job search: {e}") continue time_left = minimum_page_time - time.time() - # Use the wait_or_skip function again before moving to the next search - self.wait_or_skip(time_left) + if time_left > 0: + try: + user_input = inputimeout( + 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': + logger.debug("User chose to skip waiting.") + else: + logger.debug(f"Sleeping for {time_left} seconds as user chose not to skip.") + time.sleep(time_left) minimum_page_time = time.time() + minimum_time if page_sleep % 5 == 0: sleep_time = random.randint(50, 90) - # Use the wait_or_skip function for a longer sleep period - self.wait_or_skip(sleep_time) + try: + user_input = inputimeout( + prompt=f"Sleeping for {sleep_time / 60} minutes. Press 'y' to skip waiting: ", + timeout=60).strip().lower() + except TimeoutOccurred: + user_input = '' # No input after timeout + if user_input == 'y': + logger.debug("User chose to skip waiting.") + else: + logger.debug(f"Sleeping for {sleep_time} seconds.") + time.sleep(sleep_time) page_sleep += 1 def get_jobs_from_page(self): @@ -168,7 +186,6 @@ class LinkedInJobManager: no_jobs_element = self.driver.find_element(By.CLASS_NAME, 'jobs-search-two-pane__no-results-banner--expand') if 'No matching jobs found' in no_jobs_element.text or 'unfortunately, things aren' in self.driver.page_source.lower(): - utils.printyellow("No matching jobs found on this page.") logger.debug("No matching jobs found on this page, skipping.") return [] @@ -178,12 +195,11 @@ class LinkedInJobManager: try: 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, 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.") logger.debug("No job class elements found on page, skipping.") return [] @@ -201,7 +217,6 @@ class LinkedInJobManager: try: no_jobs_element = self.driver.find_element(By.CLASS_NAME, 'jobs-search-two-pane__no-results-banner--expand') if 'No matching jobs found' in no_jobs_element.text or 'unfortunately, things aren' in self.driver.page_source.lower(): - utils.printyellow("No matching jobs found on this page, moving to next.") logger.debug("No matching jobs found on this page, skipping") return except NoSuchElementException: @@ -215,7 +230,6 @@ class LinkedInJobManager: 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 @@ -236,48 +250,37 @@ class LinkedInJobManager: # Iterate over each job insight element to find the one containing the word "applicant" for element in job_insight_elements: - applicants_text = element.text.strip().lower() - logger.debug(f"Checking element text: {applicants_text}") + 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}") - # Look for keywords indicating the presence of applicants count - if "applicant" in applicants_text: - logger.info(f"Applicants text found: {applicants_text}") - - # Try to find numeric value in the text, such as "27 applicants" or "over 100 applicants" + # 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: - applicants_count = int(applicants_count) # Convert the extracted number to an integer - logger.info(f"Extracted numeric applicants count: {applicants_count}") - - # Handle case with "over X applicants" - if "over" in applicants_text: - applicants_count += 1 - logger.info(f"Adjusted applicants count for 'over': {applicants_count}") - - logger.info(f"Final applicants count: {applicants_count}") - else: - logger.warning(f"Applicants count could not be extracted from text: {applicants_text}") - - break # Stop after finding the first valid applicants count element - else: - logger.info(f"Skipping element as it does not contain 'applicant': {applicants_text}") + 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}, but continuing with application.") - + 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( @@ -294,8 +297,7 @@ class LinkedInJobManager: 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) + logger.debug(f"Job blacklisted: {job.title} at {job.company}") self.write_to_file(job, "skipped") continue if self.is_already_applied_to_job(job.title, job.company, job.link): @@ -308,15 +310,14 @@ class LinkedInJobManager: if job.apply_method not in {"Continue", "Applied", "Apply"}: self.easy_applier_component.job_apply(job) self.write_to_file(job, "success") - logger.debug("Applied to job: %s at %s", job.title, job.company) + logger.debug(f"Applied to job: {job.title} at {job.company}") except Exception as e: - logger.error("Failed to apply for %s at %s: %s", job.title, job.company, e) - utils.printred(f"Failed to apply for {job.title} at {job.company}: {e}") + logger.error(f"Failed to apply for {job.title} at {job.company}: {e}") self.write_to_file(job, "failed") continue def write_to_file(self, job, file_name): - logger.debug("Writing job application result to file: %s", file_name) + logger.debug(f"Writing job application result to file: {file_name}") pdf_path = Path(job.pdf_path).resolve() pdf_path = pdf_path.as_uri() data = { @@ -331,27 +332,26 @@ class LinkedInJobManager: if not file_path.exists(): with open(file_path, 'w', encoding='utf-8') as f: json.dump([data], f, indent=4) - logger.debug("Job data written to new file: %s", file_path) + logger.debug(f"Job data written to new file: {file_name}") else: with open(file_path, 'r+', encoding='utf-8') as f: try: existing_data = json.load(f) except json.JSONDecodeError: - logger.error("JSON decode error in file: %s", file_path) + logger.error(f"JSON decode error in file: {file_path}") existing_data = [] existing_data.append(data) f.seek(0) json.dump(existing_data, f, indent=4) f.truncate() - logger.debug("Job data appended to existing file: %s", file_path) + logger.debug(f"Job data appended to existing file: {file_name}") def get_base_search_url(self, parameters): logger.debug("Constructing base search URL") 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('experience_level', {}).items()) if v] if experience_levels: url_parts.append(f"f_E={','.join(experience_levels)}") @@ -369,11 +369,11 @@ class LinkedInJobManager: url_parts.append("f_LF=f_AL") # Easy Apply base_url = "&".join(url_parts) full_url = f"?{base_url}{date_param}" - logger.debug("Base search URL constructed: %s", full_url) + logger.debug(f"Base search URL constructed: {full_url}") return full_url def next_job_page(self, position, location, job_page): - logger.debug("Navigating to next job page: %s in %s, page %d", position, location, job_page) + logger.debug(f"Navigating to next job page: {position} in {location}, page {job_page}") self.driver.get( f"https://www.linkedin.com/jobs/search/{self.base_search_url}&keywords={position}{location}&start={job_page * 25}") @@ -384,39 +384,36 @@ class LinkedInJobManager: job_title = job_tile.find_element(By.CLASS_NAME, 'job-card-list__title').text link = job_tile.find_element(By.CLASS_NAME, 'job-card-list__title').get_attribute('href').split('?')[0] company = job_tile.find_element(By.CLASS_NAME, 'job-card-container__primary-description').text - logger.debug("Job information extracted: %s at %s", job_title, company) + logger.debug(f"Job information extracted: {job_title} at {company}") except NoSuchElementException: - utils.printyellow("Some job information (title, link, or company) is missing.") logger.warning("Some job information (title, link, or company) is missing.") try: job_location = job_tile.find_element(By.CLASS_NAME, 'job-card-container__metadata-item').text except NoSuchElementException: - utils.printyellow("Job location is missing.") logger.warning("Job location is missing.") try: apply_method = job_tile.find_element(By.CLASS_NAME, 'job-card-container__apply-method').text except NoSuchElementException: apply_method = "Applied" - utils.printyellow("Apply method not found, assuming 'Applied'.") logger.warning("Apply method not found, assuming 'Applied'.") return job_title, company, job_location, link, apply_method def is_blacklisted(self, job_title, company, link): - logger.debug("Checking if job is blacklisted: %s at %s", job_title, company) + logger.debug(f"Checking if job is blacklisted: {job_title} at {company}") job_title_words = job_title.lower().split(' ') title_blacklisted = any(word in job_title_words for word in self.title_blacklist) company_blacklisted = company.strip().lower() in (word.strip().lower() for word in self.company_blacklist) link_seen = link in self.seen_jobs is_blacklisted = title_blacklisted or company_blacklisted or link_seen - logger.debug("Job blacklisted status: %s", is_blacklisted) + logger.debug(f"Job blacklisted status: {is_blacklisted}") return title_blacklisted or company_blacklisted or link_seen def is_already_applied_to_job(self, job_title, company, link): link_seen = link in self.seen_jobs if link_seen: - utils.printyellow(f"Already applied to job: {job_title} at {company}, skipping...") + logger.debug(f"Already applied to job: {job_title} at {company}, skipping...") return link_seen def is_already_applied_to_company(self, company): @@ -432,7 +429,7 @@ class LinkedInJobManager: existing_data = json.load(f) for applied_job in existing_data: if applied_job['company'].strip().lower() == company.strip().lower(): - utils.printyellow( + logger.debug( f"Already applied at {company} (once per company policy), skipping...") return True except json.JSONDecodeError: diff --git a/src/linkedin-api.py b/src/linkedin-api.py index cb38de7..716b3db 100644 --- a/src/linkedin-api.py +++ b/src/linkedin-api.py @@ -2,11 +2,12 @@ from typing import Dict, List from linkedin_api import Linkedin from typing import Optional, Union, Literal from urllib.parse import quote, urlencode, parse_qs, urlparse -import logging +# import logging import json +from loguru import logger # set log to all debug -logging.basicConfig(level=logging.INFO) +# logging.basicConfig(level=logging.INFO) class LinkedInEvolvedAPI(Linkedin): already_applied_jobs: List[str] = [] @@ -388,7 +389,7 @@ class LinkedInEvolvedAPI(Linkedin): case 200: parse_res = res.json() url = parse_res['data']['value'] - logging.info(url) + logger.info(url) return url case _: self.logger.error("Failed to create a request PDF") @@ -496,22 +497,22 @@ if __name__ == "__main__": resume: str = api.upload_linkedin_resume("resume.pdf") if isinstance(resume, bool): - logging.error("Failed to upload resume") + logger.error("Failed to upload resume") continue elif isinstance(resume, str): - logging.info(f"Resume uploaded with hash {resume}") + logger.info(f"Resume uploaded with hash {resume}") else: - logging.error("Unknown error") + logger.error("Unknown error") continue if job_id in api.already_applied_jobs: - logging.info(f"Already applied to job {job_id}, skipping it") + logger.info(f"Already applied to job {job_id}, skipping it") continue fields = api.get_fields_for_easy_apply(job_id) for field in fields: - print(field) + logger.info(field) break diff --git a/src/llm/llm_manager.py b/src/llm/llm_manager.py new file mode 100644 index 0000000..d1f6fe1 --- /dev/null +++ b/src/llm/llm_manager.py @@ -0,0 +1,569 @@ +import json +import os +import re +import textwrap +import time +from abc import ABC, abstractmethod +from datetime import datetime +from pathlib import Path +from typing import Dict, List +from typing import Union + +import httpx +from Levenshtein import distance +from dotenv import load_dotenv +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 + +import src.strings as strings +from loguru 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: + logger.debug("Invoking OpenAI API") + response = self.model.invoke(prompt) + return response + + +class ClaudeModel(AIModel): + def __init__(self, api_key: str, llm_model: str, llm_api_url: str): + from langchain_anthropic import ChatAnthropic + self.model = ChatAnthropic(model=llm_model, api_key=api_key, + temperature=0.4, base_url=llm_api_url) + + def invoke(self, prompt: str) -> str: + response = self.model.invoke(prompt) + return response + + +class OllamaModel(AIModel): + def __init__(self, api_key: str, llm_model: str, llm_api_url: str): + from langchain_ollama import ChatOllama + self.model = ChatOllama(model=llm_model, base_url=llm_api_url) + + def invoke(self, prompt: str) -> str: + response = self.model.invoke(prompt) + return response + + +class 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) + + 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'] + logger.debug('Using {0} with {1} from {2}'.format( + llm_model_type, llm_model, llm_api_url)) + + if llm_model_type == "openai": + return OpenAIModel(api_key, llm_model, llm_api_url) + elif llm_model_type == "claude": + return ClaudeModel(api_key, llm_model, llm_api_url) + elif llm_model_type == "ollama": + return OllamaModel(api_key, llm_model, llm_api_url) + elif llm_model_type == "gemini": + return GeminiModel(api_key, llm_model, llm_api_url) + else: + raise ValueError(f"Unsupported model type: {llm_model_type}") + + def invoke(self, prompt: str) -> str: + return self.model.invoke(prompt) + + +class LLMLogger: + + def __init__(self, llm: Union[OpenAIModel, OllamaModel, ClaudeModel, GeminiModel]): + self.llm = llm + logger.debug(f"LLMLogger successfully initialized with LLM: {llm}") + + @staticmethod + def log_request(prompts, parsed_reply: Dict[str, Dict]): + logger.debug("Starting log_request method") + logger.debug(f"Prompts received: {prompts}") + logger.debug(f"Parsed reply received: {parsed_reply}") + + try: + calls_log = os.path.join( + Path("data_folder/output"), "open_ai_calls.json") + logger.debug(f"Logging path determined: {calls_log}") + except Exception as e: + logger.error(f"Error determining the log path: {str(e)}") + raise + + if isinstance(prompts, StringPromptValue): + logger.debug("Prompts are of type StringPromptValue") + prompts = prompts.text + logger.debug(f"Prompts converted to text: {prompts}") + 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(f"Prompts converted to dictionary: {prompts}") + except Exception as e: + logger.error(f"Error converting prompts to dictionary: {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(f"Prompts converted to dictionary using default method: {prompts}") + except Exception as e: + logger.error(f"Error converting prompts using default method: {str(e)}") + raise + + try: + current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + logger.debug(f"Current time obtained: {current_time}") + except Exception as e: + logger.error(f"Error obtaining current time: {str(e)}") + raise + + try: + token_usage = parsed_reply["usage_metadata"] + output_tokens = token_usage["output_tokens"] + input_tokens = token_usage["input_tokens"] + total_tokens = token_usage["total_tokens"] + logger.debug(f"Token usage - Input: {input_tokens}, Output: {output_tokens}, Total: {total_tokens}") + except KeyError as e: + logger.error(f"KeyError in parsed_reply structure: {str(e)}") + raise + + try: + model_name = parsed_reply["response_metadata"]["model_name"] + logger.debug(f"Model name: {model_name}") + except KeyError as e: + logger.error(f"KeyError in response_metadata: {str(e)}") + raise + + 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) + logger.debug(f"Total cost calculated: {total_cost}") + except Exception as e: + logger.error(f"Error calculating total cost: {str(e)}") + raise + + try: + log_entry = { + "model": model_name, + "time": current_time, + "prompts": prompts, + "replies": parsed_reply["content"], + "total_tokens": total_tokens, + "input_tokens": input_tokens, + "output_tokens": output_tokens, + "total_cost": total_cost, + } + logger.debug(f"Log entry created: {log_entry}") + except KeyError as e: + logger.error(f"Error creating log entry: missing key {str(e)} in parsed_reply") + raise + + try: + with open(calls_log, "a", encoding="utf-8") as f: + json_string = json.dumps( + log_entry, ensure_ascii=False, indent=4) + f.write(json_string + "\n") + logger.debug(f"Log entry written to file: {calls_log}") + except Exception as e: + logger.error(f"Error writing log entry to file: {str(e)}") + raise + + +class LoggerChatModel: + + def __init__(self, llm: Union[OpenAIModel, OllamaModel, ClaudeModel, GeminiModel]): + self.llm = llm + logger.debug(f"LoggerChatModel successfully initialized with LLM: {llm}") + + def __call__(self, messages: List[Dict[str, str]]) -> str: + logger.debug(f"Entering __call__ method with messages: {messages}") + while True: + try: + logger.debug("Attempting to call the LLM with messages") + + reply = self.llm.invoke(messages) + logger.debug(f"LLM response received: {reply}") + + parsed_reply = self.parse_llmresult(reply) + logger.debug(f"Parsed LLM reply: {parsed_reply}") + + LLMLogger.log_request( + prompts=messages, parsed_reply=parsed_reply) + logger.debug("Request successfully logged") + + return reply + + except httpx.HTTPStatusError as e: + logger.error(f"HTTPStatusError encountered: {str(e)}") + if e.response.status_code == 429: + retry_after = e.response.headers.get('retry-after') + retry_after_ms = e.response.headers.get('retry-after-ms') + + if retry_after: + wait_time = int(retry_after) + logger.warning( + f"Rate limit exceeded. Waiting for {wait_time} seconds before retrying (extracted from 'retry-after' header)...") + time.sleep(wait_time) + elif retry_after_ms: + wait_time = int(retry_after_ms) / 1000.0 + logger.warning( + f"Rate limit exceeded. Waiting for {wait_time} seconds before retrying (extracted from 'retry-after-ms' header)...") + time.sleep(wait_time) + else: + wait_time = 30 + logger.warning( + f"'retry-after' header not found. Waiting for {wait_time} seconds before retrying (default)...") + time.sleep(wait_time) + else: + logger.error(f"HTTP error occurred with status code: {e.response.status_code}, waiting 30 seconds before retrying") + time.sleep(30) + + except Exception as e: + logger.error(f"Unexpected error occurred: {str(e)}") + logger.info( + "Waiting for 30 seconds before retrying due to an unexpected error.") + time.sleep(30) + continue + + def parse_llmresult(self, llmresult: AIMessage) -> Dict[str, Dict]: + logger.debug(f"Parsing LLM result: {llmresult}") + + try: + content = llmresult.content + response_metadata = llmresult.response_metadata + id_ = llmresult.id + usage_metadata = llmresult.usage_metadata + + parsed_result = { + "content": content, + "response_metadata": { + "model_name": response_metadata.get("model_name", ""), + "system_fingerprint": response_metadata.get("system_fingerprint", ""), + "finish_reason": response_metadata.get("finish_reason", ""), + "logprobs": response_metadata.get("logprobs", None), + }, + "id": id_, + "usage_metadata": { + "input_tokens": usage_metadata.get("input_tokens", 0), + "output_tokens": usage_metadata.get("output_tokens", 0), + "total_tokens": usage_metadata.get("total_tokens", 0), + }, + } + + logger.debug(f"Parsed LLM result successfully: {parsed_result}") + return parsed_result + + except KeyError as e: + logger.error( + f"KeyError while parsing LLM result: missing key {str(e)}") + raise + + except Exception as e: + logger.error( + f"Unexpected error while parsing LLM result: {str(e)}") + raise + + +class GPTAnswerer: + + def __init__(self, config, llm_api_key): + self.ai_adapter = AIAdapter(config, llm_api_key) + self.llm_cheap = LoggerChatModel(self.ai_adapter) + + @property + def job_description(self): + return self.job.description + + @staticmethod + def find_best_match(text: str, options: list[str]) -> str: + logger.debug(f"Finding best match for text: '{text}' in options: {options}") + distances = [ + (option, distance(text.lower(), option.lower())) for option in options + ] + best_option = min(distances, key=lambda x: x[1])[0] + logger.debug(f"Best match found: {best_option}") + return best_option + + @staticmethod + def _remove_placeholders(text: str) -> str: + logger.debug(f"Removing placeholders from text: {text}") + text = text.replace("PLACEHOLDER", "") + return text.strip() + + @staticmethod + def _preprocess_template_string(template: str) -> str: + logger.debug("Preprocessing template string") + return textwrap.dedent(template) + + def set_resume(self, resume): + logger.debug(f"Setting resume: {resume}") + self.resume = resume + + def set_job(self, job): + logger.debug(f"Setting job: {job}") + self.job = job + self.job.set_summarize_job_description( + self.summarize_job_description(self.job.description)) + + def set_job_application_profile(self, job_application_profile): + logger.debug(f"Setting job application profile: {job_application_profile}") + self.job_application_profile = job_application_profile + + def summarize_job_description(self, text: str) -> str: + logger.debug(f"Summarizing job description: {text}") + strings.summarize_prompt_template = self._preprocess_template_string( + 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(f"Summary generated: {output}") + return output + + def _create_chain(self, template: str): + logger.debug(f"Creating chain with template: {template}") + prompt = ChatPromptTemplate.from_template(template) + return prompt | self.llm_cheap | StrOutputParser() + + def answer_question_textual_wide_range(self, question: str) -> str: + logger.debug(f"Answering textual question: {question}") + chains = { + "personal_information": self._create_chain(strings.personal_information_template), + "self_identification": self._create_chain(strings.self_identification_template), + "legal_authorization": self._create_chain(strings.legal_authorization_template), + "work_preferences": self._create_chain(strings.work_preferences_template), + "education_details": self._create_chain(strings.education_details_template), + "experience_details": self._create_chain(strings.experience_details_template), + "projects": self._create_chain(strings.projects_template), + "availability": self._create_chain(strings.availability_template), + "salary_expectations": self._create_chain(strings.salary_expectations_template), + "certifications": self._create_chain(strings.certifications_template), + "languages": self._create_chain(strings.languages_template), + "interests": self._create_chain(strings.interests_template), + "cover_letter": self._create_chain(strings.coverletter_template), + } + section_prompt = """ + You are assisting a bot designed to automatically apply for jobs on LinkedIn. The bot receives various questions about job applications and needs to determine the most relevant section of the resume to provide an accurate response. + + For the following question: '{question}', determine which section of the resume is most relevant. + Respond with exactly one of the following options: + - Personal information + - Self Identification + - Legal Authorization + - Work Preferences + - Education Details + - Experience Details + - Projects + - Availability + - Salary Expectations + - Certifications + - Languages + - Interests + - Cover letter + + Here are detailed guidelines to help you choose the correct section: + + 1. **Personal Information**: + - **Purpose**: Contains your basic contact details and online profiles. + - **Use When**: The question is about how to contact you or requests links to your professional online presence. + - **Examples**: Email address, phone number, LinkedIn profile, GitHub repository, personal website. + + 2. **Self Identification**: + - **Purpose**: Covers personal identifiers and demographic information. + - **Use When**: The question pertains to your gender, pronouns, veteran status, disability status, or ethnicity. + - **Examples**: Gender, pronouns, veteran status, disability status, ethnicity. + + 3. **Legal Authorization**: + - **Purpose**: Details your work authorization status and visa requirements. + - **Use When**: The question asks about your ability to work in specific countries or if you need sponsorship or visas. + - **Examples**: Work authorization in EU and US, visa requirements, legally allowed to work. + + 4. **Work Preferences**: + - **Purpose**: Specifies your preferences regarding work conditions and job roles. + - **Use When**: The question is about your preferences for remote work, in-person work, relocation, and willingness to undergo assessments or background checks. + - **Examples**: Remote work, in-person work, open to relocation, willingness to complete assessments. + + 5. **Education Details**: + - **Purpose**: Contains information about your academic qualifications. + - **Use When**: The question concerns your degrees, universities attended, GPA, and relevant coursework. + - **Examples**: Degree, university, GPA, field of study, exams. + + 6. **Experience Details**: + - **Purpose**: Details your professional work history and key responsibilities. + - **Use When**: The question pertains to your job roles, responsibilities, and achievements in previous positions. + - **Examples**: Job positions, company names, key responsibilities, skills acquired. + + 7. **Projects**: + - **Purpose**: Highlights specific projects you have worked on. + - **Use When**: The question asks about particular projects, their descriptions, or links to project repositories. + - **Examples**: Project names, descriptions, links to project repositories. + + 8. **Availability**: + - **Purpose**: Provides information on your availability for new roles. + - **Use When**: The question is about how soon you can start a new job or your notice period. + - **Examples**: Notice period, availability to start. + + 9. **Salary Expectations**: + - **Purpose**: Covers your expected salary range. + - **Use When**: The question pertains to your salary expectations or compensation requirements. + - **Examples**: Desired salary range. + + 10. **Certifications**: + - **Purpose**: Lists your professional certifications or licenses. + - **Use When**: The question involves your certifications or qualifications from recognized organizations. + - **Examples**: Certification names, issuing bodies, dates of validity. + + 11. **Languages**: + - **Purpose**: Describes the languages you can speak and your proficiency levels. + - **Use When**: The question asks about your language skills or proficiency in specific languages. + - **Examples**: Languages spoken, proficiency levels. + + 12. **Interests**: + - **Purpose**: Details your personal or professional interests. + - **Use When**: The question is about your hobbies, interests, or activities outside of work. + - **Examples**: Personal hobbies, professional interests. + + 13. **Cover Letter**: + - **Purpose**: Contains your personalized cover letter or statement. + - **Use When**: The question involves your cover letter or specific written content intended for the job application. + - **Examples**: Cover letter content, personalized statements. + + Provide only the exact name of the section from the list above with no additional text. + """ + prompt = ChatPromptTemplate.from_template(section_prompt) + chain = prompt | self.llm_cheap | StrOutputParser() + output = chain.invoke({"question": question}) + + match = re.search( + r"(Personal information|Self Identification|Legal Authorization|Work Preferences|Education " + r"Details|Experience Details|Projects|Availability|Salary " + r"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}) + logger.debug(f"Cover letter generated: {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( + 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(f"Chain not defined for section '{section_name}'") + raise ValueError(f"Chain not defined for section '{section_name}'") + output = chain.invoke( + {"resume_section": resume_section, "question": question}) + logger.debug(f"Question answered: {output}") + return output + + def answer_question_numeric(self, question: str, default_experience: int = 3) -> int: + logger.debug(f"Answering numeric question: {question}") + func_template = self._preprocess_template_string( + strings.numeric_question_template) + prompt = ChatPromptTemplate.from_template(func_template) + chain = prompt | self.llm_cheap | StrOutputParser() + output_str = chain.invoke( + {"resume_educations": self.resume.education_details, "resume_jobs": self.resume.experience_details, + "resume_projects": self.resume.projects, "question": question}) + logger.debug(f"Raw output for numeric question: {output_str}") + try: + output = self.extract_number_from_string(output_str) + logger.debug(f"Extracted number: {output}") + except ValueError: + logger.warning( + f"Failed to extract number, using default experience: {default_experience}") + output = default_experience + return output + + def extract_number_from_string(self, output_str): + logger.debug(f"Extracting number from string: {output_str}") + numbers = re.findall(r"\d+", output_str) + if numbers: + logger.debug(f"Numbers found: {numbers}") + return int(numbers[0]) + else: + logger.error("No numbers found in the string") + raise ValueError("No numbers found in the string") + + def answer_question_from_options(self, question: str, options: list[str]) -> str: + logger.debug(f"Answering question from options: {question}") + 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}) + logger.debug(f"Raw output for options question: {output_str}") + best_option = self.find_best_match(output_str, options) + logger.debug(f"Best option determined: {best_option}") + return best_option + + def resume_or_cover(self, phrase: str) -> str: + logger.debug( + f"Determining if phrase refers to resume or cover letter: {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'. + If the phrase contains 'upload resume', consider it as 'resume'. + Do not provide any additional information or explanations. + + phrase: {phrase} + """ + prompt = ChatPromptTemplate.from_template(prompt_template) + chain = prompt | self.llm_cheap | StrOutputParser() + response = chain.invoke({"phrase": phrase}) + logger.debug(f"Response for resume_or_cover: {response}") + if "resume" in response: + return "resume" + elif "cover" in response: + return "cover" + else: + return "resume" \ No newline at end of file diff --git a/src/utils.py b/src/utils.py index 974787e..587e7a2 100644 --- a/src/utils.py +++ b/src/utils.py @@ -1,41 +1,48 @@ import logging import os import random +import sys import time from selenium import webdriver +from loguru import logger + +from app_config import MINIMUM_LOG_LEVEL log_file = "app_log.log" -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', - handlers=[ - logging.FileHandler(log_file, mode='a', encoding='utf-8'), - logging.StreamHandler() - ], - force=True # This will reset the root logger's handlers and apply the new configuration -) +# TODO: REMOVE THE FOLLOWING BLOCK: No need as Loguru handles everything by default +# logging.basicConfig( +# level=logging.INFO, +# format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', +# handlers=[ +# logging.FileHandler(log_file, mode='a', encoding='utf-8'), +# logging.StreamHandler() +# ], +# force=True # This will reset the root logger's handlers and apply the new configuration +# ) -logger = logging.getLogger(__name__) -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.INFO) + + +if MINIMUM_LOG_LEVEL in ["DEBUG", "TRACE", "INFO", "WARNING", "ERROR", "CRITICAL"]: + logger.remove() + logger.add(sys.stderr, level=MINIMUM_LOG_LEVEL) +else: + logger.warning(f"Invalid log level: {MINIMUM_LOG_LEVEL}. Defaulting to DEBUG.") + logger.remove() + logger.add(sys.stderr, level="DEBUG") chromeProfilePath = os.path.join(os.getcwd(), "chrome_profile", "linkedin_profile") - def ensure_chrome_profile(): - logger.debug("Ensuring Chrome profile exists at path: %s", chromeProfilePath) + logger.debug(f"Ensuring Chrome profile exists at path: {chromeProfilePath}") profile_dir = os.path.dirname(chromeProfilePath) if not os.path.exists(profile_dir): os.makedirs(profile_dir) - logger.debug("Created directory for Chrome profile: %s", profile_dir) + logger.debug(f"Created directory for Chrome profile: {profile_dir}") if not os.path.exists(chromeProfilePath): os.makedirs(chromeProfilePath) - logger.debug("Created Chrome profile directory: %s", chromeProfilePath) + logger.debug(f"Created Chrome profile directory: {chromeProfilePath}") return chromeProfilePath @@ -43,13 +50,12 @@ def is_scrollable(element): scroll_height = element.get_attribute("scrollHeight") client_height = element.get_attribute("clientHeight") scrollable = int(scroll_height) > int(client_height) - logger.debug("Element scrollable check: scrollHeight=%s, clientHeight=%s, scrollable=%s", scroll_height, - client_height, scrollable) + logger.debug(f"Element scrollable check: scrollHeight={scroll_height}, clientHeight={client_height}, scrollable={scrollable}") return scrollable def scroll_slow(driver, scrollable_element, start=0, end=3600, step=300, reverse=False): - logger.debug("Starting slow scroll: start=%d, end=%d, step=%d, reverse=%s", start, end, step, reverse) + logger.debug(f"Starting slow scroll: start={start}, end={end}, step={step}, reverse={reverse}") if reverse: start, end = end, start @@ -61,18 +67,16 @@ def scroll_slow(driver, scrollable_element, start=0, end=3600, step=300, reverse max_scroll_height = int(scrollable_element.get_attribute("scrollHeight")) current_scroll_position = int(scrollable_element.get_attribute("scrollTop")) - logger.debug("Max scroll height of the element: %d", max_scroll_height) - logger.debug("Current scroll position: %d", current_scroll_position) + logger.debug(f"Max scroll height of the element: {max_scroll_height}") + logger.debug(f"Current scroll position: {current_scroll_position}") if reverse: - if current_scroll_position < start: start = current_scroll_position - logger.debug("Adjusted start position for upward scroll: %d", start) + logger.debug(f"Adjusted start position for upward scroll: {start}") else: - if end > max_scroll_height: - logger.warning("End value exceeds the scroll height. Adjusting end to %d", max_scroll_height) + logger.warning(f"End value exceeds the scroll height. Adjusting end to {max_scroll_height}") end = max_scroll_height script_scroll_to = "arguments[0].scrollTop = arguments[1];" @@ -81,12 +85,10 @@ def scroll_slow(driver, scrollable_element, start=0, end=3600, step=300, reverse if scrollable_element.is_displayed(): if not is_scrollable(scrollable_element): logger.warning("The element is not scrollable.") - print("The element is not scrollable.") return if (step > 0 and start >= end) or (step < 0 and start <= end): logger.warning("No scrolling will occur due to incorrect start/end values.") - print("No scrolling will occur due to incorrect start/end values.") return position = start @@ -94,15 +96,14 @@ def scroll_slow(driver, scrollable_element, start=0, end=3600, step=300, reverse 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) + logger.debug(f"Stopping scroll as position hasn't changed: {position}") break try: driver.execute_script(script_scroll_to, scrollable_element, position) - logger.debug("Scrolled to position: %d", position) + logger.debug(f"Scrolled to position: {position}") except Exception as e: - logger.error("Error during scrolling: %s", e) - print(f"Error during scrolling: {e}") + logger.error(f"Error during scrolling: {e}") previous_position = position position += step @@ -114,14 +115,12 @@ def scroll_slow(driver, scrollable_element, start=0, end=3600, step=300, reverse # Ensure the final scroll position is correct driver.execute_script(script_scroll_to, scrollable_element, end) - logger.debug("Scrolled to final position: %d", end) + logger.debug(f"Scrolled to final position: {end}") time.sleep(0.5) else: logger.warning("The element is not visible.") - print("The element is not visible.") except Exception as e: - logger.error("Exception occurred during scrolling: %s", e) - print(f"Exception occurred: {e}") + logger.error(f"Exception occurred during scrolling: {e}") def chrome_browser_options(): @@ -159,7 +158,7 @@ def chrome_browser_options(): profile_dir = os.path.basename(chromeProfilePath) options.add_argument('--user-data-dir=' + initial_path) options.add_argument("--profile-directory=" + profile_dir) - logger.debug("Using Chrome profile directory: %s", chromeProfilePath) + logger.debug(f"Using Chrome profile directory: {chromeProfilePath}") else: options.add_argument("--incognito") logger.debug("Using Chrome in incognito mode") @@ -179,8 +178,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] diff --git a/tests/test_linkedIn_job_manager.py b/tests/test_linkedIn_job_manager.py index 0b4121e..d66449b 100644 --- a/tests/test_linkedIn_job_manager.py +++ b/tests/test_linkedIn_job_manager.py @@ -5,6 +5,7 @@ import os import pytest from src.linkedIn_job_manager import LinkedInJobManager from selenium.common.exceptions import NoSuchElementException +from loguru import logger @pytest.fixture @@ -52,8 +53,7 @@ def test_set_parameters(mocker, job_manager): def next_job_page(self, position, location, job_page): - logger.debug("Navigating to next job page: %s in %s, page %d", - position, location, job_page) + logger.debug(f"Navigating to next job page: {position} in {location}, page {job_page}") self.driver.get( f"https://www.linkedin.com/jobs/search/{self.base_search_url}&keywords={position}&location={location}&start={job_page * 25}") From 671f4604652609e8a6c84b72f05ae348494b9f26 Mon Sep 17 00:00:00 2001 From: Thomas Hayner Date: Thu, 12 Sep 2024 21:41:36 -0600 Subject: [PATCH 79/97] sync fork with origin/main --- virtual/bin/Activate.ps1 | 247 +++++++++++++++++++++++++++++++++++ virtual/bin/activate | 70 ++++++++++ virtual/bin/activate.csh | 27 ++++ virtual/bin/activate.fish | 69 ++++++++++ virtual/bin/chardetect | 8 ++ virtual/bin/distro | 8 ++ virtual/bin/dotenv | 8 ++ virtual/bin/email_validator | 8 ++ virtual/bin/f2py | 8 ++ virtual/bin/httpx | 8 ++ virtual/bin/jsondiff | 41 ++++++ virtual/bin/jsonpatch | 107 +++++++++++++++ virtual/bin/jsonpointer | 67 ++++++++++ virtual/bin/langchain-server | 8 ++ virtual/bin/langsmith | 8 ++ virtual/bin/normalizer | 8 ++ virtual/bin/openai | 8 ++ virtual/bin/pip | 8 ++ virtual/bin/pip3 | 8 ++ virtual/bin/pip3.12 | 8 ++ virtual/bin/python | 1 + virtual/bin/python3 | 1 + virtual/bin/python3.12 | 1 + virtual/bin/tqdm | 8 ++ virtual/pyvenv.cfg | 5 + 25 files changed, 748 insertions(+) create mode 100644 virtual/bin/Activate.ps1 create mode 100644 virtual/bin/activate create mode 100644 virtual/bin/activate.csh create mode 100644 virtual/bin/activate.fish create mode 100755 virtual/bin/chardetect create mode 100755 virtual/bin/distro create mode 100755 virtual/bin/dotenv create mode 100755 virtual/bin/email_validator create mode 100755 virtual/bin/f2py create mode 100755 virtual/bin/httpx create mode 100755 virtual/bin/jsondiff create mode 100755 virtual/bin/jsonpatch create mode 100755 virtual/bin/jsonpointer create mode 100755 virtual/bin/langchain-server create mode 100755 virtual/bin/langsmith create mode 100755 virtual/bin/normalizer create mode 100755 virtual/bin/openai create mode 100755 virtual/bin/pip create mode 100755 virtual/bin/pip3 create mode 100755 virtual/bin/pip3.12 create mode 120000 virtual/bin/python create mode 120000 virtual/bin/python3 create mode 120000 virtual/bin/python3.12 create mode 100755 virtual/bin/tqdm create mode 100644 virtual/pyvenv.cfg diff --git a/virtual/bin/Activate.ps1 b/virtual/bin/Activate.ps1 new file mode 100644 index 0000000..b49d77b --- /dev/null +++ b/virtual/bin/Activate.ps1 @@ -0,0 +1,247 @@ +<# +.Synopsis +Activate a Python virtual environment for the current PowerShell session. + +.Description +Pushes the python executable for a virtual environment to the front of the +$Env:PATH environment variable and sets the prompt to signify that you are +in a Python virtual environment. Makes use of the command line switches as +well as the `pyvenv.cfg` file values present in the virtual environment. + +.Parameter VenvDir +Path to the directory that contains the virtual environment to activate. The +default value for this is the parent of the directory that the Activate.ps1 +script is located within. + +.Parameter Prompt +The prompt prefix to display when this virtual environment is activated. By +default, this prompt is the name of the virtual environment folder (VenvDir) +surrounded by parentheses and followed by a single space (ie. '(.venv) '). + +.Example +Activate.ps1 +Activates the Python virtual environment that contains the Activate.ps1 script. + +.Example +Activate.ps1 -Verbose +Activates the Python virtual environment that contains the Activate.ps1 script, +and shows extra information about the activation as it executes. + +.Example +Activate.ps1 -VenvDir C:\Users\MyUser\Common\.venv +Activates the Python virtual environment located in the specified location. + +.Example +Activate.ps1 -Prompt "MyPython" +Activates the Python virtual environment that contains the Activate.ps1 script, +and prefixes the current prompt with the specified string (surrounded in +parentheses) while the virtual environment is active. + +.Notes +On Windows, it may be required to enable this Activate.ps1 script by setting the +execution policy for the user. You can do this by issuing the following PowerShell +command: + +PS C:\> Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser + +For more information on Execution Policies: +https://go.microsoft.com/fwlink/?LinkID=135170 + +#> +Param( + [Parameter(Mandatory = $false)] + [String] + $VenvDir, + [Parameter(Mandatory = $false)] + [String] + $Prompt +) + +<# Function declarations --------------------------------------------------- #> + +<# +.Synopsis +Remove all shell session elements added by the Activate script, including the +addition of the virtual environment's Python executable from the beginning of +the PATH variable. + +.Parameter NonDestructive +If present, do not remove this function from the global namespace for the +session. + +#> +function global:deactivate ([switch]$NonDestructive) { + # Revert to original values + + # The prior prompt: + if (Test-Path -Path Function:_OLD_VIRTUAL_PROMPT) { + Copy-Item -Path Function:_OLD_VIRTUAL_PROMPT -Destination Function:prompt + Remove-Item -Path Function:_OLD_VIRTUAL_PROMPT + } + + # The prior PYTHONHOME: + if (Test-Path -Path Env:_OLD_VIRTUAL_PYTHONHOME) { + Copy-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME -Destination Env:PYTHONHOME + Remove-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME + } + + # The prior PATH: + if (Test-Path -Path Env:_OLD_VIRTUAL_PATH) { + Copy-Item -Path Env:_OLD_VIRTUAL_PATH -Destination Env:PATH + Remove-Item -Path Env:_OLD_VIRTUAL_PATH + } + + # Just remove the VIRTUAL_ENV altogether: + if (Test-Path -Path Env:VIRTUAL_ENV) { + Remove-Item -Path env:VIRTUAL_ENV + } + + # Just remove VIRTUAL_ENV_PROMPT altogether. + if (Test-Path -Path Env:VIRTUAL_ENV_PROMPT) { + Remove-Item -Path env:VIRTUAL_ENV_PROMPT + } + + # Just remove the _PYTHON_VENV_PROMPT_PREFIX altogether: + if (Get-Variable -Name "_PYTHON_VENV_PROMPT_PREFIX" -ErrorAction SilentlyContinue) { + Remove-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Scope Global -Force + } + + # Leave deactivate function in the global namespace if requested: + if (-not $NonDestructive) { + Remove-Item -Path function:deactivate + } +} + +<# +.Description +Get-PyVenvConfig parses the values from the pyvenv.cfg file located in the +given folder, and returns them in a map. + +For each line in the pyvenv.cfg file, if that line can be parsed into exactly +two strings separated by `=` (with any amount of whitespace surrounding the =) +then it is considered a `key = value` line. The left hand string is the key, +the right hand is the value. + +If the value starts with a `'` or a `"` then the first and last character is +stripped from the value before being captured. + +.Parameter ConfigDir +Path to the directory that contains the `pyvenv.cfg` file. +#> +function Get-PyVenvConfig( + [String] + $ConfigDir +) { + Write-Verbose "Given ConfigDir=$ConfigDir, obtain values in pyvenv.cfg" + + # Ensure the file exists, and issue a warning if it doesn't (but still allow the function to continue). + $pyvenvConfigPath = Join-Path -Resolve -Path $ConfigDir -ChildPath 'pyvenv.cfg' -ErrorAction Continue + + # An empty map will be returned if no config file is found. + $pyvenvConfig = @{ } + + if ($pyvenvConfigPath) { + + Write-Verbose "File exists, parse `key = value` lines" + $pyvenvConfigContent = Get-Content -Path $pyvenvConfigPath + + $pyvenvConfigContent | ForEach-Object { + $keyval = $PSItem -split "\s*=\s*", 2 + if ($keyval[0] -and $keyval[1]) { + $val = $keyval[1] + + # Remove extraneous quotations around a string value. + if ("'""".Contains($val.Substring(0, 1))) { + $val = $val.Substring(1, $val.Length - 2) + } + + $pyvenvConfig[$keyval[0]] = $val + Write-Verbose "Adding Key: '$($keyval[0])'='$val'" + } + } + } + return $pyvenvConfig +} + + +<# Begin Activate script --------------------------------------------------- #> + +# Determine the containing directory of this script +$VenvExecPath = Split-Path -Parent $MyInvocation.MyCommand.Definition +$VenvExecDir = Get-Item -Path $VenvExecPath + +Write-Verbose "Activation script is located in path: '$VenvExecPath'" +Write-Verbose "VenvExecDir Fullname: '$($VenvExecDir.FullName)" +Write-Verbose "VenvExecDir Name: '$($VenvExecDir.Name)" + +# Set values required in priority: CmdLine, ConfigFile, Default +# First, get the location of the virtual environment, it might not be +# VenvExecDir if specified on the command line. +if ($VenvDir) { + Write-Verbose "VenvDir given as parameter, using '$VenvDir' to determine values" +} +else { + Write-Verbose "VenvDir not given as a parameter, using parent directory name as VenvDir." + $VenvDir = $VenvExecDir.Parent.FullName.TrimEnd("\\/") + Write-Verbose "VenvDir=$VenvDir" +} + +# Next, read the `pyvenv.cfg` file to determine any required value such +# as `prompt`. +$pyvenvCfg = Get-PyVenvConfig -ConfigDir $VenvDir + +# Next, set the prompt from the command line, or the config file, or +# just use the name of the virtual environment folder. +if ($Prompt) { + Write-Verbose "Prompt specified as argument, using '$Prompt'" +} +else { + Write-Verbose "Prompt not specified as argument to script, checking pyvenv.cfg value" + if ($pyvenvCfg -and $pyvenvCfg['prompt']) { + Write-Verbose " Setting based on value in pyvenv.cfg='$($pyvenvCfg['prompt'])'" + $Prompt = $pyvenvCfg['prompt']; + } + else { + Write-Verbose " Setting prompt based on parent's directory's name. (Is the directory name passed to venv module when creating the virtual environment)" + Write-Verbose " Got leaf-name of $VenvDir='$(Split-Path -Path $venvDir -Leaf)'" + $Prompt = Split-Path -Path $venvDir -Leaf + } +} + +Write-Verbose "Prompt = '$Prompt'" +Write-Verbose "VenvDir='$VenvDir'" + +# Deactivate any currently active virtual environment, but leave the +# deactivate function in place. +deactivate -nondestructive + +# Now set the environment variable VIRTUAL_ENV, used by many tools to determine +# that there is an activated venv. +$env:VIRTUAL_ENV = $VenvDir + +if (-not $Env:VIRTUAL_ENV_DISABLE_PROMPT) { + + Write-Verbose "Setting prompt to '$Prompt'" + + # Set the prompt to include the env name + # Make sure _OLD_VIRTUAL_PROMPT is global + function global:_OLD_VIRTUAL_PROMPT { "" } + Copy-Item -Path function:prompt -Destination function:_OLD_VIRTUAL_PROMPT + New-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Description "Python virtual environment prompt prefix" -Scope Global -Option ReadOnly -Visibility Public -Value $Prompt + + function global:prompt { + Write-Host -NoNewline -ForegroundColor Green "($_PYTHON_VENV_PROMPT_PREFIX) " + _OLD_VIRTUAL_PROMPT + } + $env:VIRTUAL_ENV_PROMPT = $Prompt +} + +# Clear PYTHONHOME +if (Test-Path -Path Env:PYTHONHOME) { + Copy-Item -Path Env:PYTHONHOME -Destination Env:_OLD_VIRTUAL_PYTHONHOME + Remove-Item -Path Env:PYTHONHOME +} + +# Add the venv to the PATH +Copy-Item -Path Env:PATH -Destination Env:_OLD_VIRTUAL_PATH +$Env:PATH = "$VenvExecDir$([System.IO.Path]::PathSeparator)$Env:PATH" diff --git a/virtual/bin/activate b/virtual/bin/activate new file mode 100644 index 0000000..cb8602a --- /dev/null +++ b/virtual/bin/activate @@ -0,0 +1,70 @@ +# This file must be used with "source bin/activate" *from bash* +# You cannot run it directly + +deactivate () { + # reset old environment variables + if [ -n "${_OLD_VIRTUAL_PATH:-}" ] ; then + PATH="${_OLD_VIRTUAL_PATH:-}" + export PATH + unset _OLD_VIRTUAL_PATH + fi + if [ -n "${_OLD_VIRTUAL_PYTHONHOME:-}" ] ; then + PYTHONHOME="${_OLD_VIRTUAL_PYTHONHOME:-}" + export PYTHONHOME + unset _OLD_VIRTUAL_PYTHONHOME + fi + + # Call hash to forget past commands. Without forgetting + # past commands the $PATH changes we made may not be respected + hash -r 2> /dev/null + + if [ -n "${_OLD_VIRTUAL_PS1:-}" ] ; then + PS1="${_OLD_VIRTUAL_PS1:-}" + export PS1 + unset _OLD_VIRTUAL_PS1 + fi + + unset VIRTUAL_ENV + unset VIRTUAL_ENV_PROMPT + if [ ! "${1:-}" = "nondestructive" ] ; then + # Self destruct! + unset -f deactivate + fi +} + +# unset irrelevant variables +deactivate nondestructive + +# on Windows, a path can contain colons and backslashes and has to be converted: +if [ "${OSTYPE:-}" = "cygwin" ] || [ "${OSTYPE:-}" = "msys" ] ; then + # transform D:\path\to\venv to /d/path/to/venv on MSYS + # and to /cygdrive/d/path/to/venv on Cygwin + export VIRTUAL_ENV=$(cygpath "/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual") +else + # use the path as-is + export VIRTUAL_ENV="/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual" +fi + +_OLD_VIRTUAL_PATH="$PATH" +PATH="$VIRTUAL_ENV/bin:$PATH" +export PATH + +# unset PYTHONHOME if set +# this will fail if PYTHONHOME is set to the empty string (which is bad anyway) +# could use `if (set -u; : $PYTHONHOME) ;` in bash +if [ -n "${PYTHONHOME:-}" ] ; then + _OLD_VIRTUAL_PYTHONHOME="${PYTHONHOME:-}" + unset PYTHONHOME +fi + +if [ -z "${VIRTUAL_ENV_DISABLE_PROMPT:-}" ] ; then + _OLD_VIRTUAL_PS1="${PS1:-}" + PS1="(virtual) ${PS1:-}" + export PS1 + VIRTUAL_ENV_PROMPT="(virtual) " + export VIRTUAL_ENV_PROMPT +fi + +# Call hash to forget past commands. Without forgetting +# past commands the $PATH changes we made may not be respected +hash -r 2> /dev/null diff --git a/virtual/bin/activate.csh b/virtual/bin/activate.csh new file mode 100644 index 0000000..91bc4cb --- /dev/null +++ b/virtual/bin/activate.csh @@ -0,0 +1,27 @@ +# This file must be used with "source bin/activate.csh" *from csh*. +# You cannot run it directly. + +# Created by Davide Di Blasi . +# Ported to Python 3.3 venv by Andrew Svetlov + +alias deactivate 'test $?_OLD_VIRTUAL_PATH != 0 && setenv PATH "$_OLD_VIRTUAL_PATH" && unset _OLD_VIRTUAL_PATH; rehash; test $?_OLD_VIRTUAL_PROMPT != 0 && set prompt="$_OLD_VIRTUAL_PROMPT" && unset _OLD_VIRTUAL_PROMPT; unsetenv VIRTUAL_ENV; unsetenv VIRTUAL_ENV_PROMPT; test "\!:*" != "nondestructive" && unalias deactivate' + +# Unset irrelevant variables. +deactivate nondestructive + +setenv VIRTUAL_ENV "/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual" + +set _OLD_VIRTUAL_PATH="$PATH" +setenv PATH "$VIRTUAL_ENV/bin:$PATH" + + +set _OLD_VIRTUAL_PROMPT="$prompt" + +if (! "$?VIRTUAL_ENV_DISABLE_PROMPT") then + set prompt = "(virtual) $prompt" + setenv VIRTUAL_ENV_PROMPT "(virtual) " +endif + +alias pydoc python -m pydoc + +rehash diff --git a/virtual/bin/activate.fish b/virtual/bin/activate.fish new file mode 100644 index 0000000..3ad20c8 --- /dev/null +++ b/virtual/bin/activate.fish @@ -0,0 +1,69 @@ +# This file must be used with "source /bin/activate.fish" *from fish* +# (https://fishshell.com/). You cannot run it directly. + +function deactivate -d "Exit virtual environment and return to normal shell environment" + # reset old environment variables + if test -n "$_OLD_VIRTUAL_PATH" + set -gx PATH $_OLD_VIRTUAL_PATH + set -e _OLD_VIRTUAL_PATH + end + if test -n "$_OLD_VIRTUAL_PYTHONHOME" + set -gx PYTHONHOME $_OLD_VIRTUAL_PYTHONHOME + set -e _OLD_VIRTUAL_PYTHONHOME + end + + if test -n "$_OLD_FISH_PROMPT_OVERRIDE" + set -e _OLD_FISH_PROMPT_OVERRIDE + # prevents error when using nested fish instances (Issue #93858) + if functions -q _old_fish_prompt + functions -e fish_prompt + functions -c _old_fish_prompt fish_prompt + functions -e _old_fish_prompt + end + end + + set -e VIRTUAL_ENV + set -e VIRTUAL_ENV_PROMPT + if test "$argv[1]" != "nondestructive" + # Self-destruct! + functions -e deactivate + end +end + +# Unset irrelevant variables. +deactivate nondestructive + +set -gx VIRTUAL_ENV "/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual" + +set -gx _OLD_VIRTUAL_PATH $PATH +set -gx PATH "$VIRTUAL_ENV/bin" $PATH + +# Unset PYTHONHOME if set. +if set -q PYTHONHOME + set -gx _OLD_VIRTUAL_PYTHONHOME $PYTHONHOME + set -e PYTHONHOME +end + +if test -z "$VIRTUAL_ENV_DISABLE_PROMPT" + # fish uses a function instead of an env var to generate the prompt. + + # Save the current fish_prompt function as the function _old_fish_prompt. + functions -c fish_prompt _old_fish_prompt + + # With the original prompt function renamed, we can override with our own. + function fish_prompt + # Save the return status of the last command. + set -l old_status $status + + # Output the venv prompt; color taken from the blue of the Python logo. + printf "%s%s%s" (set_color 4B8BBE) "(virtual) " (set_color normal) + + # Restore the return status of the previous command. + echo "exit $old_status" | . + # Output the original/"old" prompt. + _old_fish_prompt + end + + set -gx _OLD_FISH_PROMPT_OVERRIDE "$VIRTUAL_ENV" + set -gx VIRTUAL_ENV_PROMPT "(virtual) " +end diff --git a/virtual/bin/chardetect b/virtual/bin/chardetect new file mode 100755 index 0000000..297c455 --- /dev/null +++ b/virtual/bin/chardetect @@ -0,0 +1,8 @@ +#!/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual/bin/python3.12 +# -*- coding: utf-8 -*- +import re +import sys +from chardet.cli.chardetect import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/virtual/bin/distro b/virtual/bin/distro new file mode 100755 index 0000000..d012564 --- /dev/null +++ b/virtual/bin/distro @@ -0,0 +1,8 @@ +#!/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual/bin/python3.12 +# -*- coding: utf-8 -*- +import re +import sys +from distro.distro import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/virtual/bin/dotenv b/virtual/bin/dotenv new file mode 100755 index 0000000..d7254e2 --- /dev/null +++ b/virtual/bin/dotenv @@ -0,0 +1,8 @@ +#!/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual/bin/python3.12 +# -*- coding: utf-8 -*- +import re +import sys +from dotenv.__main__ import cli +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(cli()) diff --git a/virtual/bin/email_validator b/virtual/bin/email_validator new file mode 100755 index 0000000..bd2b2ac --- /dev/null +++ b/virtual/bin/email_validator @@ -0,0 +1,8 @@ +#!/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual/bin/python3.12 +# -*- coding: utf-8 -*- +import re +import sys +from email_validator.__main__ import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/virtual/bin/f2py b/virtual/bin/f2py new file mode 100755 index 0000000..49fa48b --- /dev/null +++ b/virtual/bin/f2py @@ -0,0 +1,8 @@ +#!/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual/bin/python3.12 +# -*- coding: utf-8 -*- +import re +import sys +from numpy.f2py.f2py2e import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/virtual/bin/httpx b/virtual/bin/httpx new file mode 100755 index 0000000..c27a73f --- /dev/null +++ b/virtual/bin/httpx @@ -0,0 +1,8 @@ +#!/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual/bin/python3.12 +# -*- coding: utf-8 -*- +import re +import sys +from httpx import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/virtual/bin/jsondiff b/virtual/bin/jsondiff new file mode 100755 index 0000000..cc91661 --- /dev/null +++ b/virtual/bin/jsondiff @@ -0,0 +1,41 @@ +#!/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual/bin/python3.12 +# -*- coding: utf-8 -*- + +from __future__ import print_function + +import sys +import json +import jsonpatch +import argparse + + +parser = argparse.ArgumentParser(description='Diff two JSON files') +parser.add_argument('FILE1', type=argparse.FileType('r')) +parser.add_argument('FILE2', type=argparse.FileType('r')) +parser.add_argument('--indent', type=int, default=None, + help='Indent output by n spaces') +parser.add_argument('-u', '--preserve-unicode', action='store_true', + help='Output Unicode character as-is without using Code Point') +parser.add_argument('-v', '--version', action='version', + version='%(prog)s ' + jsonpatch.__version__) + + +def main(): + try: + diff_files() + except KeyboardInterrupt: + sys.exit(1) + + +def diff_files(): + """ Diffs two JSON files and prints a patch """ + args = parser.parse_args() + doc1 = json.load(args.FILE1) + doc2 = json.load(args.FILE2) + patch = jsonpatch.make_patch(doc1, doc2) + if patch.patch: + print(json.dumps(patch.patch, indent=args.indent, ensure_ascii=not(args.preserve_unicode))) + sys.exit(1) + +if __name__ == "__main__": + main() diff --git a/virtual/bin/jsonpatch b/virtual/bin/jsonpatch new file mode 100755 index 0000000..b4883a6 --- /dev/null +++ b/virtual/bin/jsonpatch @@ -0,0 +1,107 @@ +#!/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual/bin/python3.12 +# -*- coding: utf-8 -*- + +import sys +import os.path +import json +import jsonpatch +import tempfile +import argparse + + +parser = argparse.ArgumentParser( + description='Apply a JSON patch on a JSON file') +parser.add_argument('ORIGINAL', type=argparse.FileType('r'), + help='Original file') +parser.add_argument('PATCH', type=argparse.FileType('r'), + nargs='?', default=sys.stdin, + help='Patch file (read from stdin if omitted)') +parser.add_argument('--indent', type=int, default=None, + help='Indent output by n spaces') +parser.add_argument('-b', '--backup', action='store_true', + help='Back up ORIGINAL if modifying in-place') +parser.add_argument('-i', '--in-place', action='store_true', + help='Modify ORIGINAL in-place instead of to stdout') +parser.add_argument('-v', '--version', action='version', + version='%(prog)s ' + jsonpatch.__version__) +parser.add_argument('-u', '--preserve-unicode', action='store_true', + help='Output Unicode character as-is without using Code Point') + +def main(): + try: + patch_files() + except KeyboardInterrupt: + sys.exit(1) + + +def patch_files(): + """ Diffs two JSON files and prints a patch """ + args = parser.parse_args() + doc = json.load(args.ORIGINAL) + patch = json.load(args.PATCH) + result = jsonpatch.apply_patch(doc, patch) + + if args.in_place: + dirname = os.path.abspath(os.path.dirname(args.ORIGINAL.name)) + + try: + # Attempt to replace the file atomically. We do this by + # creating a temporary file in the same directory as the + # original file so we can atomically move the new file over + # the original later. (This is done in the same directory + # because atomic renames do not work across mount points.) + + fd, pathname = tempfile.mkstemp(dir=dirname) + fp = os.fdopen(fd, 'w') + atomic = True + + except OSError: + # We failed to create the temporary file for an atomic + # replace, so fall back to non-atomic mode by backing up + # the original (if desired) and writing a new file. + + if args.backup: + os.rename(args.ORIGINAL.name, args.ORIGINAL.name + '.orig') + fp = open(args.ORIGINAL.name, 'w') + atomic = False + + else: + # Since we're not replacing the original file in-place, write + # the modified JSON to stdout instead. + + fp = sys.stdout + + # By this point we have some sort of file object we can write the + # modified JSON to. + + json.dump(result, fp, indent=args.indent, ensure_ascii=not(args.preserve_unicode)) + fp.write('\n') + + if args.in_place: + # Close the new file. If we aren't replacing atomically, this + # is our last step, since everything else is already in place. + + fp.close() + + if atomic: + try: + # Complete the atomic replace by linking the original + # to a backup (if desired), fixing up the permissions + # on the temporary file, and moving it into place. + + if args.backup: + os.link(args.ORIGINAL.name, args.ORIGINAL.name + '.orig') + os.chmod(pathname, os.stat(args.ORIGINAL.name).st_mode) + os.rename(pathname, args.ORIGINAL.name) + + except OSError: + # In the event we could not actually do the atomic + # replace, unlink the original to move it out of the + # way and finally move the temporary file into place. + + os.unlink(args.ORIGINAL.name) + os.rename(pathname, args.ORIGINAL.name) + + +if __name__ == "__main__": + main() diff --git a/virtual/bin/jsonpointer b/virtual/bin/jsonpointer new file mode 100755 index 0000000..c0f532a --- /dev/null +++ b/virtual/bin/jsonpointer @@ -0,0 +1,67 @@ +#!/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual/bin/python3.12 +# -*- coding: utf-8 -*- + + +import argparse +import json +import sys + +import jsonpointer + +parser = argparse.ArgumentParser( + description='Resolve a JSON pointer on JSON files') + +# Accept pointer as argument or as file +ptr_group = parser.add_mutually_exclusive_group(required=True) + +ptr_group.add_argument('-f', '--pointer-file', type=argparse.FileType('r'), + nargs='?', + help='File containing a JSON pointer expression') + +ptr_group.add_argument('POINTER', type=str, nargs='?', + help='A JSON pointer expression') + +parser.add_argument('FILE', type=argparse.FileType('r'), nargs='+', + help='Files for which the pointer should be resolved') +parser.add_argument('--indent', type=int, default=None, + help='Indent output by n spaces') +parser.add_argument('-v', '--version', action='version', + version='%(prog)s ' + jsonpointer.__version__) + + +def main(): + try: + resolve_files() + except KeyboardInterrupt: + sys.exit(1) + + +def parse_pointer(args): + if args.POINTER: + ptr = args.POINTER + elif args.pointer_file: + ptr = args.pointer_file.read().strip() + else: + parser.print_usage() + sys.exit(1) + + return ptr + + +def resolve_files(): + """ Resolve a JSON pointer on JSON files """ + args = parser.parse_args() + + ptr = parse_pointer(args) + + for f in args.FILE: + doc = json.load(f) + try: + result = jsonpointer.resolve_pointer(doc, ptr) + print(json.dumps(result, indent=args.indent)) + except jsonpointer.JsonPointerException as e: + print('Could not resolve pointer: %s' % str(e), file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/virtual/bin/langchain-server b/virtual/bin/langchain-server new file mode 100755 index 0000000..5a3ad02 --- /dev/null +++ b/virtual/bin/langchain-server @@ -0,0 +1,8 @@ +#!/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual/bin/python3.12 +# -*- coding: utf-8 -*- +import re +import sys +from langchain.server import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/virtual/bin/langsmith b/virtual/bin/langsmith new file mode 100755 index 0000000..65e31b1 --- /dev/null +++ b/virtual/bin/langsmith @@ -0,0 +1,8 @@ +#!/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual/bin/python3.12 +# -*- coding: utf-8 -*- +import re +import sys +from langsmith.cli.main import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/virtual/bin/normalizer b/virtual/bin/normalizer new file mode 100755 index 0000000..3f47584 --- /dev/null +++ b/virtual/bin/normalizer @@ -0,0 +1,8 @@ +#!/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual/bin/python3.12 +# -*- coding: utf-8 -*- +import re +import sys +from charset_normalizer.cli import cli_detect +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(cli_detect()) diff --git a/virtual/bin/openai b/virtual/bin/openai new file mode 100755 index 0000000..3d595a3 --- /dev/null +++ b/virtual/bin/openai @@ -0,0 +1,8 @@ +#!/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual/bin/python3.12 +# -*- coding: utf-8 -*- +import re +import sys +from openai.cli import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/virtual/bin/pip b/virtual/bin/pip new file mode 100755 index 0000000..c5b8677 --- /dev/null +++ b/virtual/bin/pip @@ -0,0 +1,8 @@ +#!/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual/bin/python3.12 +# -*- coding: utf-8 -*- +import re +import sys +from pip._internal.cli.main import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/virtual/bin/pip3 b/virtual/bin/pip3 new file mode 100755 index 0000000..c5b8677 --- /dev/null +++ b/virtual/bin/pip3 @@ -0,0 +1,8 @@ +#!/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual/bin/python3.12 +# -*- coding: utf-8 -*- +import re +import sys +from pip._internal.cli.main import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/virtual/bin/pip3.12 b/virtual/bin/pip3.12 new file mode 100755 index 0000000..c5b8677 --- /dev/null +++ b/virtual/bin/pip3.12 @@ -0,0 +1,8 @@ +#!/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual/bin/python3.12 +# -*- coding: utf-8 -*- +import re +import sys +from pip._internal.cli.main import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/virtual/bin/python b/virtual/bin/python new file mode 120000 index 0000000..11b9d88 --- /dev/null +++ b/virtual/bin/python @@ -0,0 +1 @@ +python3.12 \ No newline at end of file diff --git a/virtual/bin/python3 b/virtual/bin/python3 new file mode 120000 index 0000000..11b9d88 --- /dev/null +++ b/virtual/bin/python3 @@ -0,0 +1 @@ +python3.12 \ No newline at end of file diff --git a/virtual/bin/python3.12 b/virtual/bin/python3.12 new file mode 120000 index 0000000..a3f0508 --- /dev/null +++ b/virtual/bin/python3.12 @@ -0,0 +1 @@ +/opt/homebrew/opt/python@3.12/bin/python3.12 \ No newline at end of file diff --git a/virtual/bin/tqdm b/virtual/bin/tqdm new file mode 100755 index 0000000..a4f79ac --- /dev/null +++ b/virtual/bin/tqdm @@ -0,0 +1,8 @@ +#!/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual/bin/python3.12 +# -*- coding: utf-8 -*- +import re +import sys +from tqdm.cli import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/virtual/pyvenv.cfg b/virtual/pyvenv.cfg new file mode 100644 index 0000000..fccf188 --- /dev/null +++ b/virtual/pyvenv.cfg @@ -0,0 +1,5 @@ +home = /opt/homebrew/opt/python@3.12/bin +include-system-site-packages = false +version = 3.12.6 +executable = /opt/homebrew/Cellar/python@3.12/3.12.6/Frameworks/Python.framework/Versions/3.12/bin/python3.12 +command = /opt/homebrew/opt/python@3.12/bin/python3.12 -m venv /Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual From 02f8f73c8d79224a627673d85c46d74ed8cca54f Mon Sep 17 00:00:00 2001 From: Thomas <49766988+thomHayner@users.noreply.github.com> Date: Fri, 13 Sep 2024 00:04:19 -0600 Subject: [PATCH 80/97] Delete virtual directory --- virtual/bin/Activate.ps1 | 247 ----------------------------------- virtual/bin/activate | 70 ---------- virtual/bin/activate.csh | 27 ---- virtual/bin/activate.fish | 69 ---------- virtual/bin/chardetect | 8 -- virtual/bin/distro | 8 -- virtual/bin/dotenv | 8 -- virtual/bin/email_validator | 8 -- virtual/bin/f2py | 8 -- virtual/bin/httpx | 8 -- virtual/bin/jsondiff | 41 ------ virtual/bin/jsonpatch | 107 --------------- virtual/bin/jsonpointer | 67 ---------- virtual/bin/langchain-server | 8 -- virtual/bin/langsmith | 8 -- virtual/bin/normalizer | 8 -- virtual/bin/openai | 8 -- virtual/bin/pip | 8 -- virtual/bin/pip3 | 8 -- virtual/bin/pip3.12 | 8 -- virtual/bin/python | 1 - virtual/bin/python3 | 1 - virtual/bin/python3.12 | 1 - virtual/bin/tqdm | 8 -- virtual/pyvenv.cfg | 5 - 25 files changed, 748 deletions(-) delete mode 100644 virtual/bin/Activate.ps1 delete mode 100644 virtual/bin/activate delete mode 100644 virtual/bin/activate.csh delete mode 100644 virtual/bin/activate.fish delete mode 100755 virtual/bin/chardetect delete mode 100755 virtual/bin/distro delete mode 100755 virtual/bin/dotenv delete mode 100755 virtual/bin/email_validator delete mode 100755 virtual/bin/f2py delete mode 100755 virtual/bin/httpx delete mode 100755 virtual/bin/jsondiff delete mode 100755 virtual/bin/jsonpatch delete mode 100755 virtual/bin/jsonpointer delete mode 100755 virtual/bin/langchain-server delete mode 100755 virtual/bin/langsmith delete mode 100755 virtual/bin/normalizer delete mode 100755 virtual/bin/openai delete mode 100755 virtual/bin/pip delete mode 100755 virtual/bin/pip3 delete mode 100755 virtual/bin/pip3.12 delete mode 120000 virtual/bin/python delete mode 120000 virtual/bin/python3 delete mode 120000 virtual/bin/python3.12 delete mode 100755 virtual/bin/tqdm delete mode 100644 virtual/pyvenv.cfg diff --git a/virtual/bin/Activate.ps1 b/virtual/bin/Activate.ps1 deleted file mode 100644 index b49d77b..0000000 --- a/virtual/bin/Activate.ps1 +++ /dev/null @@ -1,247 +0,0 @@ -<# -.Synopsis -Activate a Python virtual environment for the current PowerShell session. - -.Description -Pushes the python executable for a virtual environment to the front of the -$Env:PATH environment variable and sets the prompt to signify that you are -in a Python virtual environment. Makes use of the command line switches as -well as the `pyvenv.cfg` file values present in the virtual environment. - -.Parameter VenvDir -Path to the directory that contains the virtual environment to activate. The -default value for this is the parent of the directory that the Activate.ps1 -script is located within. - -.Parameter Prompt -The prompt prefix to display when this virtual environment is activated. By -default, this prompt is the name of the virtual environment folder (VenvDir) -surrounded by parentheses and followed by a single space (ie. '(.venv) '). - -.Example -Activate.ps1 -Activates the Python virtual environment that contains the Activate.ps1 script. - -.Example -Activate.ps1 -Verbose -Activates the Python virtual environment that contains the Activate.ps1 script, -and shows extra information about the activation as it executes. - -.Example -Activate.ps1 -VenvDir C:\Users\MyUser\Common\.venv -Activates the Python virtual environment located in the specified location. - -.Example -Activate.ps1 -Prompt "MyPython" -Activates the Python virtual environment that contains the Activate.ps1 script, -and prefixes the current prompt with the specified string (surrounded in -parentheses) while the virtual environment is active. - -.Notes -On Windows, it may be required to enable this Activate.ps1 script by setting the -execution policy for the user. You can do this by issuing the following PowerShell -command: - -PS C:\> Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser - -For more information on Execution Policies: -https://go.microsoft.com/fwlink/?LinkID=135170 - -#> -Param( - [Parameter(Mandatory = $false)] - [String] - $VenvDir, - [Parameter(Mandatory = $false)] - [String] - $Prompt -) - -<# Function declarations --------------------------------------------------- #> - -<# -.Synopsis -Remove all shell session elements added by the Activate script, including the -addition of the virtual environment's Python executable from the beginning of -the PATH variable. - -.Parameter NonDestructive -If present, do not remove this function from the global namespace for the -session. - -#> -function global:deactivate ([switch]$NonDestructive) { - # Revert to original values - - # The prior prompt: - if (Test-Path -Path Function:_OLD_VIRTUAL_PROMPT) { - Copy-Item -Path Function:_OLD_VIRTUAL_PROMPT -Destination Function:prompt - Remove-Item -Path Function:_OLD_VIRTUAL_PROMPT - } - - # The prior PYTHONHOME: - if (Test-Path -Path Env:_OLD_VIRTUAL_PYTHONHOME) { - Copy-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME -Destination Env:PYTHONHOME - Remove-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME - } - - # The prior PATH: - if (Test-Path -Path Env:_OLD_VIRTUAL_PATH) { - Copy-Item -Path Env:_OLD_VIRTUAL_PATH -Destination Env:PATH - Remove-Item -Path Env:_OLD_VIRTUAL_PATH - } - - # Just remove the VIRTUAL_ENV altogether: - if (Test-Path -Path Env:VIRTUAL_ENV) { - Remove-Item -Path env:VIRTUAL_ENV - } - - # Just remove VIRTUAL_ENV_PROMPT altogether. - if (Test-Path -Path Env:VIRTUAL_ENV_PROMPT) { - Remove-Item -Path env:VIRTUAL_ENV_PROMPT - } - - # Just remove the _PYTHON_VENV_PROMPT_PREFIX altogether: - if (Get-Variable -Name "_PYTHON_VENV_PROMPT_PREFIX" -ErrorAction SilentlyContinue) { - Remove-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Scope Global -Force - } - - # Leave deactivate function in the global namespace if requested: - if (-not $NonDestructive) { - Remove-Item -Path function:deactivate - } -} - -<# -.Description -Get-PyVenvConfig parses the values from the pyvenv.cfg file located in the -given folder, and returns them in a map. - -For each line in the pyvenv.cfg file, if that line can be parsed into exactly -two strings separated by `=` (with any amount of whitespace surrounding the =) -then it is considered a `key = value` line. The left hand string is the key, -the right hand is the value. - -If the value starts with a `'` or a `"` then the first and last character is -stripped from the value before being captured. - -.Parameter ConfigDir -Path to the directory that contains the `pyvenv.cfg` file. -#> -function Get-PyVenvConfig( - [String] - $ConfigDir -) { - Write-Verbose "Given ConfigDir=$ConfigDir, obtain values in pyvenv.cfg" - - # Ensure the file exists, and issue a warning if it doesn't (but still allow the function to continue). - $pyvenvConfigPath = Join-Path -Resolve -Path $ConfigDir -ChildPath 'pyvenv.cfg' -ErrorAction Continue - - # An empty map will be returned if no config file is found. - $pyvenvConfig = @{ } - - if ($pyvenvConfigPath) { - - Write-Verbose "File exists, parse `key = value` lines" - $pyvenvConfigContent = Get-Content -Path $pyvenvConfigPath - - $pyvenvConfigContent | ForEach-Object { - $keyval = $PSItem -split "\s*=\s*", 2 - if ($keyval[0] -and $keyval[1]) { - $val = $keyval[1] - - # Remove extraneous quotations around a string value. - if ("'""".Contains($val.Substring(0, 1))) { - $val = $val.Substring(1, $val.Length - 2) - } - - $pyvenvConfig[$keyval[0]] = $val - Write-Verbose "Adding Key: '$($keyval[0])'='$val'" - } - } - } - return $pyvenvConfig -} - - -<# Begin Activate script --------------------------------------------------- #> - -# Determine the containing directory of this script -$VenvExecPath = Split-Path -Parent $MyInvocation.MyCommand.Definition -$VenvExecDir = Get-Item -Path $VenvExecPath - -Write-Verbose "Activation script is located in path: '$VenvExecPath'" -Write-Verbose "VenvExecDir Fullname: '$($VenvExecDir.FullName)" -Write-Verbose "VenvExecDir Name: '$($VenvExecDir.Name)" - -# Set values required in priority: CmdLine, ConfigFile, Default -# First, get the location of the virtual environment, it might not be -# VenvExecDir if specified on the command line. -if ($VenvDir) { - Write-Verbose "VenvDir given as parameter, using '$VenvDir' to determine values" -} -else { - Write-Verbose "VenvDir not given as a parameter, using parent directory name as VenvDir." - $VenvDir = $VenvExecDir.Parent.FullName.TrimEnd("\\/") - Write-Verbose "VenvDir=$VenvDir" -} - -# Next, read the `pyvenv.cfg` file to determine any required value such -# as `prompt`. -$pyvenvCfg = Get-PyVenvConfig -ConfigDir $VenvDir - -# Next, set the prompt from the command line, or the config file, or -# just use the name of the virtual environment folder. -if ($Prompt) { - Write-Verbose "Prompt specified as argument, using '$Prompt'" -} -else { - Write-Verbose "Prompt not specified as argument to script, checking pyvenv.cfg value" - if ($pyvenvCfg -and $pyvenvCfg['prompt']) { - Write-Verbose " Setting based on value in pyvenv.cfg='$($pyvenvCfg['prompt'])'" - $Prompt = $pyvenvCfg['prompt']; - } - else { - Write-Verbose " Setting prompt based on parent's directory's name. (Is the directory name passed to venv module when creating the virtual environment)" - Write-Verbose " Got leaf-name of $VenvDir='$(Split-Path -Path $venvDir -Leaf)'" - $Prompt = Split-Path -Path $venvDir -Leaf - } -} - -Write-Verbose "Prompt = '$Prompt'" -Write-Verbose "VenvDir='$VenvDir'" - -# Deactivate any currently active virtual environment, but leave the -# deactivate function in place. -deactivate -nondestructive - -# Now set the environment variable VIRTUAL_ENV, used by many tools to determine -# that there is an activated venv. -$env:VIRTUAL_ENV = $VenvDir - -if (-not $Env:VIRTUAL_ENV_DISABLE_PROMPT) { - - Write-Verbose "Setting prompt to '$Prompt'" - - # Set the prompt to include the env name - # Make sure _OLD_VIRTUAL_PROMPT is global - function global:_OLD_VIRTUAL_PROMPT { "" } - Copy-Item -Path function:prompt -Destination function:_OLD_VIRTUAL_PROMPT - New-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Description "Python virtual environment prompt prefix" -Scope Global -Option ReadOnly -Visibility Public -Value $Prompt - - function global:prompt { - Write-Host -NoNewline -ForegroundColor Green "($_PYTHON_VENV_PROMPT_PREFIX) " - _OLD_VIRTUAL_PROMPT - } - $env:VIRTUAL_ENV_PROMPT = $Prompt -} - -# Clear PYTHONHOME -if (Test-Path -Path Env:PYTHONHOME) { - Copy-Item -Path Env:PYTHONHOME -Destination Env:_OLD_VIRTUAL_PYTHONHOME - Remove-Item -Path Env:PYTHONHOME -} - -# Add the venv to the PATH -Copy-Item -Path Env:PATH -Destination Env:_OLD_VIRTUAL_PATH -$Env:PATH = "$VenvExecDir$([System.IO.Path]::PathSeparator)$Env:PATH" diff --git a/virtual/bin/activate b/virtual/bin/activate deleted file mode 100644 index cb8602a..0000000 --- a/virtual/bin/activate +++ /dev/null @@ -1,70 +0,0 @@ -# This file must be used with "source bin/activate" *from bash* -# You cannot run it directly - -deactivate () { - # reset old environment variables - if [ -n "${_OLD_VIRTUAL_PATH:-}" ] ; then - PATH="${_OLD_VIRTUAL_PATH:-}" - export PATH - unset _OLD_VIRTUAL_PATH - fi - if [ -n "${_OLD_VIRTUAL_PYTHONHOME:-}" ] ; then - PYTHONHOME="${_OLD_VIRTUAL_PYTHONHOME:-}" - export PYTHONHOME - unset _OLD_VIRTUAL_PYTHONHOME - fi - - # Call hash to forget past commands. Without forgetting - # past commands the $PATH changes we made may not be respected - hash -r 2> /dev/null - - if [ -n "${_OLD_VIRTUAL_PS1:-}" ] ; then - PS1="${_OLD_VIRTUAL_PS1:-}" - export PS1 - unset _OLD_VIRTUAL_PS1 - fi - - unset VIRTUAL_ENV - unset VIRTUAL_ENV_PROMPT - if [ ! "${1:-}" = "nondestructive" ] ; then - # Self destruct! - unset -f deactivate - fi -} - -# unset irrelevant variables -deactivate nondestructive - -# on Windows, a path can contain colons and backslashes and has to be converted: -if [ "${OSTYPE:-}" = "cygwin" ] || [ "${OSTYPE:-}" = "msys" ] ; then - # transform D:\path\to\venv to /d/path/to/venv on MSYS - # and to /cygdrive/d/path/to/venv on Cygwin - export VIRTUAL_ENV=$(cygpath "/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual") -else - # use the path as-is - export VIRTUAL_ENV="/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual" -fi - -_OLD_VIRTUAL_PATH="$PATH" -PATH="$VIRTUAL_ENV/bin:$PATH" -export PATH - -# unset PYTHONHOME if set -# this will fail if PYTHONHOME is set to the empty string (which is bad anyway) -# could use `if (set -u; : $PYTHONHOME) ;` in bash -if [ -n "${PYTHONHOME:-}" ] ; then - _OLD_VIRTUAL_PYTHONHOME="${PYTHONHOME:-}" - unset PYTHONHOME -fi - -if [ -z "${VIRTUAL_ENV_DISABLE_PROMPT:-}" ] ; then - _OLD_VIRTUAL_PS1="${PS1:-}" - PS1="(virtual) ${PS1:-}" - export PS1 - VIRTUAL_ENV_PROMPT="(virtual) " - export VIRTUAL_ENV_PROMPT -fi - -# Call hash to forget past commands. Without forgetting -# past commands the $PATH changes we made may not be respected -hash -r 2> /dev/null diff --git a/virtual/bin/activate.csh b/virtual/bin/activate.csh deleted file mode 100644 index 91bc4cb..0000000 --- a/virtual/bin/activate.csh +++ /dev/null @@ -1,27 +0,0 @@ -# This file must be used with "source bin/activate.csh" *from csh*. -# You cannot run it directly. - -# Created by Davide Di Blasi . -# Ported to Python 3.3 venv by Andrew Svetlov - -alias deactivate 'test $?_OLD_VIRTUAL_PATH != 0 && setenv PATH "$_OLD_VIRTUAL_PATH" && unset _OLD_VIRTUAL_PATH; rehash; test $?_OLD_VIRTUAL_PROMPT != 0 && set prompt="$_OLD_VIRTUAL_PROMPT" && unset _OLD_VIRTUAL_PROMPT; unsetenv VIRTUAL_ENV; unsetenv VIRTUAL_ENV_PROMPT; test "\!:*" != "nondestructive" && unalias deactivate' - -# Unset irrelevant variables. -deactivate nondestructive - -setenv VIRTUAL_ENV "/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual" - -set _OLD_VIRTUAL_PATH="$PATH" -setenv PATH "$VIRTUAL_ENV/bin:$PATH" - - -set _OLD_VIRTUAL_PROMPT="$prompt" - -if (! "$?VIRTUAL_ENV_DISABLE_PROMPT") then - set prompt = "(virtual) $prompt" - setenv VIRTUAL_ENV_PROMPT "(virtual) " -endif - -alias pydoc python -m pydoc - -rehash diff --git a/virtual/bin/activate.fish b/virtual/bin/activate.fish deleted file mode 100644 index 3ad20c8..0000000 --- a/virtual/bin/activate.fish +++ /dev/null @@ -1,69 +0,0 @@ -# This file must be used with "source /bin/activate.fish" *from fish* -# (https://fishshell.com/). You cannot run it directly. - -function deactivate -d "Exit virtual environment and return to normal shell environment" - # reset old environment variables - if test -n "$_OLD_VIRTUAL_PATH" - set -gx PATH $_OLD_VIRTUAL_PATH - set -e _OLD_VIRTUAL_PATH - end - if test -n "$_OLD_VIRTUAL_PYTHONHOME" - set -gx PYTHONHOME $_OLD_VIRTUAL_PYTHONHOME - set -e _OLD_VIRTUAL_PYTHONHOME - end - - if test -n "$_OLD_FISH_PROMPT_OVERRIDE" - set -e _OLD_FISH_PROMPT_OVERRIDE - # prevents error when using nested fish instances (Issue #93858) - if functions -q _old_fish_prompt - functions -e fish_prompt - functions -c _old_fish_prompt fish_prompt - functions -e _old_fish_prompt - end - end - - set -e VIRTUAL_ENV - set -e VIRTUAL_ENV_PROMPT - if test "$argv[1]" != "nondestructive" - # Self-destruct! - functions -e deactivate - end -end - -# Unset irrelevant variables. -deactivate nondestructive - -set -gx VIRTUAL_ENV "/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual" - -set -gx _OLD_VIRTUAL_PATH $PATH -set -gx PATH "$VIRTUAL_ENV/bin" $PATH - -# Unset PYTHONHOME if set. -if set -q PYTHONHOME - set -gx _OLD_VIRTUAL_PYTHONHOME $PYTHONHOME - set -e PYTHONHOME -end - -if test -z "$VIRTUAL_ENV_DISABLE_PROMPT" - # fish uses a function instead of an env var to generate the prompt. - - # Save the current fish_prompt function as the function _old_fish_prompt. - functions -c fish_prompt _old_fish_prompt - - # With the original prompt function renamed, we can override with our own. - function fish_prompt - # Save the return status of the last command. - set -l old_status $status - - # Output the venv prompt; color taken from the blue of the Python logo. - printf "%s%s%s" (set_color 4B8BBE) "(virtual) " (set_color normal) - - # Restore the return status of the previous command. - echo "exit $old_status" | . - # Output the original/"old" prompt. - _old_fish_prompt - end - - set -gx _OLD_FISH_PROMPT_OVERRIDE "$VIRTUAL_ENV" - set -gx VIRTUAL_ENV_PROMPT "(virtual) " -end diff --git a/virtual/bin/chardetect b/virtual/bin/chardetect deleted file mode 100755 index 297c455..0000000 --- a/virtual/bin/chardetect +++ /dev/null @@ -1,8 +0,0 @@ -#!/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual/bin/python3.12 -# -*- coding: utf-8 -*- -import re -import sys -from chardet.cli.chardetect import main -if __name__ == '__main__': - sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) - sys.exit(main()) diff --git a/virtual/bin/distro b/virtual/bin/distro deleted file mode 100755 index d012564..0000000 --- a/virtual/bin/distro +++ /dev/null @@ -1,8 +0,0 @@ -#!/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual/bin/python3.12 -# -*- coding: utf-8 -*- -import re -import sys -from distro.distro import main -if __name__ == '__main__': - sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) - sys.exit(main()) diff --git a/virtual/bin/dotenv b/virtual/bin/dotenv deleted file mode 100755 index d7254e2..0000000 --- a/virtual/bin/dotenv +++ /dev/null @@ -1,8 +0,0 @@ -#!/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual/bin/python3.12 -# -*- coding: utf-8 -*- -import re -import sys -from dotenv.__main__ import cli -if __name__ == '__main__': - sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) - sys.exit(cli()) diff --git a/virtual/bin/email_validator b/virtual/bin/email_validator deleted file mode 100755 index bd2b2ac..0000000 --- a/virtual/bin/email_validator +++ /dev/null @@ -1,8 +0,0 @@ -#!/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual/bin/python3.12 -# -*- coding: utf-8 -*- -import re -import sys -from email_validator.__main__ import main -if __name__ == '__main__': - sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) - sys.exit(main()) diff --git a/virtual/bin/f2py b/virtual/bin/f2py deleted file mode 100755 index 49fa48b..0000000 --- a/virtual/bin/f2py +++ /dev/null @@ -1,8 +0,0 @@ -#!/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual/bin/python3.12 -# -*- coding: utf-8 -*- -import re -import sys -from numpy.f2py.f2py2e import main -if __name__ == '__main__': - sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) - sys.exit(main()) diff --git a/virtual/bin/httpx b/virtual/bin/httpx deleted file mode 100755 index c27a73f..0000000 --- a/virtual/bin/httpx +++ /dev/null @@ -1,8 +0,0 @@ -#!/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual/bin/python3.12 -# -*- coding: utf-8 -*- -import re -import sys -from httpx import main -if __name__ == '__main__': - sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) - sys.exit(main()) diff --git a/virtual/bin/jsondiff b/virtual/bin/jsondiff deleted file mode 100755 index cc91661..0000000 --- a/virtual/bin/jsondiff +++ /dev/null @@ -1,41 +0,0 @@ -#!/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual/bin/python3.12 -# -*- coding: utf-8 -*- - -from __future__ import print_function - -import sys -import json -import jsonpatch -import argparse - - -parser = argparse.ArgumentParser(description='Diff two JSON files') -parser.add_argument('FILE1', type=argparse.FileType('r')) -parser.add_argument('FILE2', type=argparse.FileType('r')) -parser.add_argument('--indent', type=int, default=None, - help='Indent output by n spaces') -parser.add_argument('-u', '--preserve-unicode', action='store_true', - help='Output Unicode character as-is without using Code Point') -parser.add_argument('-v', '--version', action='version', - version='%(prog)s ' + jsonpatch.__version__) - - -def main(): - try: - diff_files() - except KeyboardInterrupt: - sys.exit(1) - - -def diff_files(): - """ Diffs two JSON files and prints a patch """ - args = parser.parse_args() - doc1 = json.load(args.FILE1) - doc2 = json.load(args.FILE2) - patch = jsonpatch.make_patch(doc1, doc2) - if patch.patch: - print(json.dumps(patch.patch, indent=args.indent, ensure_ascii=not(args.preserve_unicode))) - sys.exit(1) - -if __name__ == "__main__": - main() diff --git a/virtual/bin/jsonpatch b/virtual/bin/jsonpatch deleted file mode 100755 index b4883a6..0000000 --- a/virtual/bin/jsonpatch +++ /dev/null @@ -1,107 +0,0 @@ -#!/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual/bin/python3.12 -# -*- coding: utf-8 -*- - -import sys -import os.path -import json -import jsonpatch -import tempfile -import argparse - - -parser = argparse.ArgumentParser( - description='Apply a JSON patch on a JSON file') -parser.add_argument('ORIGINAL', type=argparse.FileType('r'), - help='Original file') -parser.add_argument('PATCH', type=argparse.FileType('r'), - nargs='?', default=sys.stdin, - help='Patch file (read from stdin if omitted)') -parser.add_argument('--indent', type=int, default=None, - help='Indent output by n spaces') -parser.add_argument('-b', '--backup', action='store_true', - help='Back up ORIGINAL if modifying in-place') -parser.add_argument('-i', '--in-place', action='store_true', - help='Modify ORIGINAL in-place instead of to stdout') -parser.add_argument('-v', '--version', action='version', - version='%(prog)s ' + jsonpatch.__version__) -parser.add_argument('-u', '--preserve-unicode', action='store_true', - help='Output Unicode character as-is without using Code Point') - -def main(): - try: - patch_files() - except KeyboardInterrupt: - sys.exit(1) - - -def patch_files(): - """ Diffs two JSON files and prints a patch """ - args = parser.parse_args() - doc = json.load(args.ORIGINAL) - patch = json.load(args.PATCH) - result = jsonpatch.apply_patch(doc, patch) - - if args.in_place: - dirname = os.path.abspath(os.path.dirname(args.ORIGINAL.name)) - - try: - # Attempt to replace the file atomically. We do this by - # creating a temporary file in the same directory as the - # original file so we can atomically move the new file over - # the original later. (This is done in the same directory - # because atomic renames do not work across mount points.) - - fd, pathname = tempfile.mkstemp(dir=dirname) - fp = os.fdopen(fd, 'w') - atomic = True - - except OSError: - # We failed to create the temporary file for an atomic - # replace, so fall back to non-atomic mode by backing up - # the original (if desired) and writing a new file. - - if args.backup: - os.rename(args.ORIGINAL.name, args.ORIGINAL.name + '.orig') - fp = open(args.ORIGINAL.name, 'w') - atomic = False - - else: - # Since we're not replacing the original file in-place, write - # the modified JSON to stdout instead. - - fp = sys.stdout - - # By this point we have some sort of file object we can write the - # modified JSON to. - - json.dump(result, fp, indent=args.indent, ensure_ascii=not(args.preserve_unicode)) - fp.write('\n') - - if args.in_place: - # Close the new file. If we aren't replacing atomically, this - # is our last step, since everything else is already in place. - - fp.close() - - if atomic: - try: - # Complete the atomic replace by linking the original - # to a backup (if desired), fixing up the permissions - # on the temporary file, and moving it into place. - - if args.backup: - os.link(args.ORIGINAL.name, args.ORIGINAL.name + '.orig') - os.chmod(pathname, os.stat(args.ORIGINAL.name).st_mode) - os.rename(pathname, args.ORIGINAL.name) - - except OSError: - # In the event we could not actually do the atomic - # replace, unlink the original to move it out of the - # way and finally move the temporary file into place. - - os.unlink(args.ORIGINAL.name) - os.rename(pathname, args.ORIGINAL.name) - - -if __name__ == "__main__": - main() diff --git a/virtual/bin/jsonpointer b/virtual/bin/jsonpointer deleted file mode 100755 index c0f532a..0000000 --- a/virtual/bin/jsonpointer +++ /dev/null @@ -1,67 +0,0 @@ -#!/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual/bin/python3.12 -# -*- coding: utf-8 -*- - - -import argparse -import json -import sys - -import jsonpointer - -parser = argparse.ArgumentParser( - description='Resolve a JSON pointer on JSON files') - -# Accept pointer as argument or as file -ptr_group = parser.add_mutually_exclusive_group(required=True) - -ptr_group.add_argument('-f', '--pointer-file', type=argparse.FileType('r'), - nargs='?', - help='File containing a JSON pointer expression') - -ptr_group.add_argument('POINTER', type=str, nargs='?', - help='A JSON pointer expression') - -parser.add_argument('FILE', type=argparse.FileType('r'), nargs='+', - help='Files for which the pointer should be resolved') -parser.add_argument('--indent', type=int, default=None, - help='Indent output by n spaces') -parser.add_argument('-v', '--version', action='version', - version='%(prog)s ' + jsonpointer.__version__) - - -def main(): - try: - resolve_files() - except KeyboardInterrupt: - sys.exit(1) - - -def parse_pointer(args): - if args.POINTER: - ptr = args.POINTER - elif args.pointer_file: - ptr = args.pointer_file.read().strip() - else: - parser.print_usage() - sys.exit(1) - - return ptr - - -def resolve_files(): - """ Resolve a JSON pointer on JSON files """ - args = parser.parse_args() - - ptr = parse_pointer(args) - - for f in args.FILE: - doc = json.load(f) - try: - result = jsonpointer.resolve_pointer(doc, ptr) - print(json.dumps(result, indent=args.indent)) - except jsonpointer.JsonPointerException as e: - print('Could not resolve pointer: %s' % str(e), file=sys.stderr) - - -if __name__ == "__main__": - main() diff --git a/virtual/bin/langchain-server b/virtual/bin/langchain-server deleted file mode 100755 index 5a3ad02..0000000 --- a/virtual/bin/langchain-server +++ /dev/null @@ -1,8 +0,0 @@ -#!/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual/bin/python3.12 -# -*- coding: utf-8 -*- -import re -import sys -from langchain.server import main -if __name__ == '__main__': - sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) - sys.exit(main()) diff --git a/virtual/bin/langsmith b/virtual/bin/langsmith deleted file mode 100755 index 65e31b1..0000000 --- a/virtual/bin/langsmith +++ /dev/null @@ -1,8 +0,0 @@ -#!/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual/bin/python3.12 -# -*- coding: utf-8 -*- -import re -import sys -from langsmith.cli.main import main -if __name__ == '__main__': - sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) - sys.exit(main()) diff --git a/virtual/bin/normalizer b/virtual/bin/normalizer deleted file mode 100755 index 3f47584..0000000 --- a/virtual/bin/normalizer +++ /dev/null @@ -1,8 +0,0 @@ -#!/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual/bin/python3.12 -# -*- coding: utf-8 -*- -import re -import sys -from charset_normalizer.cli import cli_detect -if __name__ == '__main__': - sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) - sys.exit(cli_detect()) diff --git a/virtual/bin/openai b/virtual/bin/openai deleted file mode 100755 index 3d595a3..0000000 --- a/virtual/bin/openai +++ /dev/null @@ -1,8 +0,0 @@ -#!/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual/bin/python3.12 -# -*- coding: utf-8 -*- -import re -import sys -from openai.cli import main -if __name__ == '__main__': - sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) - sys.exit(main()) diff --git a/virtual/bin/pip b/virtual/bin/pip deleted file mode 100755 index c5b8677..0000000 --- a/virtual/bin/pip +++ /dev/null @@ -1,8 +0,0 @@ -#!/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual/bin/python3.12 -# -*- coding: utf-8 -*- -import re -import sys -from pip._internal.cli.main import main -if __name__ == '__main__': - sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) - sys.exit(main()) diff --git a/virtual/bin/pip3 b/virtual/bin/pip3 deleted file mode 100755 index c5b8677..0000000 --- a/virtual/bin/pip3 +++ /dev/null @@ -1,8 +0,0 @@ -#!/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual/bin/python3.12 -# -*- coding: utf-8 -*- -import re -import sys -from pip._internal.cli.main import main -if __name__ == '__main__': - sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) - sys.exit(main()) diff --git a/virtual/bin/pip3.12 b/virtual/bin/pip3.12 deleted file mode 100755 index c5b8677..0000000 --- a/virtual/bin/pip3.12 +++ /dev/null @@ -1,8 +0,0 @@ -#!/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual/bin/python3.12 -# -*- coding: utf-8 -*- -import re -import sys -from pip._internal.cli.main import main -if __name__ == '__main__': - sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) - sys.exit(main()) diff --git a/virtual/bin/python b/virtual/bin/python deleted file mode 120000 index 11b9d88..0000000 --- a/virtual/bin/python +++ /dev/null @@ -1 +0,0 @@ -python3.12 \ No newline at end of file diff --git a/virtual/bin/python3 b/virtual/bin/python3 deleted file mode 120000 index 11b9d88..0000000 --- a/virtual/bin/python3 +++ /dev/null @@ -1 +0,0 @@ -python3.12 \ No newline at end of file diff --git a/virtual/bin/python3.12 b/virtual/bin/python3.12 deleted file mode 120000 index a3f0508..0000000 --- a/virtual/bin/python3.12 +++ /dev/null @@ -1 +0,0 @@ -/opt/homebrew/opt/python@3.12/bin/python3.12 \ No newline at end of file diff --git a/virtual/bin/tqdm b/virtual/bin/tqdm deleted file mode 100755 index a4f79ac..0000000 --- a/virtual/bin/tqdm +++ /dev/null @@ -1,8 +0,0 @@ -#!/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual/bin/python3.12 -# -*- coding: utf-8 -*- -import re -import sys -from tqdm.cli import main -if __name__ == '__main__': - sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) - sys.exit(main()) diff --git a/virtual/pyvenv.cfg b/virtual/pyvenv.cfg deleted file mode 100644 index fccf188..0000000 --- a/virtual/pyvenv.cfg +++ /dev/null @@ -1,5 +0,0 @@ -home = /opt/homebrew/opt/python@3.12/bin -include-system-site-packages = false -version = 3.12.6 -executable = /opt/homebrew/Cellar/python@3.12/3.12.6/Frameworks/Python.framework/Versions/3.12/bin/python3.12 -command = /opt/homebrew/opt/python@3.12/bin/python3.12 -m venv /Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual From ff1c99821df0a7895f5d04e3186e597440e28a68 Mon Sep 17 00:00:00 2001 From: Thomas <49766988+thomHayner@users.noreply.github.com> Date: Fri, 13 Sep 2024 00:06:55 -0600 Subject: [PATCH 81/97] Update .gitignore add: virtual/* .DS_Store --- .gitignore | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index c5c01ec..35f5936 100644 --- a/.gitignore +++ b/.gitignore @@ -152,4 +152,8 @@ mono_crash.* data_folder/output/* generated_cv/* chrome_profile/* -answers.json \ No newline at end of file +virtual/* +answers.json + +# MacOS +.DS_Store From 1e5c5cc6bef37b9eedfc24eefec95f0409eff319 Mon Sep 17 00:00:00 2001 From: Thomas Hayner Date: Fri, 13 Sep 2024 00:27:06 -0600 Subject: [PATCH 82/97] Revert "Delete virtual directory" This reverts commit 02f8f73c8d79224a627673d85c46d74ed8cca54f. --- virtual/bin/Activate.ps1 | 247 +++++++++++++++++++++++++++++++++++ virtual/bin/activate | 70 ++++++++++ virtual/bin/activate.csh | 27 ++++ virtual/bin/activate.fish | 69 ++++++++++ virtual/bin/chardetect | 8 ++ virtual/bin/distro | 8 ++ virtual/bin/dotenv | 8 ++ virtual/bin/email_validator | 8 ++ virtual/bin/f2py | 8 ++ virtual/bin/httpx | 8 ++ virtual/bin/jsondiff | 41 ++++++ virtual/bin/jsonpatch | 107 +++++++++++++++ virtual/bin/jsonpointer | 67 ++++++++++ virtual/bin/langchain-server | 8 ++ virtual/bin/langsmith | 8 ++ virtual/bin/normalizer | 8 ++ virtual/bin/openai | 8 ++ virtual/bin/pip | 8 ++ virtual/bin/pip3 | 8 ++ virtual/bin/pip3.12 | 8 ++ virtual/bin/python | 1 + virtual/bin/python3 | 1 + virtual/bin/python3.12 | 1 + virtual/bin/tqdm | 8 ++ virtual/pyvenv.cfg | 5 + 25 files changed, 748 insertions(+) create mode 100644 virtual/bin/Activate.ps1 create mode 100644 virtual/bin/activate create mode 100644 virtual/bin/activate.csh create mode 100644 virtual/bin/activate.fish create mode 100755 virtual/bin/chardetect create mode 100755 virtual/bin/distro create mode 100755 virtual/bin/dotenv create mode 100755 virtual/bin/email_validator create mode 100755 virtual/bin/f2py create mode 100755 virtual/bin/httpx create mode 100755 virtual/bin/jsondiff create mode 100755 virtual/bin/jsonpatch create mode 100755 virtual/bin/jsonpointer create mode 100755 virtual/bin/langchain-server create mode 100755 virtual/bin/langsmith create mode 100755 virtual/bin/normalizer create mode 100755 virtual/bin/openai create mode 100755 virtual/bin/pip create mode 100755 virtual/bin/pip3 create mode 100755 virtual/bin/pip3.12 create mode 120000 virtual/bin/python create mode 120000 virtual/bin/python3 create mode 120000 virtual/bin/python3.12 create mode 100755 virtual/bin/tqdm create mode 100644 virtual/pyvenv.cfg diff --git a/virtual/bin/Activate.ps1 b/virtual/bin/Activate.ps1 new file mode 100644 index 0000000..b49d77b --- /dev/null +++ b/virtual/bin/Activate.ps1 @@ -0,0 +1,247 @@ +<# +.Synopsis +Activate a Python virtual environment for the current PowerShell session. + +.Description +Pushes the python executable for a virtual environment to the front of the +$Env:PATH environment variable and sets the prompt to signify that you are +in a Python virtual environment. Makes use of the command line switches as +well as the `pyvenv.cfg` file values present in the virtual environment. + +.Parameter VenvDir +Path to the directory that contains the virtual environment to activate. The +default value for this is the parent of the directory that the Activate.ps1 +script is located within. + +.Parameter Prompt +The prompt prefix to display when this virtual environment is activated. By +default, this prompt is the name of the virtual environment folder (VenvDir) +surrounded by parentheses and followed by a single space (ie. '(.venv) '). + +.Example +Activate.ps1 +Activates the Python virtual environment that contains the Activate.ps1 script. + +.Example +Activate.ps1 -Verbose +Activates the Python virtual environment that contains the Activate.ps1 script, +and shows extra information about the activation as it executes. + +.Example +Activate.ps1 -VenvDir C:\Users\MyUser\Common\.venv +Activates the Python virtual environment located in the specified location. + +.Example +Activate.ps1 -Prompt "MyPython" +Activates the Python virtual environment that contains the Activate.ps1 script, +and prefixes the current prompt with the specified string (surrounded in +parentheses) while the virtual environment is active. + +.Notes +On Windows, it may be required to enable this Activate.ps1 script by setting the +execution policy for the user. You can do this by issuing the following PowerShell +command: + +PS C:\> Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser + +For more information on Execution Policies: +https://go.microsoft.com/fwlink/?LinkID=135170 + +#> +Param( + [Parameter(Mandatory = $false)] + [String] + $VenvDir, + [Parameter(Mandatory = $false)] + [String] + $Prompt +) + +<# Function declarations --------------------------------------------------- #> + +<# +.Synopsis +Remove all shell session elements added by the Activate script, including the +addition of the virtual environment's Python executable from the beginning of +the PATH variable. + +.Parameter NonDestructive +If present, do not remove this function from the global namespace for the +session. + +#> +function global:deactivate ([switch]$NonDestructive) { + # Revert to original values + + # The prior prompt: + if (Test-Path -Path Function:_OLD_VIRTUAL_PROMPT) { + Copy-Item -Path Function:_OLD_VIRTUAL_PROMPT -Destination Function:prompt + Remove-Item -Path Function:_OLD_VIRTUAL_PROMPT + } + + # The prior PYTHONHOME: + if (Test-Path -Path Env:_OLD_VIRTUAL_PYTHONHOME) { + Copy-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME -Destination Env:PYTHONHOME + Remove-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME + } + + # The prior PATH: + if (Test-Path -Path Env:_OLD_VIRTUAL_PATH) { + Copy-Item -Path Env:_OLD_VIRTUAL_PATH -Destination Env:PATH + Remove-Item -Path Env:_OLD_VIRTUAL_PATH + } + + # Just remove the VIRTUAL_ENV altogether: + if (Test-Path -Path Env:VIRTUAL_ENV) { + Remove-Item -Path env:VIRTUAL_ENV + } + + # Just remove VIRTUAL_ENV_PROMPT altogether. + if (Test-Path -Path Env:VIRTUAL_ENV_PROMPT) { + Remove-Item -Path env:VIRTUAL_ENV_PROMPT + } + + # Just remove the _PYTHON_VENV_PROMPT_PREFIX altogether: + if (Get-Variable -Name "_PYTHON_VENV_PROMPT_PREFIX" -ErrorAction SilentlyContinue) { + Remove-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Scope Global -Force + } + + # Leave deactivate function in the global namespace if requested: + if (-not $NonDestructive) { + Remove-Item -Path function:deactivate + } +} + +<# +.Description +Get-PyVenvConfig parses the values from the pyvenv.cfg file located in the +given folder, and returns them in a map. + +For each line in the pyvenv.cfg file, if that line can be parsed into exactly +two strings separated by `=` (with any amount of whitespace surrounding the =) +then it is considered a `key = value` line. The left hand string is the key, +the right hand is the value. + +If the value starts with a `'` or a `"` then the first and last character is +stripped from the value before being captured. + +.Parameter ConfigDir +Path to the directory that contains the `pyvenv.cfg` file. +#> +function Get-PyVenvConfig( + [String] + $ConfigDir +) { + Write-Verbose "Given ConfigDir=$ConfigDir, obtain values in pyvenv.cfg" + + # Ensure the file exists, and issue a warning if it doesn't (but still allow the function to continue). + $pyvenvConfigPath = Join-Path -Resolve -Path $ConfigDir -ChildPath 'pyvenv.cfg' -ErrorAction Continue + + # An empty map will be returned if no config file is found. + $pyvenvConfig = @{ } + + if ($pyvenvConfigPath) { + + Write-Verbose "File exists, parse `key = value` lines" + $pyvenvConfigContent = Get-Content -Path $pyvenvConfigPath + + $pyvenvConfigContent | ForEach-Object { + $keyval = $PSItem -split "\s*=\s*", 2 + if ($keyval[0] -and $keyval[1]) { + $val = $keyval[1] + + # Remove extraneous quotations around a string value. + if ("'""".Contains($val.Substring(0, 1))) { + $val = $val.Substring(1, $val.Length - 2) + } + + $pyvenvConfig[$keyval[0]] = $val + Write-Verbose "Adding Key: '$($keyval[0])'='$val'" + } + } + } + return $pyvenvConfig +} + + +<# Begin Activate script --------------------------------------------------- #> + +# Determine the containing directory of this script +$VenvExecPath = Split-Path -Parent $MyInvocation.MyCommand.Definition +$VenvExecDir = Get-Item -Path $VenvExecPath + +Write-Verbose "Activation script is located in path: '$VenvExecPath'" +Write-Verbose "VenvExecDir Fullname: '$($VenvExecDir.FullName)" +Write-Verbose "VenvExecDir Name: '$($VenvExecDir.Name)" + +# Set values required in priority: CmdLine, ConfigFile, Default +# First, get the location of the virtual environment, it might not be +# VenvExecDir if specified on the command line. +if ($VenvDir) { + Write-Verbose "VenvDir given as parameter, using '$VenvDir' to determine values" +} +else { + Write-Verbose "VenvDir not given as a parameter, using parent directory name as VenvDir." + $VenvDir = $VenvExecDir.Parent.FullName.TrimEnd("\\/") + Write-Verbose "VenvDir=$VenvDir" +} + +# Next, read the `pyvenv.cfg` file to determine any required value such +# as `prompt`. +$pyvenvCfg = Get-PyVenvConfig -ConfigDir $VenvDir + +# Next, set the prompt from the command line, or the config file, or +# just use the name of the virtual environment folder. +if ($Prompt) { + Write-Verbose "Prompt specified as argument, using '$Prompt'" +} +else { + Write-Verbose "Prompt not specified as argument to script, checking pyvenv.cfg value" + if ($pyvenvCfg -and $pyvenvCfg['prompt']) { + Write-Verbose " Setting based on value in pyvenv.cfg='$($pyvenvCfg['prompt'])'" + $Prompt = $pyvenvCfg['prompt']; + } + else { + Write-Verbose " Setting prompt based on parent's directory's name. (Is the directory name passed to venv module when creating the virtual environment)" + Write-Verbose " Got leaf-name of $VenvDir='$(Split-Path -Path $venvDir -Leaf)'" + $Prompt = Split-Path -Path $venvDir -Leaf + } +} + +Write-Verbose "Prompt = '$Prompt'" +Write-Verbose "VenvDir='$VenvDir'" + +# Deactivate any currently active virtual environment, but leave the +# deactivate function in place. +deactivate -nondestructive + +# Now set the environment variable VIRTUAL_ENV, used by many tools to determine +# that there is an activated venv. +$env:VIRTUAL_ENV = $VenvDir + +if (-not $Env:VIRTUAL_ENV_DISABLE_PROMPT) { + + Write-Verbose "Setting prompt to '$Prompt'" + + # Set the prompt to include the env name + # Make sure _OLD_VIRTUAL_PROMPT is global + function global:_OLD_VIRTUAL_PROMPT { "" } + Copy-Item -Path function:prompt -Destination function:_OLD_VIRTUAL_PROMPT + New-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Description "Python virtual environment prompt prefix" -Scope Global -Option ReadOnly -Visibility Public -Value $Prompt + + function global:prompt { + Write-Host -NoNewline -ForegroundColor Green "($_PYTHON_VENV_PROMPT_PREFIX) " + _OLD_VIRTUAL_PROMPT + } + $env:VIRTUAL_ENV_PROMPT = $Prompt +} + +# Clear PYTHONHOME +if (Test-Path -Path Env:PYTHONHOME) { + Copy-Item -Path Env:PYTHONHOME -Destination Env:_OLD_VIRTUAL_PYTHONHOME + Remove-Item -Path Env:PYTHONHOME +} + +# Add the venv to the PATH +Copy-Item -Path Env:PATH -Destination Env:_OLD_VIRTUAL_PATH +$Env:PATH = "$VenvExecDir$([System.IO.Path]::PathSeparator)$Env:PATH" diff --git a/virtual/bin/activate b/virtual/bin/activate new file mode 100644 index 0000000..cb8602a --- /dev/null +++ b/virtual/bin/activate @@ -0,0 +1,70 @@ +# This file must be used with "source bin/activate" *from bash* +# You cannot run it directly + +deactivate () { + # reset old environment variables + if [ -n "${_OLD_VIRTUAL_PATH:-}" ] ; then + PATH="${_OLD_VIRTUAL_PATH:-}" + export PATH + unset _OLD_VIRTUAL_PATH + fi + if [ -n "${_OLD_VIRTUAL_PYTHONHOME:-}" ] ; then + PYTHONHOME="${_OLD_VIRTUAL_PYTHONHOME:-}" + export PYTHONHOME + unset _OLD_VIRTUAL_PYTHONHOME + fi + + # Call hash to forget past commands. Without forgetting + # past commands the $PATH changes we made may not be respected + hash -r 2> /dev/null + + if [ -n "${_OLD_VIRTUAL_PS1:-}" ] ; then + PS1="${_OLD_VIRTUAL_PS1:-}" + export PS1 + unset _OLD_VIRTUAL_PS1 + fi + + unset VIRTUAL_ENV + unset VIRTUAL_ENV_PROMPT + if [ ! "${1:-}" = "nondestructive" ] ; then + # Self destruct! + unset -f deactivate + fi +} + +# unset irrelevant variables +deactivate nondestructive + +# on Windows, a path can contain colons and backslashes and has to be converted: +if [ "${OSTYPE:-}" = "cygwin" ] || [ "${OSTYPE:-}" = "msys" ] ; then + # transform D:\path\to\venv to /d/path/to/venv on MSYS + # and to /cygdrive/d/path/to/venv on Cygwin + export VIRTUAL_ENV=$(cygpath "/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual") +else + # use the path as-is + export VIRTUAL_ENV="/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual" +fi + +_OLD_VIRTUAL_PATH="$PATH" +PATH="$VIRTUAL_ENV/bin:$PATH" +export PATH + +# unset PYTHONHOME if set +# this will fail if PYTHONHOME is set to the empty string (which is bad anyway) +# could use `if (set -u; : $PYTHONHOME) ;` in bash +if [ -n "${PYTHONHOME:-}" ] ; then + _OLD_VIRTUAL_PYTHONHOME="${PYTHONHOME:-}" + unset PYTHONHOME +fi + +if [ -z "${VIRTUAL_ENV_DISABLE_PROMPT:-}" ] ; then + _OLD_VIRTUAL_PS1="${PS1:-}" + PS1="(virtual) ${PS1:-}" + export PS1 + VIRTUAL_ENV_PROMPT="(virtual) " + export VIRTUAL_ENV_PROMPT +fi + +# Call hash to forget past commands. Without forgetting +# past commands the $PATH changes we made may not be respected +hash -r 2> /dev/null diff --git a/virtual/bin/activate.csh b/virtual/bin/activate.csh new file mode 100644 index 0000000..91bc4cb --- /dev/null +++ b/virtual/bin/activate.csh @@ -0,0 +1,27 @@ +# This file must be used with "source bin/activate.csh" *from csh*. +# You cannot run it directly. + +# Created by Davide Di Blasi . +# Ported to Python 3.3 venv by Andrew Svetlov + +alias deactivate 'test $?_OLD_VIRTUAL_PATH != 0 && setenv PATH "$_OLD_VIRTUAL_PATH" && unset _OLD_VIRTUAL_PATH; rehash; test $?_OLD_VIRTUAL_PROMPT != 0 && set prompt="$_OLD_VIRTUAL_PROMPT" && unset _OLD_VIRTUAL_PROMPT; unsetenv VIRTUAL_ENV; unsetenv VIRTUAL_ENV_PROMPT; test "\!:*" != "nondestructive" && unalias deactivate' + +# Unset irrelevant variables. +deactivate nondestructive + +setenv VIRTUAL_ENV "/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual" + +set _OLD_VIRTUAL_PATH="$PATH" +setenv PATH "$VIRTUAL_ENV/bin:$PATH" + + +set _OLD_VIRTUAL_PROMPT="$prompt" + +if (! "$?VIRTUAL_ENV_DISABLE_PROMPT") then + set prompt = "(virtual) $prompt" + setenv VIRTUAL_ENV_PROMPT "(virtual) " +endif + +alias pydoc python -m pydoc + +rehash diff --git a/virtual/bin/activate.fish b/virtual/bin/activate.fish new file mode 100644 index 0000000..3ad20c8 --- /dev/null +++ b/virtual/bin/activate.fish @@ -0,0 +1,69 @@ +# This file must be used with "source /bin/activate.fish" *from fish* +# (https://fishshell.com/). You cannot run it directly. + +function deactivate -d "Exit virtual environment and return to normal shell environment" + # reset old environment variables + if test -n "$_OLD_VIRTUAL_PATH" + set -gx PATH $_OLD_VIRTUAL_PATH + set -e _OLD_VIRTUAL_PATH + end + if test -n "$_OLD_VIRTUAL_PYTHONHOME" + set -gx PYTHONHOME $_OLD_VIRTUAL_PYTHONHOME + set -e _OLD_VIRTUAL_PYTHONHOME + end + + if test -n "$_OLD_FISH_PROMPT_OVERRIDE" + set -e _OLD_FISH_PROMPT_OVERRIDE + # prevents error when using nested fish instances (Issue #93858) + if functions -q _old_fish_prompt + functions -e fish_prompt + functions -c _old_fish_prompt fish_prompt + functions -e _old_fish_prompt + end + end + + set -e VIRTUAL_ENV + set -e VIRTUAL_ENV_PROMPT + if test "$argv[1]" != "nondestructive" + # Self-destruct! + functions -e deactivate + end +end + +# Unset irrelevant variables. +deactivate nondestructive + +set -gx VIRTUAL_ENV "/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual" + +set -gx _OLD_VIRTUAL_PATH $PATH +set -gx PATH "$VIRTUAL_ENV/bin" $PATH + +# Unset PYTHONHOME if set. +if set -q PYTHONHOME + set -gx _OLD_VIRTUAL_PYTHONHOME $PYTHONHOME + set -e PYTHONHOME +end + +if test -z "$VIRTUAL_ENV_DISABLE_PROMPT" + # fish uses a function instead of an env var to generate the prompt. + + # Save the current fish_prompt function as the function _old_fish_prompt. + functions -c fish_prompt _old_fish_prompt + + # With the original prompt function renamed, we can override with our own. + function fish_prompt + # Save the return status of the last command. + set -l old_status $status + + # Output the venv prompt; color taken from the blue of the Python logo. + printf "%s%s%s" (set_color 4B8BBE) "(virtual) " (set_color normal) + + # Restore the return status of the previous command. + echo "exit $old_status" | . + # Output the original/"old" prompt. + _old_fish_prompt + end + + set -gx _OLD_FISH_PROMPT_OVERRIDE "$VIRTUAL_ENV" + set -gx VIRTUAL_ENV_PROMPT "(virtual) " +end diff --git a/virtual/bin/chardetect b/virtual/bin/chardetect new file mode 100755 index 0000000..297c455 --- /dev/null +++ b/virtual/bin/chardetect @@ -0,0 +1,8 @@ +#!/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual/bin/python3.12 +# -*- coding: utf-8 -*- +import re +import sys +from chardet.cli.chardetect import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/virtual/bin/distro b/virtual/bin/distro new file mode 100755 index 0000000..d012564 --- /dev/null +++ b/virtual/bin/distro @@ -0,0 +1,8 @@ +#!/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual/bin/python3.12 +# -*- coding: utf-8 -*- +import re +import sys +from distro.distro import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/virtual/bin/dotenv b/virtual/bin/dotenv new file mode 100755 index 0000000..d7254e2 --- /dev/null +++ b/virtual/bin/dotenv @@ -0,0 +1,8 @@ +#!/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual/bin/python3.12 +# -*- coding: utf-8 -*- +import re +import sys +from dotenv.__main__ import cli +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(cli()) diff --git a/virtual/bin/email_validator b/virtual/bin/email_validator new file mode 100755 index 0000000..bd2b2ac --- /dev/null +++ b/virtual/bin/email_validator @@ -0,0 +1,8 @@ +#!/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual/bin/python3.12 +# -*- coding: utf-8 -*- +import re +import sys +from email_validator.__main__ import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/virtual/bin/f2py b/virtual/bin/f2py new file mode 100755 index 0000000..49fa48b --- /dev/null +++ b/virtual/bin/f2py @@ -0,0 +1,8 @@ +#!/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual/bin/python3.12 +# -*- coding: utf-8 -*- +import re +import sys +from numpy.f2py.f2py2e import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/virtual/bin/httpx b/virtual/bin/httpx new file mode 100755 index 0000000..c27a73f --- /dev/null +++ b/virtual/bin/httpx @@ -0,0 +1,8 @@ +#!/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual/bin/python3.12 +# -*- coding: utf-8 -*- +import re +import sys +from httpx import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/virtual/bin/jsondiff b/virtual/bin/jsondiff new file mode 100755 index 0000000..cc91661 --- /dev/null +++ b/virtual/bin/jsondiff @@ -0,0 +1,41 @@ +#!/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual/bin/python3.12 +# -*- coding: utf-8 -*- + +from __future__ import print_function + +import sys +import json +import jsonpatch +import argparse + + +parser = argparse.ArgumentParser(description='Diff two JSON files') +parser.add_argument('FILE1', type=argparse.FileType('r')) +parser.add_argument('FILE2', type=argparse.FileType('r')) +parser.add_argument('--indent', type=int, default=None, + help='Indent output by n spaces') +parser.add_argument('-u', '--preserve-unicode', action='store_true', + help='Output Unicode character as-is without using Code Point') +parser.add_argument('-v', '--version', action='version', + version='%(prog)s ' + jsonpatch.__version__) + + +def main(): + try: + diff_files() + except KeyboardInterrupt: + sys.exit(1) + + +def diff_files(): + """ Diffs two JSON files and prints a patch """ + args = parser.parse_args() + doc1 = json.load(args.FILE1) + doc2 = json.load(args.FILE2) + patch = jsonpatch.make_patch(doc1, doc2) + if patch.patch: + print(json.dumps(patch.patch, indent=args.indent, ensure_ascii=not(args.preserve_unicode))) + sys.exit(1) + +if __name__ == "__main__": + main() diff --git a/virtual/bin/jsonpatch b/virtual/bin/jsonpatch new file mode 100755 index 0000000..b4883a6 --- /dev/null +++ b/virtual/bin/jsonpatch @@ -0,0 +1,107 @@ +#!/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual/bin/python3.12 +# -*- coding: utf-8 -*- + +import sys +import os.path +import json +import jsonpatch +import tempfile +import argparse + + +parser = argparse.ArgumentParser( + description='Apply a JSON patch on a JSON file') +parser.add_argument('ORIGINAL', type=argparse.FileType('r'), + help='Original file') +parser.add_argument('PATCH', type=argparse.FileType('r'), + nargs='?', default=sys.stdin, + help='Patch file (read from stdin if omitted)') +parser.add_argument('--indent', type=int, default=None, + help='Indent output by n spaces') +parser.add_argument('-b', '--backup', action='store_true', + help='Back up ORIGINAL if modifying in-place') +parser.add_argument('-i', '--in-place', action='store_true', + help='Modify ORIGINAL in-place instead of to stdout') +parser.add_argument('-v', '--version', action='version', + version='%(prog)s ' + jsonpatch.__version__) +parser.add_argument('-u', '--preserve-unicode', action='store_true', + help='Output Unicode character as-is without using Code Point') + +def main(): + try: + patch_files() + except KeyboardInterrupt: + sys.exit(1) + + +def patch_files(): + """ Diffs two JSON files and prints a patch """ + args = parser.parse_args() + doc = json.load(args.ORIGINAL) + patch = json.load(args.PATCH) + result = jsonpatch.apply_patch(doc, patch) + + if args.in_place: + dirname = os.path.abspath(os.path.dirname(args.ORIGINAL.name)) + + try: + # Attempt to replace the file atomically. We do this by + # creating a temporary file in the same directory as the + # original file so we can atomically move the new file over + # the original later. (This is done in the same directory + # because atomic renames do not work across mount points.) + + fd, pathname = tempfile.mkstemp(dir=dirname) + fp = os.fdopen(fd, 'w') + atomic = True + + except OSError: + # We failed to create the temporary file for an atomic + # replace, so fall back to non-atomic mode by backing up + # the original (if desired) and writing a new file. + + if args.backup: + os.rename(args.ORIGINAL.name, args.ORIGINAL.name + '.orig') + fp = open(args.ORIGINAL.name, 'w') + atomic = False + + else: + # Since we're not replacing the original file in-place, write + # the modified JSON to stdout instead. + + fp = sys.stdout + + # By this point we have some sort of file object we can write the + # modified JSON to. + + json.dump(result, fp, indent=args.indent, ensure_ascii=not(args.preserve_unicode)) + fp.write('\n') + + if args.in_place: + # Close the new file. If we aren't replacing atomically, this + # is our last step, since everything else is already in place. + + fp.close() + + if atomic: + try: + # Complete the atomic replace by linking the original + # to a backup (if desired), fixing up the permissions + # on the temporary file, and moving it into place. + + if args.backup: + os.link(args.ORIGINAL.name, args.ORIGINAL.name + '.orig') + os.chmod(pathname, os.stat(args.ORIGINAL.name).st_mode) + os.rename(pathname, args.ORIGINAL.name) + + except OSError: + # In the event we could not actually do the atomic + # replace, unlink the original to move it out of the + # way and finally move the temporary file into place. + + os.unlink(args.ORIGINAL.name) + os.rename(pathname, args.ORIGINAL.name) + + +if __name__ == "__main__": + main() diff --git a/virtual/bin/jsonpointer b/virtual/bin/jsonpointer new file mode 100755 index 0000000..c0f532a --- /dev/null +++ b/virtual/bin/jsonpointer @@ -0,0 +1,67 @@ +#!/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual/bin/python3.12 +# -*- coding: utf-8 -*- + + +import argparse +import json +import sys + +import jsonpointer + +parser = argparse.ArgumentParser( + description='Resolve a JSON pointer on JSON files') + +# Accept pointer as argument or as file +ptr_group = parser.add_mutually_exclusive_group(required=True) + +ptr_group.add_argument('-f', '--pointer-file', type=argparse.FileType('r'), + nargs='?', + help='File containing a JSON pointer expression') + +ptr_group.add_argument('POINTER', type=str, nargs='?', + help='A JSON pointer expression') + +parser.add_argument('FILE', type=argparse.FileType('r'), nargs='+', + help='Files for which the pointer should be resolved') +parser.add_argument('--indent', type=int, default=None, + help='Indent output by n spaces') +parser.add_argument('-v', '--version', action='version', + version='%(prog)s ' + jsonpointer.__version__) + + +def main(): + try: + resolve_files() + except KeyboardInterrupt: + sys.exit(1) + + +def parse_pointer(args): + if args.POINTER: + ptr = args.POINTER + elif args.pointer_file: + ptr = args.pointer_file.read().strip() + else: + parser.print_usage() + sys.exit(1) + + return ptr + + +def resolve_files(): + """ Resolve a JSON pointer on JSON files """ + args = parser.parse_args() + + ptr = parse_pointer(args) + + for f in args.FILE: + doc = json.load(f) + try: + result = jsonpointer.resolve_pointer(doc, ptr) + print(json.dumps(result, indent=args.indent)) + except jsonpointer.JsonPointerException as e: + print('Could not resolve pointer: %s' % str(e), file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/virtual/bin/langchain-server b/virtual/bin/langchain-server new file mode 100755 index 0000000..5a3ad02 --- /dev/null +++ b/virtual/bin/langchain-server @@ -0,0 +1,8 @@ +#!/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual/bin/python3.12 +# -*- coding: utf-8 -*- +import re +import sys +from langchain.server import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/virtual/bin/langsmith b/virtual/bin/langsmith new file mode 100755 index 0000000..65e31b1 --- /dev/null +++ b/virtual/bin/langsmith @@ -0,0 +1,8 @@ +#!/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual/bin/python3.12 +# -*- coding: utf-8 -*- +import re +import sys +from langsmith.cli.main import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/virtual/bin/normalizer b/virtual/bin/normalizer new file mode 100755 index 0000000..3f47584 --- /dev/null +++ b/virtual/bin/normalizer @@ -0,0 +1,8 @@ +#!/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual/bin/python3.12 +# -*- coding: utf-8 -*- +import re +import sys +from charset_normalizer.cli import cli_detect +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(cli_detect()) diff --git a/virtual/bin/openai b/virtual/bin/openai new file mode 100755 index 0000000..3d595a3 --- /dev/null +++ b/virtual/bin/openai @@ -0,0 +1,8 @@ +#!/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual/bin/python3.12 +# -*- coding: utf-8 -*- +import re +import sys +from openai.cli import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/virtual/bin/pip b/virtual/bin/pip new file mode 100755 index 0000000..c5b8677 --- /dev/null +++ b/virtual/bin/pip @@ -0,0 +1,8 @@ +#!/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual/bin/python3.12 +# -*- coding: utf-8 -*- +import re +import sys +from pip._internal.cli.main import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/virtual/bin/pip3 b/virtual/bin/pip3 new file mode 100755 index 0000000..c5b8677 --- /dev/null +++ b/virtual/bin/pip3 @@ -0,0 +1,8 @@ +#!/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual/bin/python3.12 +# -*- coding: utf-8 -*- +import re +import sys +from pip._internal.cli.main import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/virtual/bin/pip3.12 b/virtual/bin/pip3.12 new file mode 100755 index 0000000..c5b8677 --- /dev/null +++ b/virtual/bin/pip3.12 @@ -0,0 +1,8 @@ +#!/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual/bin/python3.12 +# -*- coding: utf-8 -*- +import re +import sys +from pip._internal.cli.main import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/virtual/bin/python b/virtual/bin/python new file mode 120000 index 0000000..11b9d88 --- /dev/null +++ b/virtual/bin/python @@ -0,0 +1 @@ +python3.12 \ No newline at end of file diff --git a/virtual/bin/python3 b/virtual/bin/python3 new file mode 120000 index 0000000..11b9d88 --- /dev/null +++ b/virtual/bin/python3 @@ -0,0 +1 @@ +python3.12 \ No newline at end of file diff --git a/virtual/bin/python3.12 b/virtual/bin/python3.12 new file mode 120000 index 0000000..a3f0508 --- /dev/null +++ b/virtual/bin/python3.12 @@ -0,0 +1 @@ +/opt/homebrew/opt/python@3.12/bin/python3.12 \ No newline at end of file diff --git a/virtual/bin/tqdm b/virtual/bin/tqdm new file mode 100755 index 0000000..a4f79ac --- /dev/null +++ b/virtual/bin/tqdm @@ -0,0 +1,8 @@ +#!/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual/bin/python3.12 +# -*- coding: utf-8 -*- +import re +import sys +from tqdm.cli import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/virtual/pyvenv.cfg b/virtual/pyvenv.cfg new file mode 100644 index 0000000..fccf188 --- /dev/null +++ b/virtual/pyvenv.cfg @@ -0,0 +1,5 @@ +home = /opt/homebrew/opt/python@3.12/bin +include-system-site-packages = false +version = 3.12.6 +executable = /opt/homebrew/Cellar/python@3.12/3.12.6/Frameworks/Python.framework/Versions/3.12/bin/python3.12 +command = /opt/homebrew/opt/python@3.12/bin/python3.12 -m venv /Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual From 75e18f7737b54c1ad7811d24268d22e6e5acfa4e Mon Sep 17 00:00:00 2001 From: Thomas <49766988+thomHayner@users.noreply.github.com> Date: Fri, 13 Sep 2024 00:33:50 -0600 Subject: [PATCH 83/97] Delete virtual directory --- virtual/bin/Activate.ps1 | 247 ----------------------------------- virtual/bin/activate | 70 ---------- virtual/bin/activate.csh | 27 ---- virtual/bin/activate.fish | 69 ---------- virtual/bin/chardetect | 8 -- virtual/bin/distro | 8 -- virtual/bin/dotenv | 8 -- virtual/bin/email_validator | 8 -- virtual/bin/f2py | 8 -- virtual/bin/httpx | 8 -- virtual/bin/jsondiff | 41 ------ virtual/bin/jsonpatch | 107 --------------- virtual/bin/jsonpointer | 67 ---------- virtual/bin/langchain-server | 8 -- virtual/bin/langsmith | 8 -- virtual/bin/normalizer | 8 -- virtual/bin/openai | 8 -- virtual/bin/pip | 8 -- virtual/bin/pip3 | 8 -- virtual/bin/pip3.12 | 8 -- virtual/bin/python | 1 - virtual/bin/python3 | 1 - virtual/bin/python3.12 | 1 - virtual/bin/tqdm | 8 -- virtual/pyvenv.cfg | 5 - 25 files changed, 748 deletions(-) delete mode 100644 virtual/bin/Activate.ps1 delete mode 100644 virtual/bin/activate delete mode 100644 virtual/bin/activate.csh delete mode 100644 virtual/bin/activate.fish delete mode 100755 virtual/bin/chardetect delete mode 100755 virtual/bin/distro delete mode 100755 virtual/bin/dotenv delete mode 100755 virtual/bin/email_validator delete mode 100755 virtual/bin/f2py delete mode 100755 virtual/bin/httpx delete mode 100755 virtual/bin/jsondiff delete mode 100755 virtual/bin/jsonpatch delete mode 100755 virtual/bin/jsonpointer delete mode 100755 virtual/bin/langchain-server delete mode 100755 virtual/bin/langsmith delete mode 100755 virtual/bin/normalizer delete mode 100755 virtual/bin/openai delete mode 100755 virtual/bin/pip delete mode 100755 virtual/bin/pip3 delete mode 100755 virtual/bin/pip3.12 delete mode 120000 virtual/bin/python delete mode 120000 virtual/bin/python3 delete mode 120000 virtual/bin/python3.12 delete mode 100755 virtual/bin/tqdm delete mode 100644 virtual/pyvenv.cfg diff --git a/virtual/bin/Activate.ps1 b/virtual/bin/Activate.ps1 deleted file mode 100644 index b49d77b..0000000 --- a/virtual/bin/Activate.ps1 +++ /dev/null @@ -1,247 +0,0 @@ -<# -.Synopsis -Activate a Python virtual environment for the current PowerShell session. - -.Description -Pushes the python executable for a virtual environment to the front of the -$Env:PATH environment variable and sets the prompt to signify that you are -in a Python virtual environment. Makes use of the command line switches as -well as the `pyvenv.cfg` file values present in the virtual environment. - -.Parameter VenvDir -Path to the directory that contains the virtual environment to activate. The -default value for this is the parent of the directory that the Activate.ps1 -script is located within. - -.Parameter Prompt -The prompt prefix to display when this virtual environment is activated. By -default, this prompt is the name of the virtual environment folder (VenvDir) -surrounded by parentheses and followed by a single space (ie. '(.venv) '). - -.Example -Activate.ps1 -Activates the Python virtual environment that contains the Activate.ps1 script. - -.Example -Activate.ps1 -Verbose -Activates the Python virtual environment that contains the Activate.ps1 script, -and shows extra information about the activation as it executes. - -.Example -Activate.ps1 -VenvDir C:\Users\MyUser\Common\.venv -Activates the Python virtual environment located in the specified location. - -.Example -Activate.ps1 -Prompt "MyPython" -Activates the Python virtual environment that contains the Activate.ps1 script, -and prefixes the current prompt with the specified string (surrounded in -parentheses) while the virtual environment is active. - -.Notes -On Windows, it may be required to enable this Activate.ps1 script by setting the -execution policy for the user. You can do this by issuing the following PowerShell -command: - -PS C:\> Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser - -For more information on Execution Policies: -https://go.microsoft.com/fwlink/?LinkID=135170 - -#> -Param( - [Parameter(Mandatory = $false)] - [String] - $VenvDir, - [Parameter(Mandatory = $false)] - [String] - $Prompt -) - -<# Function declarations --------------------------------------------------- #> - -<# -.Synopsis -Remove all shell session elements added by the Activate script, including the -addition of the virtual environment's Python executable from the beginning of -the PATH variable. - -.Parameter NonDestructive -If present, do not remove this function from the global namespace for the -session. - -#> -function global:deactivate ([switch]$NonDestructive) { - # Revert to original values - - # The prior prompt: - if (Test-Path -Path Function:_OLD_VIRTUAL_PROMPT) { - Copy-Item -Path Function:_OLD_VIRTUAL_PROMPT -Destination Function:prompt - Remove-Item -Path Function:_OLD_VIRTUAL_PROMPT - } - - # The prior PYTHONHOME: - if (Test-Path -Path Env:_OLD_VIRTUAL_PYTHONHOME) { - Copy-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME -Destination Env:PYTHONHOME - Remove-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME - } - - # The prior PATH: - if (Test-Path -Path Env:_OLD_VIRTUAL_PATH) { - Copy-Item -Path Env:_OLD_VIRTUAL_PATH -Destination Env:PATH - Remove-Item -Path Env:_OLD_VIRTUAL_PATH - } - - # Just remove the VIRTUAL_ENV altogether: - if (Test-Path -Path Env:VIRTUAL_ENV) { - Remove-Item -Path env:VIRTUAL_ENV - } - - # Just remove VIRTUAL_ENV_PROMPT altogether. - if (Test-Path -Path Env:VIRTUAL_ENV_PROMPT) { - Remove-Item -Path env:VIRTUAL_ENV_PROMPT - } - - # Just remove the _PYTHON_VENV_PROMPT_PREFIX altogether: - if (Get-Variable -Name "_PYTHON_VENV_PROMPT_PREFIX" -ErrorAction SilentlyContinue) { - Remove-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Scope Global -Force - } - - # Leave deactivate function in the global namespace if requested: - if (-not $NonDestructive) { - Remove-Item -Path function:deactivate - } -} - -<# -.Description -Get-PyVenvConfig parses the values from the pyvenv.cfg file located in the -given folder, and returns them in a map. - -For each line in the pyvenv.cfg file, if that line can be parsed into exactly -two strings separated by `=` (with any amount of whitespace surrounding the =) -then it is considered a `key = value` line. The left hand string is the key, -the right hand is the value. - -If the value starts with a `'` or a `"` then the first and last character is -stripped from the value before being captured. - -.Parameter ConfigDir -Path to the directory that contains the `pyvenv.cfg` file. -#> -function Get-PyVenvConfig( - [String] - $ConfigDir -) { - Write-Verbose "Given ConfigDir=$ConfigDir, obtain values in pyvenv.cfg" - - # Ensure the file exists, and issue a warning if it doesn't (but still allow the function to continue). - $pyvenvConfigPath = Join-Path -Resolve -Path $ConfigDir -ChildPath 'pyvenv.cfg' -ErrorAction Continue - - # An empty map will be returned if no config file is found. - $pyvenvConfig = @{ } - - if ($pyvenvConfigPath) { - - Write-Verbose "File exists, parse `key = value` lines" - $pyvenvConfigContent = Get-Content -Path $pyvenvConfigPath - - $pyvenvConfigContent | ForEach-Object { - $keyval = $PSItem -split "\s*=\s*", 2 - if ($keyval[0] -and $keyval[1]) { - $val = $keyval[1] - - # Remove extraneous quotations around a string value. - if ("'""".Contains($val.Substring(0, 1))) { - $val = $val.Substring(1, $val.Length - 2) - } - - $pyvenvConfig[$keyval[0]] = $val - Write-Verbose "Adding Key: '$($keyval[0])'='$val'" - } - } - } - return $pyvenvConfig -} - - -<# Begin Activate script --------------------------------------------------- #> - -# Determine the containing directory of this script -$VenvExecPath = Split-Path -Parent $MyInvocation.MyCommand.Definition -$VenvExecDir = Get-Item -Path $VenvExecPath - -Write-Verbose "Activation script is located in path: '$VenvExecPath'" -Write-Verbose "VenvExecDir Fullname: '$($VenvExecDir.FullName)" -Write-Verbose "VenvExecDir Name: '$($VenvExecDir.Name)" - -# Set values required in priority: CmdLine, ConfigFile, Default -# First, get the location of the virtual environment, it might not be -# VenvExecDir if specified on the command line. -if ($VenvDir) { - Write-Verbose "VenvDir given as parameter, using '$VenvDir' to determine values" -} -else { - Write-Verbose "VenvDir not given as a parameter, using parent directory name as VenvDir." - $VenvDir = $VenvExecDir.Parent.FullName.TrimEnd("\\/") - Write-Verbose "VenvDir=$VenvDir" -} - -# Next, read the `pyvenv.cfg` file to determine any required value such -# as `prompt`. -$pyvenvCfg = Get-PyVenvConfig -ConfigDir $VenvDir - -# Next, set the prompt from the command line, or the config file, or -# just use the name of the virtual environment folder. -if ($Prompt) { - Write-Verbose "Prompt specified as argument, using '$Prompt'" -} -else { - Write-Verbose "Prompt not specified as argument to script, checking pyvenv.cfg value" - if ($pyvenvCfg -and $pyvenvCfg['prompt']) { - Write-Verbose " Setting based on value in pyvenv.cfg='$($pyvenvCfg['prompt'])'" - $Prompt = $pyvenvCfg['prompt']; - } - else { - Write-Verbose " Setting prompt based on parent's directory's name. (Is the directory name passed to venv module when creating the virtual environment)" - Write-Verbose " Got leaf-name of $VenvDir='$(Split-Path -Path $venvDir -Leaf)'" - $Prompt = Split-Path -Path $venvDir -Leaf - } -} - -Write-Verbose "Prompt = '$Prompt'" -Write-Verbose "VenvDir='$VenvDir'" - -# Deactivate any currently active virtual environment, but leave the -# deactivate function in place. -deactivate -nondestructive - -# Now set the environment variable VIRTUAL_ENV, used by many tools to determine -# that there is an activated venv. -$env:VIRTUAL_ENV = $VenvDir - -if (-not $Env:VIRTUAL_ENV_DISABLE_PROMPT) { - - Write-Verbose "Setting prompt to '$Prompt'" - - # Set the prompt to include the env name - # Make sure _OLD_VIRTUAL_PROMPT is global - function global:_OLD_VIRTUAL_PROMPT { "" } - Copy-Item -Path function:prompt -Destination function:_OLD_VIRTUAL_PROMPT - New-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Description "Python virtual environment prompt prefix" -Scope Global -Option ReadOnly -Visibility Public -Value $Prompt - - function global:prompt { - Write-Host -NoNewline -ForegroundColor Green "($_PYTHON_VENV_PROMPT_PREFIX) " - _OLD_VIRTUAL_PROMPT - } - $env:VIRTUAL_ENV_PROMPT = $Prompt -} - -# Clear PYTHONHOME -if (Test-Path -Path Env:PYTHONHOME) { - Copy-Item -Path Env:PYTHONHOME -Destination Env:_OLD_VIRTUAL_PYTHONHOME - Remove-Item -Path Env:PYTHONHOME -} - -# Add the venv to the PATH -Copy-Item -Path Env:PATH -Destination Env:_OLD_VIRTUAL_PATH -$Env:PATH = "$VenvExecDir$([System.IO.Path]::PathSeparator)$Env:PATH" diff --git a/virtual/bin/activate b/virtual/bin/activate deleted file mode 100644 index cb8602a..0000000 --- a/virtual/bin/activate +++ /dev/null @@ -1,70 +0,0 @@ -# This file must be used with "source bin/activate" *from bash* -# You cannot run it directly - -deactivate () { - # reset old environment variables - if [ -n "${_OLD_VIRTUAL_PATH:-}" ] ; then - PATH="${_OLD_VIRTUAL_PATH:-}" - export PATH - unset _OLD_VIRTUAL_PATH - fi - if [ -n "${_OLD_VIRTUAL_PYTHONHOME:-}" ] ; then - PYTHONHOME="${_OLD_VIRTUAL_PYTHONHOME:-}" - export PYTHONHOME - unset _OLD_VIRTUAL_PYTHONHOME - fi - - # Call hash to forget past commands. Without forgetting - # past commands the $PATH changes we made may not be respected - hash -r 2> /dev/null - - if [ -n "${_OLD_VIRTUAL_PS1:-}" ] ; then - PS1="${_OLD_VIRTUAL_PS1:-}" - export PS1 - unset _OLD_VIRTUAL_PS1 - fi - - unset VIRTUAL_ENV - unset VIRTUAL_ENV_PROMPT - if [ ! "${1:-}" = "nondestructive" ] ; then - # Self destruct! - unset -f deactivate - fi -} - -# unset irrelevant variables -deactivate nondestructive - -# on Windows, a path can contain colons and backslashes and has to be converted: -if [ "${OSTYPE:-}" = "cygwin" ] || [ "${OSTYPE:-}" = "msys" ] ; then - # transform D:\path\to\venv to /d/path/to/venv on MSYS - # and to /cygdrive/d/path/to/venv on Cygwin - export VIRTUAL_ENV=$(cygpath "/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual") -else - # use the path as-is - export VIRTUAL_ENV="/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual" -fi - -_OLD_VIRTUAL_PATH="$PATH" -PATH="$VIRTUAL_ENV/bin:$PATH" -export PATH - -# unset PYTHONHOME if set -# this will fail if PYTHONHOME is set to the empty string (which is bad anyway) -# could use `if (set -u; : $PYTHONHOME) ;` in bash -if [ -n "${PYTHONHOME:-}" ] ; then - _OLD_VIRTUAL_PYTHONHOME="${PYTHONHOME:-}" - unset PYTHONHOME -fi - -if [ -z "${VIRTUAL_ENV_DISABLE_PROMPT:-}" ] ; then - _OLD_VIRTUAL_PS1="${PS1:-}" - PS1="(virtual) ${PS1:-}" - export PS1 - VIRTUAL_ENV_PROMPT="(virtual) " - export VIRTUAL_ENV_PROMPT -fi - -# Call hash to forget past commands. Without forgetting -# past commands the $PATH changes we made may not be respected -hash -r 2> /dev/null diff --git a/virtual/bin/activate.csh b/virtual/bin/activate.csh deleted file mode 100644 index 91bc4cb..0000000 --- a/virtual/bin/activate.csh +++ /dev/null @@ -1,27 +0,0 @@ -# This file must be used with "source bin/activate.csh" *from csh*. -# You cannot run it directly. - -# Created by Davide Di Blasi . -# Ported to Python 3.3 venv by Andrew Svetlov - -alias deactivate 'test $?_OLD_VIRTUAL_PATH != 0 && setenv PATH "$_OLD_VIRTUAL_PATH" && unset _OLD_VIRTUAL_PATH; rehash; test $?_OLD_VIRTUAL_PROMPT != 0 && set prompt="$_OLD_VIRTUAL_PROMPT" && unset _OLD_VIRTUAL_PROMPT; unsetenv VIRTUAL_ENV; unsetenv VIRTUAL_ENV_PROMPT; test "\!:*" != "nondestructive" && unalias deactivate' - -# Unset irrelevant variables. -deactivate nondestructive - -setenv VIRTUAL_ENV "/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual" - -set _OLD_VIRTUAL_PATH="$PATH" -setenv PATH "$VIRTUAL_ENV/bin:$PATH" - - -set _OLD_VIRTUAL_PROMPT="$prompt" - -if (! "$?VIRTUAL_ENV_DISABLE_PROMPT") then - set prompt = "(virtual) $prompt" - setenv VIRTUAL_ENV_PROMPT "(virtual) " -endif - -alias pydoc python -m pydoc - -rehash diff --git a/virtual/bin/activate.fish b/virtual/bin/activate.fish deleted file mode 100644 index 3ad20c8..0000000 --- a/virtual/bin/activate.fish +++ /dev/null @@ -1,69 +0,0 @@ -# This file must be used with "source /bin/activate.fish" *from fish* -# (https://fishshell.com/). You cannot run it directly. - -function deactivate -d "Exit virtual environment and return to normal shell environment" - # reset old environment variables - if test -n "$_OLD_VIRTUAL_PATH" - set -gx PATH $_OLD_VIRTUAL_PATH - set -e _OLD_VIRTUAL_PATH - end - if test -n "$_OLD_VIRTUAL_PYTHONHOME" - set -gx PYTHONHOME $_OLD_VIRTUAL_PYTHONHOME - set -e _OLD_VIRTUAL_PYTHONHOME - end - - if test -n "$_OLD_FISH_PROMPT_OVERRIDE" - set -e _OLD_FISH_PROMPT_OVERRIDE - # prevents error when using nested fish instances (Issue #93858) - if functions -q _old_fish_prompt - functions -e fish_prompt - functions -c _old_fish_prompt fish_prompt - functions -e _old_fish_prompt - end - end - - set -e VIRTUAL_ENV - set -e VIRTUAL_ENV_PROMPT - if test "$argv[1]" != "nondestructive" - # Self-destruct! - functions -e deactivate - end -end - -# Unset irrelevant variables. -deactivate nondestructive - -set -gx VIRTUAL_ENV "/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual" - -set -gx _OLD_VIRTUAL_PATH $PATH -set -gx PATH "$VIRTUAL_ENV/bin" $PATH - -# Unset PYTHONHOME if set. -if set -q PYTHONHOME - set -gx _OLD_VIRTUAL_PYTHONHOME $PYTHONHOME - set -e PYTHONHOME -end - -if test -z "$VIRTUAL_ENV_DISABLE_PROMPT" - # fish uses a function instead of an env var to generate the prompt. - - # Save the current fish_prompt function as the function _old_fish_prompt. - functions -c fish_prompt _old_fish_prompt - - # With the original prompt function renamed, we can override with our own. - function fish_prompt - # Save the return status of the last command. - set -l old_status $status - - # Output the venv prompt; color taken from the blue of the Python logo. - printf "%s%s%s" (set_color 4B8BBE) "(virtual) " (set_color normal) - - # Restore the return status of the previous command. - echo "exit $old_status" | . - # Output the original/"old" prompt. - _old_fish_prompt - end - - set -gx _OLD_FISH_PROMPT_OVERRIDE "$VIRTUAL_ENV" - set -gx VIRTUAL_ENV_PROMPT "(virtual) " -end diff --git a/virtual/bin/chardetect b/virtual/bin/chardetect deleted file mode 100755 index 297c455..0000000 --- a/virtual/bin/chardetect +++ /dev/null @@ -1,8 +0,0 @@ -#!/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual/bin/python3.12 -# -*- coding: utf-8 -*- -import re -import sys -from chardet.cli.chardetect import main -if __name__ == '__main__': - sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) - sys.exit(main()) diff --git a/virtual/bin/distro b/virtual/bin/distro deleted file mode 100755 index d012564..0000000 --- a/virtual/bin/distro +++ /dev/null @@ -1,8 +0,0 @@ -#!/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual/bin/python3.12 -# -*- coding: utf-8 -*- -import re -import sys -from distro.distro import main -if __name__ == '__main__': - sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) - sys.exit(main()) diff --git a/virtual/bin/dotenv b/virtual/bin/dotenv deleted file mode 100755 index d7254e2..0000000 --- a/virtual/bin/dotenv +++ /dev/null @@ -1,8 +0,0 @@ -#!/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual/bin/python3.12 -# -*- coding: utf-8 -*- -import re -import sys -from dotenv.__main__ import cli -if __name__ == '__main__': - sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) - sys.exit(cli()) diff --git a/virtual/bin/email_validator b/virtual/bin/email_validator deleted file mode 100755 index bd2b2ac..0000000 --- a/virtual/bin/email_validator +++ /dev/null @@ -1,8 +0,0 @@ -#!/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual/bin/python3.12 -# -*- coding: utf-8 -*- -import re -import sys -from email_validator.__main__ import main -if __name__ == '__main__': - sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) - sys.exit(main()) diff --git a/virtual/bin/f2py b/virtual/bin/f2py deleted file mode 100755 index 49fa48b..0000000 --- a/virtual/bin/f2py +++ /dev/null @@ -1,8 +0,0 @@ -#!/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual/bin/python3.12 -# -*- coding: utf-8 -*- -import re -import sys -from numpy.f2py.f2py2e import main -if __name__ == '__main__': - sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) - sys.exit(main()) diff --git a/virtual/bin/httpx b/virtual/bin/httpx deleted file mode 100755 index c27a73f..0000000 --- a/virtual/bin/httpx +++ /dev/null @@ -1,8 +0,0 @@ -#!/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual/bin/python3.12 -# -*- coding: utf-8 -*- -import re -import sys -from httpx import main -if __name__ == '__main__': - sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) - sys.exit(main()) diff --git a/virtual/bin/jsondiff b/virtual/bin/jsondiff deleted file mode 100755 index cc91661..0000000 --- a/virtual/bin/jsondiff +++ /dev/null @@ -1,41 +0,0 @@ -#!/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual/bin/python3.12 -# -*- coding: utf-8 -*- - -from __future__ import print_function - -import sys -import json -import jsonpatch -import argparse - - -parser = argparse.ArgumentParser(description='Diff two JSON files') -parser.add_argument('FILE1', type=argparse.FileType('r')) -parser.add_argument('FILE2', type=argparse.FileType('r')) -parser.add_argument('--indent', type=int, default=None, - help='Indent output by n spaces') -parser.add_argument('-u', '--preserve-unicode', action='store_true', - help='Output Unicode character as-is without using Code Point') -parser.add_argument('-v', '--version', action='version', - version='%(prog)s ' + jsonpatch.__version__) - - -def main(): - try: - diff_files() - except KeyboardInterrupt: - sys.exit(1) - - -def diff_files(): - """ Diffs two JSON files and prints a patch """ - args = parser.parse_args() - doc1 = json.load(args.FILE1) - doc2 = json.load(args.FILE2) - patch = jsonpatch.make_patch(doc1, doc2) - if patch.patch: - print(json.dumps(patch.patch, indent=args.indent, ensure_ascii=not(args.preserve_unicode))) - sys.exit(1) - -if __name__ == "__main__": - main() diff --git a/virtual/bin/jsonpatch b/virtual/bin/jsonpatch deleted file mode 100755 index b4883a6..0000000 --- a/virtual/bin/jsonpatch +++ /dev/null @@ -1,107 +0,0 @@ -#!/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual/bin/python3.12 -# -*- coding: utf-8 -*- - -import sys -import os.path -import json -import jsonpatch -import tempfile -import argparse - - -parser = argparse.ArgumentParser( - description='Apply a JSON patch on a JSON file') -parser.add_argument('ORIGINAL', type=argparse.FileType('r'), - help='Original file') -parser.add_argument('PATCH', type=argparse.FileType('r'), - nargs='?', default=sys.stdin, - help='Patch file (read from stdin if omitted)') -parser.add_argument('--indent', type=int, default=None, - help='Indent output by n spaces') -parser.add_argument('-b', '--backup', action='store_true', - help='Back up ORIGINAL if modifying in-place') -parser.add_argument('-i', '--in-place', action='store_true', - help='Modify ORIGINAL in-place instead of to stdout') -parser.add_argument('-v', '--version', action='version', - version='%(prog)s ' + jsonpatch.__version__) -parser.add_argument('-u', '--preserve-unicode', action='store_true', - help='Output Unicode character as-is without using Code Point') - -def main(): - try: - patch_files() - except KeyboardInterrupt: - sys.exit(1) - - -def patch_files(): - """ Diffs two JSON files and prints a patch """ - args = parser.parse_args() - doc = json.load(args.ORIGINAL) - patch = json.load(args.PATCH) - result = jsonpatch.apply_patch(doc, patch) - - if args.in_place: - dirname = os.path.abspath(os.path.dirname(args.ORIGINAL.name)) - - try: - # Attempt to replace the file atomically. We do this by - # creating a temporary file in the same directory as the - # original file so we can atomically move the new file over - # the original later. (This is done in the same directory - # because atomic renames do not work across mount points.) - - fd, pathname = tempfile.mkstemp(dir=dirname) - fp = os.fdopen(fd, 'w') - atomic = True - - except OSError: - # We failed to create the temporary file for an atomic - # replace, so fall back to non-atomic mode by backing up - # the original (if desired) and writing a new file. - - if args.backup: - os.rename(args.ORIGINAL.name, args.ORIGINAL.name + '.orig') - fp = open(args.ORIGINAL.name, 'w') - atomic = False - - else: - # Since we're not replacing the original file in-place, write - # the modified JSON to stdout instead. - - fp = sys.stdout - - # By this point we have some sort of file object we can write the - # modified JSON to. - - json.dump(result, fp, indent=args.indent, ensure_ascii=not(args.preserve_unicode)) - fp.write('\n') - - if args.in_place: - # Close the new file. If we aren't replacing atomically, this - # is our last step, since everything else is already in place. - - fp.close() - - if atomic: - try: - # Complete the atomic replace by linking the original - # to a backup (if desired), fixing up the permissions - # on the temporary file, and moving it into place. - - if args.backup: - os.link(args.ORIGINAL.name, args.ORIGINAL.name + '.orig') - os.chmod(pathname, os.stat(args.ORIGINAL.name).st_mode) - os.rename(pathname, args.ORIGINAL.name) - - except OSError: - # In the event we could not actually do the atomic - # replace, unlink the original to move it out of the - # way and finally move the temporary file into place. - - os.unlink(args.ORIGINAL.name) - os.rename(pathname, args.ORIGINAL.name) - - -if __name__ == "__main__": - main() diff --git a/virtual/bin/jsonpointer b/virtual/bin/jsonpointer deleted file mode 100755 index c0f532a..0000000 --- a/virtual/bin/jsonpointer +++ /dev/null @@ -1,67 +0,0 @@ -#!/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual/bin/python3.12 -# -*- coding: utf-8 -*- - - -import argparse -import json -import sys - -import jsonpointer - -parser = argparse.ArgumentParser( - description='Resolve a JSON pointer on JSON files') - -# Accept pointer as argument or as file -ptr_group = parser.add_mutually_exclusive_group(required=True) - -ptr_group.add_argument('-f', '--pointer-file', type=argparse.FileType('r'), - nargs='?', - help='File containing a JSON pointer expression') - -ptr_group.add_argument('POINTER', type=str, nargs='?', - help='A JSON pointer expression') - -parser.add_argument('FILE', type=argparse.FileType('r'), nargs='+', - help='Files for which the pointer should be resolved') -parser.add_argument('--indent', type=int, default=None, - help='Indent output by n spaces') -parser.add_argument('-v', '--version', action='version', - version='%(prog)s ' + jsonpointer.__version__) - - -def main(): - try: - resolve_files() - except KeyboardInterrupt: - sys.exit(1) - - -def parse_pointer(args): - if args.POINTER: - ptr = args.POINTER - elif args.pointer_file: - ptr = args.pointer_file.read().strip() - else: - parser.print_usage() - sys.exit(1) - - return ptr - - -def resolve_files(): - """ Resolve a JSON pointer on JSON files """ - args = parser.parse_args() - - ptr = parse_pointer(args) - - for f in args.FILE: - doc = json.load(f) - try: - result = jsonpointer.resolve_pointer(doc, ptr) - print(json.dumps(result, indent=args.indent)) - except jsonpointer.JsonPointerException as e: - print('Could not resolve pointer: %s' % str(e), file=sys.stderr) - - -if __name__ == "__main__": - main() diff --git a/virtual/bin/langchain-server b/virtual/bin/langchain-server deleted file mode 100755 index 5a3ad02..0000000 --- a/virtual/bin/langchain-server +++ /dev/null @@ -1,8 +0,0 @@ -#!/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual/bin/python3.12 -# -*- coding: utf-8 -*- -import re -import sys -from langchain.server import main -if __name__ == '__main__': - sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) - sys.exit(main()) diff --git a/virtual/bin/langsmith b/virtual/bin/langsmith deleted file mode 100755 index 65e31b1..0000000 --- a/virtual/bin/langsmith +++ /dev/null @@ -1,8 +0,0 @@ -#!/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual/bin/python3.12 -# -*- coding: utf-8 -*- -import re -import sys -from langsmith.cli.main import main -if __name__ == '__main__': - sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) - sys.exit(main()) diff --git a/virtual/bin/normalizer b/virtual/bin/normalizer deleted file mode 100755 index 3f47584..0000000 --- a/virtual/bin/normalizer +++ /dev/null @@ -1,8 +0,0 @@ -#!/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual/bin/python3.12 -# -*- coding: utf-8 -*- -import re -import sys -from charset_normalizer.cli import cli_detect -if __name__ == '__main__': - sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) - sys.exit(cli_detect()) diff --git a/virtual/bin/openai b/virtual/bin/openai deleted file mode 100755 index 3d595a3..0000000 --- a/virtual/bin/openai +++ /dev/null @@ -1,8 +0,0 @@ -#!/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual/bin/python3.12 -# -*- coding: utf-8 -*- -import re -import sys -from openai.cli import main -if __name__ == '__main__': - sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) - sys.exit(main()) diff --git a/virtual/bin/pip b/virtual/bin/pip deleted file mode 100755 index c5b8677..0000000 --- a/virtual/bin/pip +++ /dev/null @@ -1,8 +0,0 @@ -#!/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual/bin/python3.12 -# -*- coding: utf-8 -*- -import re -import sys -from pip._internal.cli.main import main -if __name__ == '__main__': - sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) - sys.exit(main()) diff --git a/virtual/bin/pip3 b/virtual/bin/pip3 deleted file mode 100755 index c5b8677..0000000 --- a/virtual/bin/pip3 +++ /dev/null @@ -1,8 +0,0 @@ -#!/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual/bin/python3.12 -# -*- coding: utf-8 -*- -import re -import sys -from pip._internal.cli.main import main -if __name__ == '__main__': - sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) - sys.exit(main()) diff --git a/virtual/bin/pip3.12 b/virtual/bin/pip3.12 deleted file mode 100755 index c5b8677..0000000 --- a/virtual/bin/pip3.12 +++ /dev/null @@ -1,8 +0,0 @@ -#!/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual/bin/python3.12 -# -*- coding: utf-8 -*- -import re -import sys -from pip._internal.cli.main import main -if __name__ == '__main__': - sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) - sys.exit(main()) diff --git a/virtual/bin/python b/virtual/bin/python deleted file mode 120000 index 11b9d88..0000000 --- a/virtual/bin/python +++ /dev/null @@ -1 +0,0 @@ -python3.12 \ No newline at end of file diff --git a/virtual/bin/python3 b/virtual/bin/python3 deleted file mode 120000 index 11b9d88..0000000 --- a/virtual/bin/python3 +++ /dev/null @@ -1 +0,0 @@ -python3.12 \ No newline at end of file diff --git a/virtual/bin/python3.12 b/virtual/bin/python3.12 deleted file mode 120000 index a3f0508..0000000 --- a/virtual/bin/python3.12 +++ /dev/null @@ -1 +0,0 @@ -/opt/homebrew/opt/python@3.12/bin/python3.12 \ No newline at end of file diff --git a/virtual/bin/tqdm b/virtual/bin/tqdm deleted file mode 100755 index a4f79ac..0000000 --- a/virtual/bin/tqdm +++ /dev/null @@ -1,8 +0,0 @@ -#!/Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual/bin/python3.12 -# -*- coding: utf-8 -*- -import re -import sys -from tqdm.cli import main -if __name__ == '__main__': - sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) - sys.exit(main()) diff --git a/virtual/pyvenv.cfg b/virtual/pyvenv.cfg deleted file mode 100644 index fccf188..0000000 --- a/virtual/pyvenv.cfg +++ /dev/null @@ -1,5 +0,0 @@ -home = /opt/homebrew/opt/python@3.12/bin -include-system-site-packages = false -version = 3.12.6 -executable = /opt/homebrew/Cellar/python@3.12/3.12.6/Frameworks/Python.framework/Versions/3.12/bin/python3.12 -command = /opt/homebrew/opt/python@3.12/bin/python3.12 -m venv /Users/elliothayner/GitHub/linkedIn_AI_hawk/virtual From 28f4ac137bf622ec588aaafcf4acab1a06bdce71 Mon Sep 17 00:00:00 2001 From: Thomas <49766988+thomHayner@users.noreply.github.com> Date: Fri, 13 Sep 2024 01:02:10 -0600 Subject: [PATCH 84/97] Update .gitignore " * " character after directoryName/ is not necessary --- .gitignore | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 35f5936..4d9a5de 100644 --- a/.gitignore +++ b/.gitignore @@ -149,10 +149,10 @@ venv.bak/ mono_crash.* # Project Specific -data_folder/output/* -generated_cv/* -chrome_profile/* -virtual/* +data_folder/output/ +generated_cv/ +chrome_profile/ +virtual/ answers.json # MacOS From 6d27b823aecd2d14cf7112b1463a2a7620c71e3e Mon Sep 17 00:00:00 2001 From: Thomas Hayner Date: Fri, 13 Sep 2024 02:30:38 -0600 Subject: [PATCH 85/97] cleanup: consistent variable names --- data_folder/config.yaml | 4 ++-- data_folder/plain_text_resume.yaml | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/data_folder/config.yaml b/data_folder/config.yaml index 2051ec8..ee5fae2 100644 --- a/data_folder/config.yaml +++ b/data_folder/config.yaml @@ -8,7 +8,7 @@ experience_level: director: [true/false] executive: [true/false] -jobTypes: +job_types: full-time: [true/false] contract: [true/false] part-time: [true/false] @@ -45,7 +45,7 @@ title_blacklist: job_applicants_threshold: min_applicants: 0 - max_applicants: 100 + max_applicants: 281 llm_model_type: openai llm_model: gpt-4o diff --git a/data_folder/plain_text_resume.yaml b/data_folder/plain_text_resume.yaml index 0c55645..cacf576 100644 --- a/data_folder/plain_text_resume.yaml +++ b/data_folder/plain_text_resume.yaml @@ -59,7 +59,6 @@ projects: - name: "[Project Name]" description: "[Project Description]" link: "[Project Link]" - - name: "[Project Name]" description: "[Project Description]" link: "[Project Link]" From 09e41160f9f596fb985a1af023d62342c6aa5405 Mon Sep 17 00:00:00 2001 From: Thomas Hayner Date: Fri, 13 Sep 2024 02:50:48 -0600 Subject: [PATCH 86/97] revert --- data_folder/config.yaml | 4 ++-- data_folder/plain_text_resume.yaml | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/data_folder/config.yaml b/data_folder/config.yaml index ee5fae2..2051ec8 100644 --- a/data_folder/config.yaml +++ b/data_folder/config.yaml @@ -8,7 +8,7 @@ experience_level: director: [true/false] executive: [true/false] -job_types: +jobTypes: full-time: [true/false] contract: [true/false] part-time: [true/false] @@ -45,7 +45,7 @@ title_blacklist: job_applicants_threshold: min_applicants: 0 - max_applicants: 281 + max_applicants: 100 llm_model_type: openai llm_model: gpt-4o diff --git a/data_folder/plain_text_resume.yaml b/data_folder/plain_text_resume.yaml index cacf576..0c55645 100644 --- a/data_folder/plain_text_resume.yaml +++ b/data_folder/plain_text_resume.yaml @@ -59,6 +59,7 @@ projects: - name: "[Project Name]" description: "[Project Description]" link: "[Project Link]" + - name: "[Project Name]" description: "[Project Description]" link: "[Project Link]" From 0534297ccca8d5dfe4b188b07e13df5e70141a29 Mon Sep 17 00:00:00 2001 From: tapas-joshi Date: Fri, 13 Sep 2024 12:12:42 -0400 Subject: [PATCH 87/97] Made llm_api_url optional Tested with Ollama local and it works without providing any URL. Also made MINIMUM_WAIT_TIME a config variable in app_config which can easily be tweaked --- app_config.py | 5 ++++- src/linkedIn_job_manager.py | 3 ++- src/llm/llm_manager.py | 44 ++++++++++++++++++++++--------------- 3 files changed, 32 insertions(+), 20 deletions(-) diff --git a/app_config.py b/app_config.py index 75684d1..30d05e0 100644 --- a/app_config.py +++ b/app_config.py @@ -1 +1,4 @@ -MINIMUM_LOG_LEVEL="DEBUG" \ No newline at end of file +# LOGGING +MINIMUM_LOG_LEVEL = "DEBUG" + +MINIMUM_WAIT_TIME = 60 * 15 \ No newline at end of file diff --git a/src/linkedIn_job_manager.py b/src/linkedIn_job_manager.py index b608c07..76fff93 100644 --- a/src/linkedIn_job_manager.py +++ b/src/linkedIn_job_manager.py @@ -10,6 +10,7 @@ from selenium.common.exceptions import NoSuchElementException from selenium.webdriver.common.by import By import src.utils as utils +from app_config import MINIMUM_WAIT_TIME from src.job import Job from src.linkedIn_easy_applier import LinkedInEasyApplier from loguru import logger @@ -78,7 +79,7 @@ class LinkedInJobManager: searches = list(product(self.positions, self.locations)) random.shuffle(searches) page_sleep = 0 - minimum_time = 60 * 15 + minimum_time = MINIMUM_WAIT_TIME minimum_page_time = time.time() + minimum_time for position, location in searches: diff --git a/src/llm/llm_manager.py b/src/llm/llm_manager.py index d1f6fe1..8dd2751 100644 --- a/src/llm/llm_manager.py +++ b/src/llm/llm_manager.py @@ -12,6 +12,7 @@ from typing import Union import httpx from Levenshtein import distance from dotenv import load_dotenv +from langchain_core.messages import BaseMessage from langchain_core.messages.ai import AIMessage from langchain_core.output_parsers import StrOutputParser from langchain_core.prompt_values import StringPromptValue @@ -30,44 +31,50 @@ class AIModel(ABC): class OpenAIModel(AIModel): - def __init__(self, api_key: str, llm_model: str, llm_api_url: str): + def __init__(self, api_key: str, llm_model: 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) + temperature=0.4) - def invoke(self, prompt: str) -> str: + def invoke(self, prompt: str) -> BaseMessage: logger.debug("Invoking OpenAI API") response = self.model.invoke(prompt) return response class ClaudeModel(AIModel): - def __init__(self, api_key: str, llm_model: str, llm_api_url: str): + def __init__(self, api_key: str, llm_model: 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) - def invoke(self, prompt: str) -> str: + def invoke(self, prompt: str) -> BaseMessage: response = self.model.invoke(prompt) + logger.debug("Invoking Claude API") return response class OllamaModel(AIModel): - def __init__(self, api_key: str, llm_model: str, llm_api_url: str): + def __init__(self, llm_model: str, llm_api_url: str): from langchain_ollama import ChatOllama - self.model = ChatOllama(model=llm_model, base_url=llm_api_url) - def invoke(self, prompt: str) -> str: + if len(llm_api_url) > 0: + logger.debug(f"Using Ollama with API URL: {llm_api_url}") + self.model = ChatOllama(model=llm_model, base_url=llm_api_url) + else: + self.model = ChatOllama(model=llm_model) + + def invoke(self, prompt: str) -> BaseMessage: response = self.model.invoke(prompt) return response class GeminiModel(AIModel): - def __init__(self, api_key:str, llm_model: str, llm_api_url: str): + def __init__(self, api_key:str, llm_model: str): from langchain_google_genai import ChatGoogleGenerativeAI self.model = ChatGoogleGenerativeAI(model=llm_model, google_api_key=api_key) - def invoke(self, prompt: str) -> str: + def invoke(self, prompt: str) -> BaseMessage: response = self.model.invoke(prompt) return response @@ -79,18 +86,19 @@ class AIAdapter: 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'] - logger.debug('Using {0} with {1} from {2}'.format( - llm_model_type, llm_model, llm_api_url)) + + llm_api_url = config.get('llm_api_url', "") + + logger.debug(f"Using {llm_model_type} with {llm_model}") if llm_model_type == "openai": - return OpenAIModel(api_key, llm_model, llm_api_url) + return OpenAIModel(api_key, llm_model) elif llm_model_type == "claude": - return ClaudeModel(api_key, llm_model, llm_api_url) + return ClaudeModel(api_key, llm_model) elif llm_model_type == "ollama": - return OllamaModel(api_key, llm_model, llm_api_url) + return OllamaModel(llm_model, llm_api_url) elif llm_model_type == "gemini": - return GeminiModel(api_key, llm_model, llm_api_url) + return GeminiModel(api_key, llm_model) else: raise ValueError(f"Unsupported model type: {llm_model_type}") From b75055e076b5918f2f59c84c9f49841c7375c5da Mon Sep 17 00:00:00 2001 From: feder-cr <85809106+feder-cr@users.noreply.github.com> Date: Fri, 13 Sep 2024 18:20:04 +0200 Subject: [PATCH 88/97] little fix --- .gitignore | 4 +++- src/utils.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 1ac6fe6..74ef2bc 100644 --- a/.gitignore +++ b/.gitignore @@ -26,7 +26,9 @@ share/python-wheels/ .installed.cfg *.egg MANIFEST - +chrome_profile/* +data_folder/* +answers.json # 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. diff --git a/src/utils.py b/src/utils.py index 587e7a2..3d9021c 100644 --- a/src/utils.py +++ b/src/utils.py @@ -66,7 +66,7 @@ def scroll_slow(driver, scrollable_element, start=0, end=3600, step=300, reverse raise ValueError("Step cannot be zero.") max_scroll_height = int(scrollable_element.get_attribute("scrollHeight")) - current_scroll_position = int(scrollable_element.get_attribute("scrollTop")) + current_scroll_position = int(float(scrollable_element.get_attribute("scrollTop"))) logger.debug(f"Max scroll height of the element: {max_scroll_height}") logger.debug(f"Current scroll position: {current_scroll_position}") From 90611d5895443867ed4eb57408e0dba2f92b2989 Mon Sep 17 00:00:00 2001 From: Federico <85809106+feder-cr@users.noreply.github.com> Date: Fri, 13 Sep 2024 18:36:54 +0200 Subject: [PATCH 89/97] Update config.yaml --- data_folder/config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/data_folder/config.yaml b/data_folder/config.yaml index 2051ec8..af6af1e 100644 --- a/data_folder/config.yaml +++ b/data_folder/config.yaml @@ -48,5 +48,5 @@ job_applicants_threshold: 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 +llm_model: gpt-4o-mini +# llm_api_url: https://api.pawan.krd/cosmosrp/v1 From 80a7b78b4d1c72f495d2beafaabe24ab0f226849 Mon Sep 17 00:00:00 2001 From: Federico <85809106+feder-cr@users.noreply.github.com> Date: Fri, 13 Sep 2024 18:37:24 +0200 Subject: [PATCH 90/97] Update config.yaml --- data_folder/config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data_folder/config.yaml b/data_folder/config.yaml index af6af1e..762597d 100644 --- a/data_folder/config.yaml +++ b/data_folder/config.yaml @@ -49,4 +49,4 @@ job_applicants_threshold: llm_model_type: openai llm_model: gpt-4o-mini -# llm_api_url: https://api.pawan.krd/cosmosrp/v1 +# llm_api_url: https://api.pawan.krd/cosmosrp/v1 this field is optional From 07891940ee87746d78efa4527d6439f326f1a358 Mon Sep 17 00:00:00 2001 From: Thomas Hayner Date: Fri, 13 Sep 2024 11:56:36 -0600 Subject: [PATCH 91/97] organize and alphebetize --- requirements.txt | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/requirements.txt b/requirements.txt index 11127a8..70ffd85 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,25 +1,23 @@ +httpx~=0.27.2 +inputimeout==1.0.4 +jsonschema==4.23.0 +jsonschema-specifications==2023.12.1 langchain==0.2.11 +langchain-anthropic==0.1.3 langchain-community==0.2.10 langchain-core==0.2.24 +langchain-google-genai==1.0.10 +langchain-ollama==0.1.3 langchain-openai==0.1.17 langchain-text-splitters==0.2.2 langsmith==0.1.93 Levenshtein==0.25.1 +linkedin-api openai==1.37.1 +pdfminer.six==20221105 +python-dotenv~=1.0.1 +PyYAML~=6.0.2 regex==2024.7.24 reportlab==4.2.2 selenium==4.9.1 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 -langchain-google-genai==1.0.10 -jsonschema==4.23.0 -jsonschema-specifications==2023.12.1 -httpx~=0.27.2 -python-dotenv~=1.0.1 -PyYAML~=6.0.2 From 8c0a4127a8ba891ecbb168e8022cd8c0720445e3 Mon Sep 17 00:00:00 2001 From: Thomas Hayner Date: Fri, 13 Sep 2024 13:41:29 -0600 Subject: [PATCH 92/97] add new packages --- requirements.txt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 70ffd85..f16c6cd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,18 +3,20 @@ inputimeout==1.0.4 jsonschema==4.23.0 jsonschema-specifications==2023.12.1 langchain==0.2.11 -langchain-anthropic==0.1.3 +langchain-anthropic langchain-community==0.2.10 -langchain-core==0.2.24 +langchain-core===0.2.36 langchain-google-genai==1.0.10 langchain-ollama==0.1.3 langchain-openai==0.1.17 langchain-text-splitters==0.2.2 langsmith==0.1.93 Levenshtein==0.25.1 +loguru==0.7.2 linkedin-api openai==1.37.1 pdfminer.six==20221105 +pytest>=8.3.3 python-dotenv~=1.0.1 PyYAML~=6.0.2 regex==2024.7.24 From c4240e110a39024247ab1b7c80d59b21edb0548f Mon Sep 17 00:00:00 2001 From: Thomas Hayner Date: Fri, 13 Sep 2024 13:44:34 -0600 Subject: [PATCH 93/97] reinclude last two packages --- requirements.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/requirements.txt b/requirements.txt index f16c6cd..dcf225d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,5 @@ +click +git+https://github.com/feder-cr/lib_resume_builder_AIHawk.git httpx~=0.27.2 inputimeout==1.0.4 jsonschema==4.23.0 From 9e21417e0241d46639258bbce6140cfc228b7192 Mon Sep 17 00:00:00 2001 From: feder-cr <85809106+feder-cr@users.noreply.github.com> Date: Fri, 13 Sep 2024 23:43:53 +0200 Subject: [PATCH 94/97] gemini check --- src/gpt.py | 581 ----------------------------------------- src/llm/llm_manager.py | 21 +- 2 files changed, 16 insertions(+), 586 deletions(-) delete mode 100644 src/gpt.py diff --git a/src/gpt.py b/src/gpt.py deleted file mode 100644 index c82e02e..0000000 --- a/src/gpt.py +++ /dev/null @@ -1,581 +0,0 @@ -import json -import os -import re -import textwrap -import time -from abc import ABC, abstractmethod -from datetime import datetime -from pathlib import Path -from typing import Dict, List -from typing import Union - -import httpx -from Levenshtein import distance -from dotenv import load_dotenv -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 - -import src.strings as strings -from src.utils import logger - -load_dotenv() - - -class AIModel(ABC): - @abstractmethod - def invoke(self, prompt: str) -> str: - pass - - -class OpenAIModel(AIModel): - def __init__(self, api_key: str, llm_model: str, llm_api_url: str): - from langchain_openai import ChatOpenAI - self.model = ChatOpenAI(model_name=llm_model, openai_api_key=api_key, - temperature=0.4, base_url=llm_api_url) - - def invoke(self, prompt: str) -> str: - print("invoke in openai") - response = self.model.invoke(prompt) - return response - - -class ClaudeModel(AIModel): - def __init__(self, api_key: str, llm_model: str, llm_api_url: str): - from langchain_anthropic import ChatAnthropic - self.model = ChatAnthropic(model=llm_model, api_key=api_key, - temperature=0.4, base_url=llm_api_url) - - def invoke(self, prompt: str) -> str: - response = self.model.invoke(prompt) - return response - - -class OllamaModel(AIModel): - def __init__(self, api_key: str, llm_model: str, llm_api_url: str): - from langchain_ollama import ChatOllama - self.model = ChatOllama(model=llm_model, base_url=llm_api_url) - - def invoke(self, prompt: str) -> str: - response = self.model.invoke(prompt) - return response - - -class 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) - - def _create_model(self, config: dict, api_key: str) -> AIModel: - llm_model_type = config['llm_model_type'] - llm_model = config['llm_model'] - llm_api_url = config['llm_api_url'] - print('Using {0} with {1} from {2}'.format( - llm_model_type, llm_model, llm_api_url)) - - if llm_model_type == "openai": - return OpenAIModel(api_key, llm_model, llm_api_url) - elif llm_model_type == "claude": - return ClaudeModel(api_key, llm_model, llm_api_url) - elif llm_model_type == "ollama": - return OllamaModel(api_key, llm_model, llm_api_url) - elif llm_model_type == "gemini": - return GeminiModel(api_key, llm_model, llm_api_url) - else: - raise ValueError(f"Unsupported model type: {llm_model_type}") - - def invoke(self, prompt: str) -> str: - return self.model.invoke(prompt) - - -class LLMLogger: - - def __init__(self, llm: Union[OpenAIModel, OllamaModel, ClaudeModel, GeminiModel]): - - self.llm = llm - logger.debug("LLMLogger successfully initialized with LLM: %s", llm) - - @staticmethod - def log_request(prompts, parsed_reply: Dict[str, Dict]): - logger.debug("Starting log_request method") - logger.debug("Prompts received: %s", prompts) - logger.debug("Parsed reply received: %s", parsed_reply) - - try: - 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)) - raise - - if isinstance(prompts, StringPromptValue): - 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") - try: - 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: - 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 - - try: - current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - logger.debug("Current time obtained: %s", current_time) - except Exception as e: - logger.error("Error obtaining current time: %s", str(e)) - raise - - try: - token_usage = parsed_reply["usage_metadata"] - 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) - except KeyError as e: - logger.error("KeyError in parsed_reply structure: %s", str(e)) - raise - - try: - model_name = parsed_reply["response_metadata"]["model_name"] - logger.debug("Model name: %s", model_name) - except KeyError as e: - logger.error("KeyError in response_metadata: %s", str(e)) - raise - - 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) - logger.debug("Total cost calculated: %f", total_cost) - except Exception as e: - logger.error("Error calculating total cost: %s", str(e)) - raise - - try: - log_entry = { - "model": model_name, - "time": current_time, - "prompts": prompts, - "replies": parsed_reply["content"], - "total_tokens": total_tokens, - "input_tokens": input_tokens, - "output_tokens": output_tokens, - "total_cost": total_cost, - } - 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)) - raise - - try: - with open(calls_log, "a", encoding="utf-8") as f: - 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: - logger.error("Error writing log entry to file: %s", str(e)) - raise - - -class LoggerChatModel: - - def __init__(self, llm: Union[OpenAIModel, OllamaModel, ClaudeModel, GeminiModel]): - self.llm = 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.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) - logger.debug("Request successfully logged") - - return reply - - except httpx.HTTPStatusError as e: - logger.error("HTTPStatusError encountered: %s", str(e)) - if e.response.status_code == 429: - retry_after = e.response.headers.get('retry-after') - retry_after_ms = e.response.headers.get('retry-after-ms') - - if retry_after: - wait_time = int(retry_after) - logger.warning( - "Rate limit exceeded. Waiting for %d seconds before retrying (extracted from 'retry-after' header)...", - wait_time) - time.sleep(wait_time) - elif retry_after_ms: - wait_time = int(retry_after_ms) / 1000.0 - logger.warning( - "Rate limit exceeded. Waiting for %f seconds before retrying (extracted from 'retry-after-ms' header)...", - wait_time) - time.sleep(wait_time) - else: - wait_time = 30 - logger.warning( - "'retry-after' header not found. Waiting for %d seconds before retrying (default)...", - wait_time) - time.sleep(wait_time) - else: - logger.error("HTTP error occurred with status code: %d, waiting 30 seconds before retrying", - e.response.status_code) - time.sleep(30) - - 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.") - time.sleep(30) - continue - - def parse_llmresult(self, llmresult: AIMessage) -> Dict[str, Dict]: - logger.debug("Parsing LLM result: %s", llmresult) - - try: - content = llmresult.content - response_metadata = llmresult.response_metadata - id_ = llmresult.id - usage_metadata = llmresult.usage_metadata - - parsed_result = { - "content": content, - "response_metadata": { - "model_name": response_metadata.get("model_name", ""), - "system_fingerprint": response_metadata.get("system_fingerprint", ""), - "finish_reason": response_metadata.get("finish_reason", ""), - "logprobs": response_metadata.get("logprobs", None), - }, - "id": id_, - "usage_metadata": { - "input_tokens": usage_metadata.get("input_tokens", 0), - "output_tokens": usage_metadata.get("output_tokens", 0), - "total_tokens": usage_metadata.get("total_tokens", 0), - }, - } - - logger.debug("Parsed LLM result successfully: %s", parsed_result) - return parsed_result - - except KeyError as 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)) - raise - - -class GPTAnswerer: - - def __init__(self, config, llm_api_key): - self.ai_adapter = AIAdapter(config, llm_api_key) - self.llm_cheap = LoggerChatModel(self.ai_adapter) - - @property - def job_description(self): - return self.job.description - - @staticmethod - def find_best_match(text: str, options: list[str]) -> str: - logger.debug( - "Finding best match for text: '%s' in options: %s", text, options) - distances = [ - (option, distance(text.lower(), option.lower())) for option in options - ] - best_option = min(distances, key=lambda x: x[1])[0] - logger.debug("Best match found: %s", best_option) - return best_option - - @staticmethod - def _remove_placeholders(text: str) -> str: - logger.debug("Removing placeholders from text: %s", text) - text = text.replace("PLACEHOLDER", "") - return text.strip() - - @staticmethod - def _preprocess_template_string(template: str) -> str: - logger.debug("Preprocessing template string") - return textwrap.dedent(template) - - def set_resume(self, resume): - logger.debug("Setting resume: %s", resume) - self.resume = resume - - 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)) - - def set_job_application_profile(self, 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: - logger.debug("Summarizing job description: %s", text) - strings.summarize_prompt_template = self._preprocess_template_string( - 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) - return output - - def _create_chain(self, template: str): - logger.debug("Creating chain with template: %s", template) - prompt = ChatPromptTemplate.from_template(template) - return prompt | self.llm_cheap | StrOutputParser() - - def answer_question_textual_wide_range(self, question: str) -> str: - logger.debug("Answering textual question: %s", question) - chains = { - "personal_information": self._create_chain(strings.personal_information_template), - "self_identification": self._create_chain(strings.self_identification_template), - "legal_authorization": self._create_chain(strings.legal_authorization_template), - "work_preferences": self._create_chain(strings.work_preferences_template), - "education_details": self._create_chain(strings.education_details_template), - "experience_details": self._create_chain(strings.experience_details_template), - "projects": self._create_chain(strings.projects_template), - "availability": self._create_chain(strings.availability_template), - "salary_expectations": self._create_chain(strings.salary_expectations_template), - "certifications": self._create_chain(strings.certifications_template), - "languages": self._create_chain(strings.languages_template), - "interests": self._create_chain(strings.interests_template), - "cover_letter": self._create_chain(strings.coverletter_template), - } - section_prompt = """ - You are assisting a bot designed to automatically apply for jobs on LinkedIn. The bot receives various questions about job applications and needs to determine the most relevant section of the resume to provide an accurate response. - - For the following question: '{question}', determine which section of the resume is most relevant. - Respond with exactly one of the following options: - - Personal information - - Self Identification - - Legal Authorization - - Work Preferences - - Education Details - - Experience Details - - Projects - - Availability - - Salary Expectations - - Certifications - - Languages - - Interests - - Cover letter - - Here are detailed guidelines to help you choose the correct section: - - 1. **Personal Information**: - - **Purpose**: Contains your basic contact details and online profiles. - - **Use When**: The question is about how to contact you or requests links to your professional online presence. - - **Examples**: Email address, phone number, LinkedIn profile, GitHub repository, personal website. - - 2. **Self Identification**: - - **Purpose**: Covers personal identifiers and demographic information. - - **Use When**: The question pertains to your gender, pronouns, veteran status, disability status, or ethnicity. - - **Examples**: Gender, pronouns, veteran status, disability status, ethnicity. - - 3. **Legal Authorization**: - - **Purpose**: Details your work authorization status and visa requirements. - - **Use When**: The question asks about your ability to work in specific countries or if you need sponsorship or visas. - - **Examples**: Work authorization in EU and US, visa requirements, legally allowed to work. - - 4. **Work Preferences**: - - **Purpose**: Specifies your preferences regarding work conditions and job roles. - - **Use When**: The question is about your preferences for remote work, in-person work, relocation, and willingness to undergo assessments or background checks. - - **Examples**: Remote work, in-person work, open to relocation, willingness to complete assessments. - - 5. **Education Details**: - - **Purpose**: Contains information about your academic qualifications. - - **Use When**: The question concerns your degrees, universities attended, GPA, and relevant coursework. - - **Examples**: Degree, university, GPA, field of study, exams. - - 6. **Experience Details**: - - **Purpose**: Details your professional work history and key responsibilities. - - **Use When**: The question pertains to your job roles, responsibilities, and achievements in previous positions. - - **Examples**: Job positions, company names, key responsibilities, skills acquired. - - 7. **Projects**: - - **Purpose**: Highlights specific projects you have worked on. - - **Use When**: The question asks about particular projects, their descriptions, or links to project repositories. - - **Examples**: Project names, descriptions, links to project repositories. - - 8. **Availability**: - - **Purpose**: Provides information on your availability for new roles. - - **Use When**: The question is about how soon you can start a new job or your notice period. - - **Examples**: Notice period, availability to start. - - 9. **Salary Expectations**: - - **Purpose**: Covers your expected salary range. - - **Use When**: The question pertains to your salary expectations or compensation requirements. - - **Examples**: Desired salary range. - - 10. **Certifications**: - - **Purpose**: Lists your professional certifications or licenses. - - **Use When**: The question involves your certifications or qualifications from recognized organizations. - - **Examples**: Certification names, issuing bodies, dates of validity. - - 11. **Languages**: - - **Purpose**: Describes the languages you can speak and your proficiency levels. - - **Use When**: The question asks about your language skills or proficiency in specific languages. - - **Examples**: Languages spoken, proficiency levels. - - 12. **Interests**: - - **Purpose**: Details your personal or professional interests. - - **Use When**: The question is about your hobbies, interests, or activities outside of work. - - **Examples**: Personal hobbies, professional interests. - - 13. **Cover Letter**: - - **Purpose**: Contains your personalized cover letter or statement. - - **Use When**: The question involves your cover letter or specific written content intended for the job application. - - **Examples**: Cover letter content, personalized statements. - - Provide only the exact name of the section from the list above with no additional text. - """ - prompt = ChatPromptTemplate.from_template(section_prompt) - 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) - 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}) - 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.") - 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}) - 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) - prompt = ChatPromptTemplate.from_template(func_template) - chain = prompt | self.llm_cheap | StrOutputParser() - output_str = chain.invoke( - {"resume_educations": self.resume.education_details, "resume_jobs": self.resume.experience_details, - "resume_projects": self.resume.projects, "question": question}) - logger.debug("Raw output for numeric question: %s", output_str) - try: - 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) - output = default_experience - return output - - def extract_number_from_string(self, output_str): - logger.debug("Extracting number from string: %s", output_str) - numbers = re.findall(r"\d+", output_str) - if numbers: - logger.debug("Numbers found: %s", numbers) - return int(numbers[0]) - else: - logger.error("No numbers found in the string") - raise ValueError("No numbers found in the string") - - 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) - prompt = ChatPromptTemplate.from_template(func_template) - chain = prompt | self.llm_cheap | StrOutputParser() - 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) - 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'. - If the phrase contains 'upload resume', consider it as 'resume'. - Do not provide any additional information or explanations. - - phrase: {phrase} - """ - prompt = ChatPromptTemplate.from_template(prompt_template) - chain = prompt | self.llm_cheap | StrOutputParser() - response = chain.invoke({"phrase": phrase}) - logger.debug("Response for resume_or_cover: %s", response) - if "resume" in response: - return "resume" - elif "cover" in response: - return "cover" - else: - return "resume" diff --git a/src/llm/llm_manager.py b/src/llm/llm_manager.py index 8dd2751..df5681b 100644 --- a/src/llm/llm_manager.py +++ b/src/llm/llm_manager.py @@ -68,11 +68,23 @@ class OllamaModel(AIModel): response = self.model.invoke(prompt) return response - +#gemini doesn't seem to work because API doesn't rstitute answers for questions that involve answers that are too short class GeminiModel(AIModel): def __init__(self, api_key:str, llm_model: str): - from langchain_google_genai import ChatGoogleGenerativeAI - self.model = ChatGoogleGenerativeAI(model=llm_model, google_api_key=api_key) + from langchain_google_genai import ChatGoogleGenerativeAI, HarmBlockThreshold, HarmCategory + self.model = ChatGoogleGenerativeAI(model=llm_model, google_api_key=api_key,safety_settings={ + HarmCategory.HARM_CATEGORY_UNSPECIFIED: HarmBlockThreshold.BLOCK_NONE, + HarmCategory.HARM_CATEGORY_DEROGATORY: HarmBlockThreshold.BLOCK_NONE, + HarmCategory.HARM_CATEGORY_TOXICITY: HarmBlockThreshold.BLOCK_NONE, + HarmCategory.HARM_CATEGORY_VIOLENCE: HarmBlockThreshold.BLOCK_NONE, + HarmCategory.HARM_CATEGORY_SEXUAL: HarmBlockThreshold.BLOCK_NONE, + HarmCategory.HARM_CATEGORY_MEDICAL: HarmBlockThreshold.BLOCK_NONE, + HarmCategory.HARM_CATEGORY_DANGEROUS: HarmBlockThreshold.BLOCK_NONE, + HarmCategory.HARM_CATEGORY_HARASSMENT: HarmBlockThreshold.BLOCK_NONE, + HarmCategory.HARM_CATEGORY_HATE_SPEECH: HarmBlockThreshold.BLOCK_NONE, + HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT: HarmBlockThreshold.BLOCK_NONE, + HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT: HarmBlockThreshold.BLOCK_NONE + },max_output_tokens=3000) def invoke(self, prompt: str) -> BaseMessage: response = self.model.invoke(prompt) @@ -388,8 +400,7 @@ class GPTAnswerer: "interests": self._create_chain(strings.interests_template), "cover_letter": self._create_chain(strings.coverletter_template), } - section_prompt = """ - You are assisting a bot designed to automatically apply for jobs on LinkedIn. The bot receives various questions about job applications and needs to determine the most relevant section of the resume to provide an accurate response. + section_prompt = """You are assisting a bot designed to automatically apply for jobs on LinkedIn. The bot receives various questions about job applications and needs to determine the most relevant section of the resume to provide an accurate response. For the following question: '{question}', determine which section of the resume is most relevant. Respond with exactly one of the following options: From acfc990b87ae57de8f35d5abfc1048046fcae65e Mon Sep 17 00:00:00 2001 From: feder-cr <85809106+feder-cr@users.noreply.github.com> Date: Fri, 13 Sep 2024 23:44:34 +0200 Subject: [PATCH 95/97] check gemini --- src/llm/llm_manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/llm/llm_manager.py b/src/llm/llm_manager.py index df5681b..5c48c55 100644 --- a/src/llm/llm_manager.py +++ b/src/llm/llm_manager.py @@ -84,7 +84,7 @@ class GeminiModel(AIModel): HarmCategory.HARM_CATEGORY_HATE_SPEECH: HarmBlockThreshold.BLOCK_NONE, HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT: HarmBlockThreshold.BLOCK_NONE, HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT: HarmBlockThreshold.BLOCK_NONE - },max_output_tokens=3000) + }) def invoke(self, prompt: str) -> BaseMessage: response = self.model.invoke(prompt) From 45eaef03d0a4a6bd954729df9cda428a3ffe740a Mon Sep 17 00:00:00 2001 From: Federico <85809106+feder-cr@users.noreply.github.com> Date: Sat, 14 Sep 2024 13:46:54 +0200 Subject: [PATCH 96/97] Delete resume_yaml_generator.py --- resume_yaml_generator.py | 161 --------------------------------------- 1 file changed, 161 deletions(-) delete mode 100644 resume_yaml_generator.py diff --git a/resume_yaml_generator.py b/resume_yaml_generator.py deleted file mode 100644 index fd38d56..0000000 --- a/resume_yaml_generator.py +++ /dev/null @@ -1,161 +0,0 @@ -import argparse -import yaml -from openai import OpenAI -import os -from typing import Dict, Any -import re -from jsonschema import validate, ValidationError -from pdfminer.high_level import extract_text -from loguru import logger - -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: - raise KeyError("No key as llm_api_key in the secret.yaml") - - 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) - - prompt = f""" - I'm sending you the content of a text-based resume. Your task is to interpret this content and generate a YAML file that conforms to the following schema structure. - The generated YAML should include all required fields and follow the structure defined in the schema. - - Pay special attention to the property attributes in the schema. These indicate the expected type and format for each field: - - 'type': Specifies the data type (e.g., string, object, array) - - 'format': Indicates a specific format for certain fields: - - 'date' format should be a valid date (e.g., YYYY-MM-DD) - - 'phone_prefix' format should be a valid country code with a '+' prefix (e.g., +1 for US) - - 'phone' format should be a valid phone number - - 'email' format should be a valid email address - - 'uri' format should be a valid URL - - 'enum': Provides a list of allowed values for a field - - Important instructions: - 1. Ensure that the YAML structure matches exactly with the provided schema. Use a dictionary structure that mirrors the schema. - 2. For all sections, if information is not explicitly provided in the resume, make a best guess based on the context of the resume. This is CRUCIAL for the following fields: - - languages: Infer from the resume content or make an educated guess. Use the 'enum' values for proficiency. - - interests: Deduce from the overall resume or related experiences. - - availability (notice_period): Provide a reasonable estimate (e.g., "2 weeks" or "1 month"). - - salary_expectations (salary_range_usd): Estimate based on experience level and industry standards. - - self_identification: Make reasonable assumptions based on the resume context. Use 'enum' values where provided. - - legal_authorization: Provide plausible values based on the resume information. Use 'Yes' or 'No' as per the 'enum' values. - - work_preferences: Infer from job history, skills, and overall resume tone. Use 'Yes' or 'No' as per the 'enum' values. - 3. For the fields mentioned in point 2, always provide a value. Do not leave them blank or omit them. - 4. For the 'key_responsibilities' field in 'experience_details', format the responsibilities as follows: - responsibility_1: "Description of first responsibility" - responsibility_2: "Description of second responsibility" - responsibility_3: "Description of third responsibility" - responsibility_4: "Description of fourth responsibility" - Continue this pattern for all responsibilities listed. - 5. In the 'experience_details' section, ensure that 'position' comes before 'company' in each entry. - 6. For the 'skills_acquired' field in 'experience_details', infer relevant skills based on the job responsibilities and industry. Do not leave this field empty. - 7. Make reasonable inferences for any missing dates, such as date_of_birth or employment dates, ensuring they follow the 'date' format. - 8. For array types (e.g., education_details, experience_details), ensure to include all required fields for each item as specified in the schema. - - Resume Text Content: - {resume_text} - - YAML Schema: - {yaml.dump(schema, default_flow_style=False)} - - Generate the YAML content that matches this schema based on the resume content provided, ensuring all format hints are followed and making educated guesses where necessary. Be sure to include best guesses for ALL fields, even if not explicitly mentioned in the resume. - Enclose your response in tags. Only include the YAML content within these tags, without any additional text or code block markers. - """ - - 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": "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: - return match.group(1).strip() - 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) - validate(instance=yaml_dict, schema=schema) - return {"valid": True, "errors": None} - 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" - if validation_result["valid"]: - report += "YAML is valid and conforms to the schema.\n" - else: - report += "YAML is not valid. Errors:\n" - report += validation_result["errors"] + "\n" - - logger.debug(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.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() - - try: - api_key = get_api_key() - schema = load_yaml("assets/resume_schema.yaml") - - # Check if input is PDF or TXT - if args.input.lower().endswith('.pdf'): - resume_text = pdf_to_text(args.input) - logger.debug(f"PDF resume converted to text successfully.") - else: - resume_text = load_resume_text(args.input) - - generated_yaml = generate_yaml_from_resume(resume_text, schema, api_key) - save_yaml(generated_yaml, args.output) - - logger.debug(f"Resume YAML generated and saved to {args.output}") - - validation_result = validate_yaml(generated_yaml, schema) - if validation_result["valid"]: - logger.debug("YAML is valid and conforms to the schema.") - else: - logger.error("YAML is not valid. Errors:") - logger.error(validation_result["errors"]) - - except Exception as e: - logger.error(f"An error occurred: {e}") - -if __name__ == "__main__": - main() From 946f8cdd11cc9e770a34d4eaccea987e028e4baa Mon Sep 17 00:00:00 2001 From: Federico <85809106+feder-cr@users.noreply.github.com> Date: Sat, 14 Sep 2024 13:48:10 +0200 Subject: [PATCH 97/97] Update README.md --- README.md | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/README.md b/README.md index 64405f0..04cecfc 100644 --- a/README.md +++ b/README.md @@ -484,27 +484,6 @@ Each section has specific fields to fill out: willing_to_undergo_drug_tests: "No" willing_to_undergo_background_checks: "Yes" ``` -### 4. Generating plain_text_resume.yaml from a PDF or Text Resume - -To simplify the process of creating your `plain_text_resume.yaml` file, you can use the provided script to generate it from a pdf-based or text-based resume. Follow these steps: - -1. Prepare your resume in a pdf (.pdf file) or plain text (.txt file) format. - -2. Place your resume in the `data_folder` directory. - -3. Run the following command: - - ```bash - python generate_resume_yaml.py --input data_folder/your_resume.[pdf|txt] --output data_folder/plain_text_resume.yaml - ``` - - Replace `your_resume.[pdf|txt]` with the actual name of your pdf or text resume file. - -4. The script will generate a `plain_text_resume.yaml` file in the `data_folder` directory. - -5. Review the generated YAML file and make any necessary adjustments to ensure all information is correct and complete. - -This automated process helps in creating a structured YAML file from your existing resume, saving time and reducing the chance of errors in manual data entry. ### PLUS. data_folder_example