├── .gitignore ├── README.md ├── Templates ├── config.yaml ├── job_filters.md ├── personal_data.md ├── plain_text_cover_letter.md └── plain_text_resume.md ├── gpt.py ├── img ├── DALL·E - bot app icon - oil painting.png ├── RoundedIcon.png ├── github-banner.jpg └── github-social-preview.jpg ├── linkedineasyapply.py ├── main.py ├── requirements.txt ├── test_gpt.py └── utils.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.csv 2 | __pycache__/** 3 | .idea/** 4 | open_ai_calls.log 5 | github banner.xd 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![LinkedIn GPT - Automated job applications tailored to you.](img/github-banner.jpg) 2 | 3 | # LinkedIn GPT 4 | Automatically apply to _LinkedIn Easy Apply_ jobs. This bot answers the application questions as well! 5 | 6 | This is a fork of a fork of the original _LinkedIn Easy Apply Bot_, but it is a very special fork of a fork, this one relies on LLMs to answer the questions. 7 | 8 | > This is for educational purposes only. I am not responsible if your LinkedIn account gets suspended or for anything else. 9 | 10 | This bot is written in Python using Selenium and OpenAI. 11 | 12 | ## Fork Notes 13 | The original bot implementation, couldn't handle open questions, just used keywords and predefined answers. Such couldn't complete a lot of the applications, as any open question or weird selector would make the bot unable to answer. Now that we have LLM, this is an easy problem to solve, just ask the bot to answer the question, and it will do it. 14 | 15 | Another great benefit, is that you can provide way more information to the bot, so it can address truthfully the job requirements, and the questions, just as you would do. 16 | 17 | I did try to tidy the code a bit, but I didn't want to spend too much time on it, as I just wanted to get it working, so there is still a lot of work to do there. 18 | 19 | Thank you for everyone that contributed to the original bot, and all the forks, made my work way easier. 20 | 21 | _by Jorge Frías_ 22 | 23 | ### Future updates 24 | - I will keep updating this fork as I use it for my own "educational research". 25 | - I will add features as I find fun applications, or I require them for my "educational research". 26 | 27 | ## Setup 28 | 29 | ### OpenAI API Key 30 | First you need to provide your Open AI API key using environment variable `OPEN_AI_API_KEY`. 31 | 32 | > [You can set up the environment variable in your venv](https://stackoverflow.com/a/20918496/8150874) 33 | 34 | I recommend to set a [Rate Limit](https://platform.openai.com/account/rate-limits) on your OpenAI account if you plan to leave the bot running for a long time, as it can get expensive quickly. I tried to use the cheapest models possible, but still requires `GPT-3.5-Turbo` to work. 35 | 36 | ### Your information 37 | Your information is provided with a directory containing the following files: 38 | - `config.yaml`. This file contains the information used to search on LinkedIn and fill in your personal information. Most of this is self-explanatory but if you need explanations please see the end of this `README`. 39 | - `plain_text_resume.md`. Will be used to answer the questions, it's provided in MarkDown format. 40 | - `plain_text_cover_letter.md`. Will be used when the form ask for a cover letter. When the form ask to write a cover letter (not upload it), the bot will adjust the cover letter to the job description. 41 | - You can use placeholders in your cover letter, a placeholder is defined as `[[placeholder]]`, the LLM will look onto the job description to fill in the placeholders. E.g. `[[company]]` will be replaced by the given company name. 42 | - `personal_data.md`. More information about you, what you want of the job search, work authorization, extended information not covered by the resume, etc. This will be used to answer the questions, and inform other parts of the application. This file doesn't have any structure, will be interpreted by the LLM so fell free to add structure or information as you see fit. 43 | - `job-filters.md`. This file gives you more control over the jobs that the bot applies to. There are two sections: `# Job Title Filters` and `# Job Description Filters` , these must be included on the document, these names are hardcoded on the script __do not modify them__. 44 | - `resume.pdf`. Will be uploaded to LinkedIn when applying. The resume file can have a different name as long as it is a pdf file and the name contains the word `resume`. E.g. `Michael_Scott_Resume.pdf`. 45 | - `cover_letter.pdf`. Will be uploaded to LinkedIn when applying if provided and the job application asks for it. The cover letter file can have a different name as long as it is a pdf file and the name contains the word `cover`. E.g. `Dwight_Schrute_Cover_Letter.pdf`. 46 | 47 | The `# Job Title Filters` section is used to filter the job title, and the `# Job Description Filters` section is used to filter the job description (once the job passes the job title filtering). The information on these sections is used on different steps of the process, you can have different rules on each section, or the same rules on both sections. 48 | 49 | Use natural language to explain what you are interested in, and what you are not. The LLM will try to understand what you mean, and will decide to apply or not to the job. 50 | 51 | > An `output` folder will be created, where you will find all generated answers to the questions. 52 | 53 | The folder approach enables you to have multiple configurations (based on locations, roles...), and switch between them easily. 54 | 55 | **You will find templates for all this files in the `templates` folder.** 56 | 57 | ### Install required libraries 58 | > You should use a `virtual environment` for this, but it is not required. 59 | ```bash 60 | pip3 install -r requirements.txt 61 | ``` 62 | 63 | ## Execute 64 | To run the bot, run the following in the command line, providing the path to your personal information directory as only argument. 65 | ```bash 66 | python3 main.py path/to/your/personal/iformation/directory 67 | ``` 68 | 69 | ## Config.yaml Explanations 70 | Just fill in your email and password for linkedin. 71 | ```yaml 72 | email: email@domain.com 73 | password: yourpassword 74 | ``` 75 | 76 | This prevents your computer from going to sleep so the bot can keep running when you are not using it. Set this to True if you want this disabled. 77 | ```yaml 78 | disableAntiLock: False 79 | ``` 80 | 81 | Set this to True if you want to look for remote jobs only. 82 | ```yaml 83 | remote: False 84 | ``` 85 | 86 | This is for what level of jobs you want the search to contain. You must choose at least one. 87 | ```yaml 88 | experienceLevel: 89 | internship: False 90 | entry: True 91 | associate: False 92 | mid-senior level: False 93 | director: False 94 | executive: False 95 | ``` 96 | 97 | This is for what type of job you are looking for. You must choose at least one. 98 | ```yaml 99 | jobTypes: 100 | full-time: True 101 | contract: False 102 | part-time: False 103 | temporary: False 104 | internship: False 105 | other: False 106 | volunteer: False 107 | ``` 108 | 109 | How far back you want to search. You must choose only one. 110 | ```yaml 111 | date: 112 | all time: True 113 | month: False 114 | week: False 115 | 24 hours: False 116 | ``` 117 | 118 | A list of positions you want to apply for. You must include at least one. 119 | ```yaml 120 | positions: 121 | #- First position 122 | #- A second position 123 | #- A third position 124 | #- ... 125 | ``` 126 | 127 | A list of locations you are applying to. You must include at least one. 128 | ```yaml 129 | locations: 130 | #- First location 131 | #- A second location 132 | #- A third location 133 | #- ... 134 | - Remote 135 | ``` 136 | 137 | How far out of the location you want your search to go. You can only input 0, 5, 10, 25, 50, 100 miles. 138 | ```yaml 139 | distance: 25 140 | ``` 141 | 142 | A list of companies to not apply to. 143 | ```yaml 144 | companyBlacklist: 145 | #- company 146 | #- company2 147 | ``` 148 | 149 | A list of words that will be used to skip over jobs with any of these words in there. 150 | ```yaml 151 | titleBlacklist: 152 | #- word1 153 | #- word2 154 | ``` 155 | 156 | Input your personal info. Include the state/province in the city name to not get the wrong city when choosing from a dropdown. 157 | The phone country code needs to be exact for the one that is on linkedin. 158 | The website is interchangeable for github/portfolio/website. 159 | > This information should also be provided on `personal_data.md`. 160 | ```yaml 161 | # ------------ Additional parameters: personal info --------------- 162 | personalInfo: 163 | First Name: FirstName 164 | Last Name: LastName 165 | Phone Country Code: Canada (+1) # See linkedin for your country code, must be exact according to the international platform, i.e. Italy (+39) not Italia (+39) 166 | Mobile Phone Number: 1234567890 167 | Street address: 123 Fake Street 168 | City: Red Deer, Alberta # Include the state/province as well! 169 | State: YourState 170 | Zip: YourZip/Postal 171 | Linkedin: https://www.linkedin.com/in/my-linkedin-profile 172 | Website: https://www.my-website.com # github/website is interchangeable here 173 | ``` 174 | 175 | # Known issues 176 | - The bot not always replaces correctly the placeholders on the cover letter. 177 | - If any field has problems with the answer, e.g. expected a number and the bot generated a text, the application will not proceed. 178 | - Usually the first screen asking for contact information also ask for a `summary`, gpt doesn't fill this screen, so the application will not proceed. 179 | -------------------------------------------------------------------------------- /Templates/config.yaml: -------------------------------------------------------------------------------- 1 | email: email@domain.com 2 | password: yourpassword 3 | 4 | disableAntiLock: False 5 | 6 | remote: True 7 | 8 | experienceLevel: 9 | internship: False 10 | entry: True 11 | associate: False 12 | mid-senior level: False 13 | director: False 14 | executive: False 15 | 16 | jobTypes: 17 | full-time: True 18 | contract: True 19 | part-time: False 20 | temporary: True 21 | internship: False 22 | other: False 23 | volunteer: False 24 | 25 | date: 26 | all time: True 27 | month: False 28 | week: False 29 | 24 hours: False 30 | 31 | positions: 32 | #- First position 33 | #- A second position 34 | #- A third position 35 | #- ... 36 | locations: 37 | #- First location 38 | #- A second location 39 | #- A third location 40 | #- ... 41 | - Remote 42 | 43 | distance: 25 44 | 45 | companyBlacklist: 46 | #- company 47 | #- company2 48 | 49 | titleBlacklist: 50 | #- word1 51 | #- word2 52 | 53 | posterBlacklist: 54 | #- name1 55 | #- name2 56 | 57 | # ------------ Additional parameters: personal info --------------- 58 | personalInfo: 59 | First Name: FirstName 60 | Last Name: LastName 61 | Phone Country Code: Canada (+1) # See linkedin for your country code, must be exact according to the international platform, i.e. Italy (+39) not Italia (+39) 62 | Mobile Phone Number: 1234567890 63 | Street address: 123 Fake Street 64 | City: Red Deer, Alberta # Include the state/province as well! 65 | State: YourState 66 | Zip: YourZip/Postal 67 | Linkedin: https://www.linkedin.com/in/my-linkedin-profile 68 | Website: https://www.my-website.com # github/website is interchangeable here 69 | -------------------------------------------------------------------------------- /Templates/job_filters.md: -------------------------------------------------------------------------------- 1 | # About this document 2 | On this document, explain the rules to filter through job postings. 3 | 4 | Both `# Job Title Filters` and `# Job Description Filters` sections, must be included on the document, these names are hardcoded on the script __do not modify them__. 5 | 6 | The `# Job Title Filters` section is used to filter the job title, and the `# Job Description Filters` section is used to filter the job description, once the job passes the job title filtering. The information on these sections is used on different steps of the process, you can have different rules on each section, or the same rules on both sections. 7 | 8 | ----- 9 | 10 | # Job Title Filters 11 | I'm looking for jobs that are close to (but not exclusively): Senior Developer, Frontend Developer, IOS Developer, Product Manager. Other roles similar to these are also acceptable. 12 | 13 | Also, I'm not interested in junior positions, I'm looking for a mid-level position or higher. 14 | 15 | Also, there are some industries I'm not interested in: blockchain and healthcare. 16 | 17 | # Job Description Filters 18 | I'm looking for jobs that are close to (but not exclusively): Senior Developer, Frontend Developer, IOS Developer, Product Manager. Other roles similar to these are also acceptable. 19 | 20 | I'm not interested in junior positions. I'm looking for a mid-level positions or higher. 21 | 22 | I seek responsibilities that are close to (but not exclusively): UX design, product design, agile coding practices, animations, and web APIs usage. 23 | 24 | Ideal roles are those where UX/UI design knowledge is required, as well as, experience on agile methodologies, and startup experience. A job not meeting these requirements is still acceptable. 25 | 26 | I'm not interested on certain sectors as blockchain, healthcare or accounting. Nor I'm interested in Project Manager roles, I want to be a developer that's it. 27 | -------------------------------------------------------------------------------- /Templates/personal_data.md: -------------------------------------------------------------------------------- 1 | Personal Info 2 | 3 | | Field | Value | 4 | |------------|---------------------------------------| 5 | | First name | Michael | 6 | | Last name | Fredericson | 7 | | Phone | +1 1234567890 | 8 | | Email | michael@gmail.com | 9 | | Address | 7717 Rushcreek Rd NW, Rushville, Ohio | 10 | | Website | https://pocketpassmanager.com | 11 | | LinkedIn | https://www.linkedin.com/in/johndoe/ | 12 | | GitHub | https://github.com/michael-fred | 13 | | Twitter | https://twitter.com/michael-fred | 14 | 15 | 16 | | Language | Level | 17 | |----------|---------------------| 18 | | English | Native or bilingual | 19 | | Spanish | Native or bilingual | 20 | 21 | 22 | | Self Ident | Value | 23 | |------------|-------------------| 24 | | Gender | prefer not to say | 25 | | Veteran | prefer not to say | 26 | | Disability | prefer not to say | 27 | | Ethnicity | prefer not to say | 28 | 29 | 30 | Legal authorization to work in... 31 | 32 | | Field | Value | 33 | |-----------------------------------|-----------------------| 34 | | Requires US visa | Yes | 35 | | Legally allowed to work in the US | No | 36 | | Requires US sponsorship | Yes | 37 | | Requires UE visa | No | 38 | | Legally allowed to work in the UE | Yes | 39 | | Requires UE sponsorship | No | 40 | 41 | 42 | Most yes or no, are "are you open to ..." 43 | 44 | | Field | Value | 45 | |-----------------------------------|-----------------------| 46 | | Has drivers license | Yes | 47 | | Relocation | Yes | 48 | | Remote work | Yes | 49 | | In-person work | Yes | 50 | | Willing to complete an assessment | Yes | 51 | | Drug test | Yes | 52 | | Background check | Yes | 53 | | Studies | up to Master's degree | 54 | | University GPA | 3.5 | 55 | | Salary min | 200k EUR, 240 USD | 56 | 57 | 58 | General experience (more detailed on the resume) 59 | 60 | | Experience | Years | 61 | |------------------------|-------| 62 | | Art Creative | 10 | 63 | | Business | 5 | 64 | | Engineering | 6 | 65 | | Information Technology | 5 | 66 | | Marketing | 3 | 67 | | Product Management | 5 | 68 | | Project Management | 5 | 69 | | User Experience | 5 | 70 | | User research | 5 | 71 | 72 | 73 | Soft skills... 74 | 75 | | Skill | 76 | |-------------------| 77 | | Team building | 78 | | Leadership | 79 | | Communication | 80 | | Collaboration | 81 | | Problem solving | 82 | | Critical thinking | 83 | | Adaptability | 84 | | Perfectionism | 85 | -------------------------------------------------------------------------------- /Templates/plain_text_cover_letter.md: -------------------------------------------------------------------------------- 1 | Dear Hiring Manager, 2 | 3 | I am writing to apply for the [[position]] position at [[company]]. With a Bachelor of Science in Computer Science and 2 years of experience specializing in iOS app development, I am confident in my ability to contribute to innovative mobile solutions. 4 | 5 | I have a strong command of iOS frameworks such as UIKit, Core Data, and Core Animation, and I am proficient in Swift and Objective-C. I have a proven track record of delivering high-quality products, meeting deadlines, and collaborating effectively with cross-functional teams. 6 | 7 | I am excited to bring my expertise in developing key features and resolving bugs to your team. Projects like SocialConnect and eShop demonstrate my leadership in implementing user authentication, real-time messaging, and push notifications, as well as integrating RESTful APIs and optimizing app performance with Core Data. 8 | 9 | As an Apple Certified iOS Developer, I stay up-to-date with the latest trends and technologies. I possess excellent problem-solving and communication skills, and I am committed to driving the development of cutting-edge mobile solutions. 10 | 11 | I am confident that my technical skills and motivation make me an excellent fit for this position. Thank you for considering my application. I have attached my resume and look forward to the opportunity to discuss my qualifications further. 12 | 13 | Sincerely, 14 | Michael Fredericson -------------------------------------------------------------------------------- /Templates/plain_text_resume.md: -------------------------------------------------------------------------------- 1 | # Michael Fredericson 2 | Add7717 Rushcreek Rd NW, Rushville, Ohio(OH), 43150 3 | +1 1234567890 | michael@gmail.com 4 | 5 | ## Objective: 6 | Highly skilled and motivated software developer with 2 years of experience specializing in building iOS applications. Seeking a challenging position to leverage my technical expertise and contribute to the development of innovative mobile solutions. 7 | 8 | ## Education: 9 | - **Bachelor of Science in Computer Science** 10 | Ohio State University, Columbus, Ohio 11 | Year of Graduation: 2020 12 | 13 | ## Skills: 14 | - Proficient in iOS app development using Swift and Objective-C 15 | - Strong knowledge of iOS frameworks such as UIKit, Core Data, and Core Animation 16 | - Experience with version control systems (Git) 17 | - Familiarity with RESTful APIs and JSON 18 | - Solid understanding of software development principles and best practices 19 | - Excellent problem-solving and debugging skills 20 | - Strong verbal and written communication skills 21 | - Ability to work effectively in both team and individual settings 22 | 23 | ## Experience: 24 | ### Software Developer 25 | **XYZ Software Company, San Francisco, CA** 26 | April 2021 - Present 27 | 28 | - Developed and maintained iOS applications for a diverse client base, consistently meeting project deadlines and client requirements. 29 | - Collaborated with cross-functional teams, including designers and backend developers, to ensure seamless integration and delivery of high-quality products. 30 | - Implemented new features and enhancements, resolving bugs and improving overall app performance. 31 | - Conducted thorough testing and debugging to identify and resolve issues, ensuring a smooth user experience. 32 | - Stayed up-to-date with the latest trends and technologies in the iOS development ecosystem, actively incorporating new tools and frameworks into projects. 33 | 34 | ## Projects: 35 | 1. **SocialConnect** 36 | - *Description:* Developed a social media app for iOS, allowing users to connect and share content. 37 | - *Technologies used:* Swift, UIKit, Firebase 38 | - *Contribution:* Led the development of key features, including user authentication, real-time messaging, and push notifications. Implemented Firebase backend services for data storage and retrieval. 39 | 40 | 2. **eShop** 41 | - *Description:* Created an e-commerce app for iOS, enabling users to browse and purchase products. 42 | - *Technologies used:* Swift, Objective-C, Core Data, RESTful API 43 | - *Contribution:* Implemented the user interface, integrated API endpoints for product retrieval, and optimized data caching for improved performance. Designed and implemented the shopping cart functionality using Core Data for local data storage. 44 | 45 | ## Certifications: 46 | - Apple Certified iOS Developer (2022) 47 | 48 | ## References: 49 | Available upon request -------------------------------------------------------------------------------- /gpt.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import textwrap 4 | from datetime import datetime 5 | from typing import Optional, List, Mapping, Any 6 | from utils import Markdown 7 | from langchain import PromptTemplate, OpenAI 8 | from langchain.chat_models import ChatOpenAI 9 | from langchain.callbacks.manager import CallbackManagerForLLMRun 10 | from langchain.chains.router import MultiPromptChain 11 | from langchain.chains import ConversationChain 12 | from langchain.chains.llm import LLMChain 13 | from langchain.chains.router.llm_router import LLMRouterChain, RouterOutputParser 14 | from langchain.chains.router.multi_prompt_prompt import MULTI_PROMPT_ROUTER_TEMPLATE 15 | from langchain.chat_models.base import BaseChatModel, SimpleChatModel 16 | from langchain.llms.base import LLM 17 | from Levenshtein import distance 18 | from langchain.schema import BaseMessage 19 | import inspect 20 | 21 | 22 | class LLMLogger: 23 | """ 24 | Logs the requests and responses to a file, to be able to analyze the performance of the model. 25 | """ 26 | def __init__(self, llm: LLM): 27 | self.llm = llm 28 | 29 | @staticmethod 30 | def log_request(model: str, prompt: str, reply: str): 31 | 32 | calls_log = os.path.join(os.getcwd(), "open_ai_calls.log") 33 | f = open(calls_log, 'a') 34 | 35 | # Current time to log 36 | time = datetime.now().strftime("%Y/%m/%d %H:%M:%S") 37 | 38 | f.write(f"\n") 39 | f.write(prompt) 40 | f.write('\n') 41 | f.write('\n') 42 | f.write('\n') 43 | f.write(reply) 44 | f.write('\n') 45 | f.write('\n') 46 | f.write('\n\n') 47 | f.close() 48 | 49 | 50 | class LoggerLLMModel(LLM): 51 | import langchain 52 | llm: langchain.llms.openai.OpenAI 53 | 54 | @property 55 | def _llm_type(self) -> str: 56 | return "custom" 57 | 58 | def _call( 59 | self, 60 | prompt: str, 61 | stop: Optional[List[str]] = None, 62 | run_manager: Optional[CallbackManagerForLLMRun] = None, 63 | ) -> str: 64 | 65 | reply = self.llm(prompt) 66 | LLMLogger.log_request(self.llm.model_name, prompt, reply) 67 | 68 | return reply 69 | 70 | 71 | class LoggerChatModel(SimpleChatModel): 72 | import langchain 73 | llm: langchain.chat_models.openai.ChatOpenAI 74 | 75 | @property 76 | def _llm_type(self) -> str: 77 | return "custom" 78 | 79 | def _call( 80 | self, 81 | messages: List[BaseMessage], 82 | stop: Optional[List[str]] = None, 83 | run_manager: Optional[CallbackManagerForLLMRun] = None, 84 | ) -> str: 85 | 86 | reply = self.llm.generate([messages], stop=stop, callbacks=run_manager) 87 | LLMLogger.log_request(self.llm.model_name, str(messages), str(reply.generations)) 88 | 89 | return reply.generations[0][0].text 90 | 91 | 92 | class GPTAnswerer: 93 | # TODO: template = textwrap.dedent(template) all templates 94 | def __init__(self, resume: str, personal_data: str, cover_letter: str, job_filtering_rules: str): 95 | """ 96 | Initializes the GPTAnswerer. 97 | :param resume: The resume text, preferably in Markdown format. 98 | :param personal_data: The personal data text, preferably in Markdown format, following the template, but any text is fine. 99 | :param cover_letter: The cover letter text, preferably in Markdown format, use placeholders as [position], [company], etc. 100 | """ 101 | self.resume = resume 102 | self.personal_data = personal_data 103 | self.cover_letter = cover_letter 104 | self._job_description = "" 105 | self.job_description_summary = "" 106 | self.job_filtering_rules = job_filtering_rules 107 | ''' 108 | Two lists of job titles, a whitelist and a blacklist. 109 | ``` 110 | Titles whitelist: Something, Something else, Another thing 111 | Titles blacklist: I don't want this, I don't want that 112 | ``` 113 | ''' 114 | 115 | # Wrapping the models on a logger to log the requests and responses 116 | self.llm_cheap = LoggerChatModel(llm=ChatOpenAI(model_name="gpt-3.5-turbo-0613", openai_api_key=GPTAnswerer.openai_api_key(), temperature=0.5)) 117 | """ 118 | The cheapest model that can handle most tasks. 119 | 120 | Currently using the GPT-3.5 Turbo model. 121 | """ 122 | self.llm_expensive = LoggerLLMModel(llm=OpenAI(model_name="text-davinci-003", openai_api_key=GPTAnswerer.openai_api_key(), temperature=0.5)) 123 | """ 124 | The most expensive model, used for the tasks GPT-3.5 Turbo can't handle. 125 | 126 | Currently using the Davinci model x10 more expensive than the GPT-3.5 Turbo model. 127 | """ 128 | 129 | @property 130 | def job_description(self): 131 | return self._job_description 132 | 133 | @job_description.setter 134 | def job_description(self, value): 135 | self._job_description = value 136 | self.job_description_summary = self.summarize_job_description(value) 137 | 138 | @staticmethod 139 | def openai_api_key(): 140 | """ 141 | Returns the OpenAI API key. 142 | environment variable used: OPEN_AI_API_KEY 143 | Returns: The OpenAI API key. 144 | """ 145 | key = os.getenv('OPEN_AI_API_KEY') 146 | 147 | if key is None: 148 | raise Exception("OpenAI API key not found. Please set the OPEN_AOI_API_KEY environment variable.") 149 | 150 | return key 151 | 152 | @staticmethod 153 | def _preprocess_template_string(template: str) -> str: 154 | """ 155 | Preprocesses the template string, removing the leading tabs -> less tokens to process. 156 | :param template: 157 | :return: 158 | """ 159 | # Remove the leading tabs from the multiline string 160 | processed_template = textwrap.dedent(template) 161 | return processed_template 162 | 163 | def summarize_job_description(self, text: str) -> str: 164 | """ 165 | Summarizes the text using the OpenAI API. 166 | Args: 167 | text: The text to summarize. 168 | Returns: 169 | The summarized text. 170 | """ 171 | summarize_prompt_template = """ 172 | The following is a summarized job description, following the rules and the template below. 173 | 174 | # Rules 175 | - Remove boilerplate text 176 | - Only relevant information to match the job description against the resume. 177 | 178 | # Job Description: 179 | ``` 180 | {text} 181 | ``` 182 | 183 | --- 184 | 185 | # Job Description Summary""" 186 | 187 | summarize_prompt_template = self._preprocess_template_string(summarize_prompt_template) 188 | 189 | prompt = PromptTemplate(input_variables=["text"], template=summarize_prompt_template) # Define the prompt (template) 190 | chain = LLMChain(llm=self.llm_cheap, prompt=prompt) 191 | output = chain.run(text=text) 192 | 193 | # Remove all spaces after new lines, until no more spaces are found 194 | while "\n " in output: 195 | output = output.replace("\n ", "\n") 196 | 197 | return output 198 | 199 | def answer_question_textual_wide_range(self, question: str) -> str: 200 | """ 201 | Answers a question using the resume, personal data, cover letter, and job description. 202 | :param question: The question to answer. 203 | """ 204 | # Can answer questions from the resume, personal data, and cover letter. Deciding which context is relevant. So we don't create a very large prompt concatenating all the data. 205 | # Prompt templates: 206 | # - Resume stuff + Personal data. 207 | # - Cover letter -> personalize to the job description 208 | # - Summary -> Resume stuff + Job description (summary) 209 | 210 | # Templates: 211 | # - Resume Stuff 212 | resume_stuff_template = """ 213 | The following is a resume, personal data, and an answered question using this information, being answered by the person who's resume it is (first person). 214 | 215 | ## Rules 216 | - Answer questions directly (if possible) 217 | - If seems likely that you have the experience, even if is not explicitly defined, answer as if you have the experience 218 | - If you cannot answer the question, answer things like "I have no experience with that, but I learn fast, very fast", "not yet, but I will learn"... 219 | - The answer must not be longer than a tweet (140 characters) 220 | - Only add periods if the answer has multiple sentences/paragraphs 221 | 222 | ## Example 1 223 | Resume: I'm a software engineer with 10 years of experience on both swift and python. 224 | Question: What is your experience with swift? 225 | Answer: 10 years 226 | 227 | ## Example 2 228 | Resume: Mick Jagger. I'm a software engineer with 4 years of experience on both C++ and python. 229 | Question: What is your full name? 230 | Answer: Mick Jagger 231 | 232 | ----- 233 | 234 | ## Extended personal data: 235 | ``` 236 | {personal_data} 237 | ``` 238 | 239 | ## Resume: 240 | ``` 241 | {resume} 242 | ``` 243 | 244 | ## Question: 245 | {question} 246 | 247 | ## Answer:""" 248 | 249 | # - Cover Letter 250 | cover_letter_template = """ 251 | The following is a cover letter, vey slightly modified to better address the job description. 252 | 253 | If the question is "cover letter," answer with the modified cover letter. 254 | 255 | ## Rules 256 | - The signature name is unchanged, it's the real name of the person who's resume it is (who's answering the questions). 257 | - The cover letter is preserved almost untouched, it's very slightly modified to better match the job description keywords. 258 | - All personal paragraphs aren't modified at all. 259 | - Only paragraphs about why the person is a good fit for the job are modified. 260 | - All placeholders [[placeholder]] are replaced with the appropriate information from the job description. 261 | - When there is no information to fill in a placeholder, it's removed and the text is adapted accordingly. 262 | - The structure and meaning of the cover letter is keep untouched, only the keywords and placeholders are modified. 263 | 264 | ## Job Description: 265 | ``` 266 | {job_description} 267 | ``` 268 | 269 | ## Cover Letter: 270 | ``` 271 | {cover_letter} 272 | ``` 273 | 274 | ## Question: 275 | {question} 276 | 277 | ## Custom Cover Letter:""" 278 | 279 | # - Summary 280 | summary_template = """ 281 | The following is a resume, a job description, and an answered question using this information, being answered by the person who's resume it is (first person). 282 | 283 | ## Rules 284 | - Answer questions directly. 285 | - If seems likely that you have the experience, even if is not explicitly defined, answer as if you have the experience. 286 | - Find relations between the job description and the resume, and answer questions about that. 287 | - Only add periods if the answer has multiple sentences/paragraphs. 288 | 289 | 290 | ## Job Description: 291 | ``` 292 | {job_description} 293 | ``` 294 | 295 | ## Resume: 296 | ``` 297 | {resume} 298 | ``` 299 | 300 | ## Question: 301 | {question} 302 | 303 | ## Answer:""" 304 | 305 | prompt_infos = [ 306 | { 307 | "name": "resume", 308 | "description": "Good for answering questions about job experience, skills, education, and personal data. Questions like 'experience with python', 'education', 'full name', 'social networks', 'links of interest', etc.", 309 | "prompt_template": resume_stuff_template 310 | }, 311 | { 312 | "name": "cover letter", 313 | "description": "Addressing questions about the cover letter and personal characteristics about the role. Questions like 'cover letter', 'why do you want to work here?', 'Your message to the hiring manager', etc.", 314 | "prompt_template": cover_letter_template 315 | }, 316 | { 317 | "name": "summary", 318 | "description": "Good for answering questions about the job description, and how I will fit into the company or the role. Questions like, summary of the resume, why you are a good fit, etc.", 319 | "prompt_template": summary_template 320 | } 321 | ] 322 | 323 | # Preprocess the templates 324 | resume_stuff_template = self._preprocess_template_string(resume_stuff_template) 325 | resume_stuff_template = self._preprocess_template_string(resume_stuff_template) 326 | resume_stuff_template = self._preprocess_template_string(resume_stuff_template) 327 | 328 | # Create the chains, using partials to fill in the data, as the MultiPromptChain does not support more than one input variable. 329 | # - Resume Stuff 330 | resume_stuff_prompt_template = PromptTemplate(template=resume_stuff_template, input_variables=["personal_data", "resume", "question"]) 331 | resume_stuff_prompt_template = resume_stuff_prompt_template.partial(personal_data=self.personal_data, resume=self.resume, question=question) 332 | resume_stuff_chain = LLMChain( 333 | llm=self.llm_cheap, 334 | prompt=resume_stuff_prompt_template 335 | ) 336 | # - Cover Letter 337 | cover_letter_prompt_template = PromptTemplate(template=cover_letter_template, input_variables=["cover_letter", "job_description", "question"]) 338 | cover_letter_prompt_template = cover_letter_prompt_template.partial(cover_letter=self.cover_letter, job_description=self.job_description_summary, question=question) 339 | cover_letter_chain = LLMChain( 340 | llm=self.llm_cheap, 341 | prompt=cover_letter_prompt_template 342 | ) 343 | # - Summary 344 | summary_prompt_template = PromptTemplate(template=summary_template, input_variables=["resume", "job_description", "question"]) 345 | summary_prompt_template = summary_prompt_template.partial(resume=self.resume, job_description=self.job_description_summary, question=question) 346 | summary_chain = LLMChain( 347 | llm=self.llm_cheap, 348 | prompt=summary_prompt_template 349 | ) 350 | 351 | # Create the router chain 352 | destination_chains = {"resume": resume_stuff_chain, "cover letter": cover_letter_chain, "summary": summary_chain} 353 | default_chain = ConversationChain(llm=self.llm_cheap, output_key="text") # Is it a ConversationChain? Or a LLMChain? Or a MultiPromptChain? 354 | destinations = [f"{p['name']}: {p['description']}" for p in prompt_infos] 355 | destinations_str = "\n".join(destinations) 356 | router_template = MULTI_PROMPT_ROUTER_TEMPLATE.format( 357 | destinations=destinations_str 358 | ) 359 | router_prompt = PromptTemplate( 360 | template=router_template, 361 | input_variables=["input"], 362 | output_parser=RouterOutputParser(), 363 | ) 364 | 365 | # TODO: This is expensive. Test with new models / new versions of langchain. 366 | # TODO: PR Langchain with a fix to this that can use gpt3, because the problem is with the prompt/handling of the output, as expects a JSON. 367 | router_chain = LLMRouterChain.from_llm(self.llm_expensive, router_prompt) # Using the advanced LLM, as is the only one that seems to work with the router chain expected output format. 368 | 369 | chain = MultiPromptChain(router_chain=router_chain, destination_chains=destination_chains, default_chain=resume_stuff_chain, verbose=True) 370 | 371 | result = chain({"input": question}) 372 | result_text = result["text"].strip() 373 | 374 | # Sometimes the LLM leaves behind placeholders, we need to remove them 375 | result_text = self._remove_placeholders(result_text) 376 | 377 | return result_text 378 | 379 | def answer_question_textual(self, question: str) -> str: 380 | template = """The following is a resume and an answered question about the resume, being answered by the person who's resume it is (first person). 381 | 382 | ## Rules 383 | - Answer the question directly, if possible. 384 | - If seems likely that you have the experience based on the resume, even if is not explicit on the resume, answer as if you have the experience. 385 | - If you cannot answer the question, answer things like "I have no experience with that, but I learn fast, very fast", "not yet, but I will learn". 386 | - The answer must not be larger than a tweet (140 characters). 387 | - Answer questions directly. eg. "Full Name" -> "John Oliver", "Experience with python" -> "10 years" 388 | 389 | ## Example 390 | Resume: I'm a software engineer with 10 years of experience on both swift and python. 391 | Question: What is your experience with swift? 392 | Answer: 10 years. 393 | 394 | ----- 395 | 396 | ## Extended personal data: 397 | ``` 398 | {personal_data} 399 | ``` 400 | 401 | ## Resume: 402 | ``` 403 | {resume} 404 | ``` 405 | 406 | ## Question: 407 | {question} 408 | 409 | ## Answer:""" 410 | 411 | template = self._preprocess_template_string(template) 412 | 413 | prompt = PromptTemplate(input_variables=["personal_data", "resume", "question"], template=template) # Define the prompt (template) 414 | chain = LLMChain(llm=self.llm_cheap, prompt=prompt) 415 | output = chain.run(personal_data=self.personal_data, resume=self.resume, question=question) 416 | 417 | return output 418 | 419 | def answer_question_numeric(self, question: str, default_experience: int = 0) -> int: 420 | template = """The following is a resume and an answered question about the resume, the answer is an integer number. 421 | 422 | ## Rules 423 | - The answer must be an integer number. 424 | - The answer must only contain digits. 425 | - If you cannot answer the question, answer {default_experience}. 426 | 427 | ## Example 428 | Resume: I'm a software engineer with 10 years of experience on swift and python. 429 | Question: How many years of experience do you have on swift? 430 | Answer: 10 431 | 432 | ----- 433 | 434 | ## Extended personal data: 435 | ``` 436 | {personal_data} 437 | ``` 438 | 439 | ## Resume: 440 | ``` 441 | {resume} 442 | ``` 443 | 444 | ## Question: 445 | {question} 446 | 447 | ## Answer:""" 448 | 449 | template = self._preprocess_template_string(template) 450 | 451 | prompt = PromptTemplate(input_variables=["default_experience", "personal_data", "resume", "question"], template=template) # Define the prompt (template) 452 | chain = LLMChain(llm=self.llm_cheap, prompt=prompt) 453 | output_str = chain.run(personal_data=self.personal_data, resume=self.resume, question=question, default_experience=default_experience) 454 | 455 | # Convert to int with error handling 456 | try: 457 | output = int(output_str) # Convert the output to an integer 458 | except ValueError: 459 | output = default_experience # If the output is not an integer, return the default experience 460 | print(f"Error: The output of the LLM is not an integer number. The default experience ({default_experience}) will be returned instead. The output was: {output_str}") 461 | 462 | return output 463 | 464 | def answer_question_from_options(self, question: str, options: list[str]) -> str: 465 | template = """The following is a resume and an answered question about the resume, the answer is one of the options. 466 | 467 | ## Rules 468 | - The answer must be one of the options. 469 | - The answer must exclusively contain one of the options. 470 | - Answer the option that seems most likely based on the resume. 471 | - Never choose the default/placeholder option, examples are: 'Select an option', 'None', 'Choose from the options below', etc. 472 | 473 | ## Example 474 | Resume: I'm a software engineer with 10 years of experience on swift, python, C, C++. 475 | Question: How many years of experience do you have on python? 476 | Options: [1-2, 3-5, 6-10, 10+] 477 | Answer: 10+ 478 | 479 | ----- 480 | 481 | ## Extended personal data: 482 | ``` 483 | {personal_data} 484 | ``` 485 | 486 | ## Resume: 487 | ``` 488 | {resume} 489 | ``` 490 | 491 | ## Question: 492 | {question} 493 | 494 | ## Options: 495 | {options} 496 | 497 | ## Answer:""" 498 | 499 | template = self._preprocess_template_string(template) 500 | 501 | prompt = PromptTemplate(input_variables=["personal_data", "resume", "question", "options"], template=template) # Define the prompt (template) 502 | # formatted_prompt = prompt.format_prompt(personal_data=self.personal_data, resume=self.resume, question=question, options=options) # Format the prompt with the data 503 | # output = self.llm(formatted_prompt.to_string()) # Send the prompt to the llm 504 | chain = LLMChain(llm=self.llm_cheap, prompt=prompt) 505 | output = chain.run(personal_data=self.personal_data, resume=self.resume, question=question, options=options) 506 | 507 | # Guard the output is one of the options 508 | if output not in options: 509 | output = self._closest_matching_option(output, options) 510 | 511 | return output 512 | 513 | @staticmethod 514 | def _closest_matching_option(to_match: str, options: list[str]) -> str: 515 | """ 516 | Choose the closest option to the output, using a levenshtein distance. 517 | """ 518 | # Choose the closest option to the output, using a levenshtein distance 519 | closest_option = min(options, key=lambda option: distance(to_match, option)) 520 | return closest_option 521 | 522 | @staticmethod 523 | def _contains_placeholder(text: str) -> bool: 524 | """ 525 | Check if the text contains a placeholder. A placeholder is a string like "[placeholder]". 526 | """ 527 | # pattern = r"\[\w+\]" # Matches "[placeholder]" 528 | pattern = r"\[\[([^\]]+)\]\]" # Matches "[[placeholder]]" 529 | match = re.search(pattern, text) 530 | return match is not None 531 | 532 | def _remove_placeholders(self, text: str) -> str: 533 | """ 534 | Remove the placeholder from the text, using the llm. The placeholder is a string like "[[placeholder]]". 535 | 536 | Does nothing if the text does not contain a placeholder. 537 | """ 538 | summarize_prompt_template = """ 539 | Following are two texts, one with placeholders and one without, the second text uses information from the first text to fill the placeholders. 540 | 541 | ## Rules 542 | - A placeholder is a string like "[[placeholder]]". E.g. "[[company]]", "[[job_title]]", "[[years_of_experience]]"... 543 | - The task is to remove the placeholders from the text. 544 | - If there is no information to fill a placeholder, remove the placeholder, and adapt the text accordingly. 545 | - No placeholders should remain in the text. 546 | 547 | ## Example 548 | Text with placeholders: "I'm a software engineer engineer with 10 years of experience on [placeholder] and [placeholder]." 549 | Text without placeholders: "I'm a software engineer with 10 years of experience." 550 | 551 | ----- 552 | 553 | ## Text with placeholders: 554 | {text_with_placeholders} 555 | 556 | ## Text without placeholders:""" 557 | 558 | summarize_prompt_template = self._preprocess_template_string(summarize_prompt_template) 559 | 560 | result = text 561 | 562 | # Max number of iterations to avoid infinite loops 563 | max_iterations = 5 564 | concurrent_iterations = 0 565 | 566 | # Remove the placeholder from the text, loop until there are no more placeholders 567 | while self._contains_placeholder(result) and concurrent_iterations < max_iterations: 568 | prompt = PromptTemplate(input_variables=["text_with_placeholders"], template=summarize_prompt_template) # Define the prompt (template) 569 | chain = LLMChain(llm=self.llm_cheap, prompt=prompt) 570 | output = chain.run(text_with_placeholders=result) 571 | 572 | result = output 573 | concurrent_iterations += 1 574 | 575 | return result 576 | 577 | def job_title_passes_filters(self, job_title: str) -> bool: 578 | """ 579 | Check if the job title passes the filters. The filters are a whitelist and a blacklist of job titles. 580 | :param job_title: The job title to check. 581 | :return: True if the job title passes the filters, False otherwise. 582 | """ 583 | 584 | template = """ 585 | Given a job title and a set of preferences, determine if the person would be interested in the job. 586 | 587 | More detailed rules: 588 | - Respond with either 'yes' or 'no'. 589 | - Respond 'yes' if the job title could of interest for the person. 590 | - Respond 'no' if the job title seems irrelevant. 591 | 592 | ----- 593 | 594 | ## Job title: {job_title} 595 | ## User preferences: 596 | {job_title_filters} 597 | ## Seems of interest: """ 598 | 599 | # Remove the leading tabs from the multiline string 600 | template = self._preprocess_template_string(template) 601 | 602 | # Extract the whitelist and blacklist from the job filtering rules 603 | job_title_filters = Markdown.extract_content_from_markdown(self.job_filtering_rules, "Job Title Filters") 604 | # TODO: Raise an exception if the job title filters are not found 605 | 606 | prompt = PromptTemplate(input_variables=["job_title", "job_title_filters"], template=template) 607 | chain = LLMChain(llm=self.llm_cheap, prompt=prompt) 608 | output = chain.run(job_title=job_title, job_title_filters=job_title_filters) 609 | 610 | # Guard the output is one of the options 611 | if output.lower() not in ['yes', 'no']: 612 | output = self._closest_matching_option(output, ['yes', 'no']) 613 | 614 | # Return the output as a boolean 615 | return output.lower() == 'yes' 616 | 617 | def job_description_passes_filters(self) -> bool: 618 | # Consider to add the resume to make a more informed decision, right now the responsibility to match resume against job description is on the recruiter. 619 | # This approach applies to what the user is interested in, not what the user is qualified for. 620 | 621 | template = """ 622 | Given a job description and a set of preferences, determine if the person would be interested in the job. 623 | 624 | More detailed rules: 625 | - Respond with either 'yes' or 'no'. 626 | - Respond 'yes' if the job title could of interest for the person. 627 | - Respond 'no' if the job title seems irrelevant. 628 | 629 | ----- 630 | 631 | ## Job Description: 632 | ``` 633 | {job_description} 634 | ``` 635 | 636 | ## User Preferences: 637 | ``` 638 | {job_description_filters} 639 | ``` 640 | 641 | ## Seems of interest: """ 642 | 643 | # Remove the leading tabs from the multiline string 644 | template = self._preprocess_template_string(template) 645 | 646 | # Extract the whitelist and blacklist from the job filtering rules 647 | job_description_filters = Markdown.extract_content_from_markdown(self.job_filtering_rules, "Job Description Filters") 648 | # TODO: Raise an exception if the job title filters are not found 649 | 650 | prompt = PromptTemplate(input_variables=["job_description", "job_description_filters"], template=template) 651 | chain = LLMChain(llm=self.llm_cheap, prompt=prompt) 652 | output = chain.run(job_description=self.job_description_summary, job_description_filters=job_description_filters) 653 | 654 | # Guard the output is one of the options 655 | if output.lower() not in ['yes', 'no']: 656 | output = self._closest_matching_option(output, ['yes', 'no']) 657 | 658 | # Return the output as a boolean 659 | return output.lower() == 'yes' 660 | 661 | def try_fix_answer(self, question: str, answer: str, error: str) -> str: 662 | """ 663 | Try to fix the answer, using the llm. The answer is a string like "yes", "no", "maybe", "I don't know". 664 | """ 665 | template = """\ 666 | The objective is to fix the text of a form input on a web page. 667 | 668 | ## Rules 669 | - Use the error to fix te original text. 670 | - The error "Please enter a valid answer" usually means the text is too large, shorten the reply to less than a tweet. 671 | - For errors like "Enter a whole number between 0 and 30", just need a number. 672 | 673 | ----- 674 | 675 | ## Form Question 676 | {question} 677 | 678 | ## Input 679 | {input} 680 | 681 | ## Error 682 | {error} 683 | 684 | ## Fixed Input 685 | """ 686 | template = self._preprocess_template_string(template) 687 | 688 | prompt = PromptTemplate(input_variables=["question", "input", "error"], template=template) 689 | chain = LLMChain(llm=self.llm_cheap, prompt=prompt) 690 | output = chain.run(question=question, input=answer, error=error) 691 | 692 | return output 693 | -------------------------------------------------------------------------------- /img/DALL·E - bot app icon - oil painting.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JorgeFrias/LinkedIn-GPT-EasyApplyBot/90afe2d018274f953a16b8bd1c67bc735c0df4ea/img/DALL·E - bot app icon - oil painting.png -------------------------------------------------------------------------------- /img/RoundedIcon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JorgeFrias/LinkedIn-GPT-EasyApplyBot/90afe2d018274f953a16b8bd1c67bc735c0df4ea/img/RoundedIcon.png -------------------------------------------------------------------------------- /img/github-banner.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JorgeFrias/LinkedIn-GPT-EasyApplyBot/90afe2d018274f953a16b8bd1c67bc735c0df4ea/img/github-banner.jpg -------------------------------------------------------------------------------- /img/github-social-preview.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JorgeFrias/LinkedIn-GPT-EasyApplyBot/90afe2d018274f953a16b8bd1c67bc735c0df4ea/img/github-social-preview.jpg -------------------------------------------------------------------------------- /linkedineasyapply.py: -------------------------------------------------------------------------------- 1 | import time, random, csv, pyautogui, pdb, traceback, sys 2 | from selenium.common.exceptions import TimeoutException, NoSuchElementException 3 | from selenium.webdriver.common.keys import Keys 4 | from selenium.webdriver.common.by import By 5 | from selenium.webdriver.support.ui import Select 6 | from selenium.webdriver.remote.webelement import WebElement 7 | from datetime import date 8 | from itertools import product 9 | from gpt import GPTAnswerer 10 | from pathlib import Path 11 | import os 12 | 13 | 14 | class EnvironmentKeys: 15 | """ 16 | Reads the environment variables and stores them. 17 | These environment variables are used to configure the application execution. 18 | """ 19 | 20 | def __init__(self): 21 | self.skip_apply: bool = self._read_env_key_bool("SKIP_APPLY") 22 | self.disable_description_filter: bool = self._read_env_key_bool("DISABLE_DESCRIPTION_FILTER") 23 | """ 24 | If True, the bot will not start applying to jobs, but will get up to the point where it would start applying. 25 | - Useful to debug the blacklists. 26 | """ 27 | 28 | @staticmethod 29 | def _read_env_key(key: str) -> str: 30 | """ 31 | Reads an environment variable and returns it. 32 | :param key: 33 | :return: 34 | """ 35 | key = os.getenv(key) 36 | 37 | if key is None: 38 | return "" 39 | 40 | return key 41 | 42 | @staticmethod 43 | def _read_env_key_bool(key: str) -> bool: 44 | """ 45 | Reads an environment variable and returns True if it is "True", False otherwise 46 | :param key: 47 | :return: 48 | """ 49 | key = os.getenv(key) 50 | 51 | if key is None: 52 | return False 53 | 54 | return key == "True" 55 | 56 | def print_config(self): 57 | """ 58 | Prints the configuration of the bot. 59 | :return: 60 | """ 61 | print("\nEnv config:") 62 | print(f"\t- SKIP_APPLY: {self.skip_apply}\n") 63 | print(f"\t- DISABLE_DESCRIPTION_FILTER: {self.disable_description_filter}\n") 64 | print("\n") 65 | 66 | 67 | class LinkedinEasyApply: 68 | def __init__(self, parameters, driver): 69 | self.browser = driver 70 | self.email = parameters['email'] 71 | self.password = parameters['password'] 72 | self.disable_lock = parameters['disableAntiLock'] 73 | self.company_blacklist = parameters.get('companyBlacklist', []) or [] 74 | self.title_blacklist = parameters.get('titleBlacklist', []) or [] 75 | self.poster_blacklist = parameters.get('posterBlacklist', []) or [] 76 | self.positions = parameters.get('positions', []) 77 | self.locations = parameters.get('locations', []) 78 | self.base_search_url = self.get_base_search_url(parameters) 79 | self.seen_jobs = [] 80 | self.unprepared_questions_file_name = "unprepared_questions" 81 | self.unprepared_questions_gpt_file_name = "unprepared_questions_gpt_answered" 82 | self.output_file_directory = Path(parameters['outputFileDirectory']) 83 | 84 | self.resume_dir: Path = parameters['uploads']['resume'] 85 | if 'coverLetter' in parameters['uploads']: 86 | self.cover_letter_dir: Path = parameters['uploads']['coverLetter'] 87 | else: 88 | self.cover_letter_dir: Path = Path("") 89 | 90 | self.personal_info = parameters.get('personalInfo', []) 91 | self.eeo = parameters.get('eeo', []) 92 | 93 | # Environment configuration 94 | self.env_config = EnvironmentKeys() 95 | self.env_config.print_config() 96 | 97 | # Data to fill in the application using GPT 98 | # - Plain text resume 99 | plain_text_resume_path = parameters['uploads']['plainTextResume'] 100 | file = open(plain_text_resume_path, "r") # Read the file 101 | plain_text_resume: str = file.read() 102 | # - Plain text personal data 103 | plain_text_personal_data_path = parameters['uploads']['plainTextPersonalData'] 104 | file = open(plain_text_personal_data_path, "r") # Read the file 105 | plain_text_personal_data: str = file.read() 106 | # - Plain text cover letter 107 | plain_text_cover_letter_path = parameters['uploads']['plainTextCoverLetter'] 108 | file = open(plain_text_cover_letter_path, "r") # Read the file 109 | plain_text_cover_letter: str = file.read() 110 | # - Job filters 111 | job_filters_path = parameters['uploads']['jobFilters'] 112 | file = open(job_filters_path, "r") # Read the file 113 | job_filters: str = file.read() 114 | 115 | # - Build the GPT answerer using the plain text data 116 | self.gpt_answerer = GPTAnswerer(plain_text_resume, plain_text_personal_data, plain_text_cover_letter, job_filters) 117 | 118 | def login(self): 119 | try: 120 | self.browser.get("https://www.linkedin.com/login") 121 | time.sleep(random.uniform(5, 10)) 122 | self.browser.find_element(By.ID, "username").send_keys(self.email) 123 | self.browser.find_element(By.ID, "password").send_keys(self.password) 124 | self.browser.find_element(By.CSS_SELECTOR, ".btn__primary--large").click() 125 | time.sleep(random.uniform(5, 10)) 126 | except TimeoutException: 127 | raise Exception("Could not login!") 128 | 129 | def security_check(self): 130 | current_url = self.browser.current_url 131 | page_source = self.browser.page_source 132 | 133 | if '/checkpoint/challenge/' in current_url or 'security check' in page_source: 134 | input("Please complete the security check and press enter in this console when it is done.") 135 | time.sleep(random.uniform(5.5, 10.5)) 136 | 137 | def start_applying(self): 138 | searches = list(product(self.positions, self.locations)) 139 | random.shuffle(searches) 140 | 141 | page_sleep = 0 142 | minimum_time = 60 * 15 143 | minimum_page_time = time.time() + minimum_time 144 | 145 | for (position, location) in searches: 146 | location_url = "&location=" + location 147 | job_page_number = -1 148 | 149 | print("Starting the search for " + position + " in " + location + ".") 150 | 151 | try: 152 | while True: 153 | page_sleep += 1 154 | job_page_number += 1 155 | print("Going to job page " + str(job_page_number)) 156 | self.next_job_page(position, location_url, job_page_number) 157 | time.sleep(random.uniform(1.5, 3.5)) 158 | print("Starting the application process for this page...") 159 | self.apply_jobs(location) 160 | print("Applying to jobs on this page has been completed!") 161 | 162 | # Sleep for a random amount of time between 5 and 15 minutes. 163 | time_left = minimum_page_time - time.time() 164 | if time_left > 0: 165 | print("Sleeping for " + str(time_left) + " seconds.") 166 | time.sleep(time_left) 167 | minimum_page_time = time.time() + minimum_time 168 | if page_sleep % 5 == 0: 169 | sleep_time = random.randint(500, 900) 170 | print("Sleeping for " + str(sleep_time / 60) + " minutes.") 171 | time.sleep(sleep_time) 172 | page_sleep += 1 173 | except: 174 | traceback.print_exc() 175 | pass 176 | 177 | time_left = minimum_page_time - time.time() 178 | if time_left > 0: 179 | print("Sleeping for " + str(time_left) + " seconds.") 180 | time.sleep(time_left) 181 | minimum_page_time = time.time() + minimum_time 182 | if page_sleep % 5 == 0: 183 | sleep_time = random.randint(500, 900) 184 | print("Sleeping for " + str(sleep_time / 60) + " minutes.") 185 | time.sleep(sleep_time) 186 | page_sleep += 1 187 | 188 | def apply_jobs(self, location): 189 | no_jobs_text = "" 190 | try: 191 | no_jobs_element = self.browser.find_element(By.CLASS_NAME, 'jobs-search-two-pane__no-results-banner--expand') 192 | no_jobs_text = no_jobs_element.text 193 | except: 194 | pass 195 | 196 | if 'No matching jobs found' in no_jobs_text: 197 | raise Exception("No more jobs on this page") 198 | 199 | if 'unfortunately, things aren' in self.browser.page_source.lower(): 200 | raise Exception("No more jobs on this page") 201 | 202 | try: 203 | job_results = self.browser.find_element(By.CLASS_NAME, "jobs-search-results-list") 204 | self.scroll_slow(job_results) 205 | self.scroll_slow(job_results, step=300, reverse=True) 206 | 207 | job_list = self.browser.find_elements(By.CLASS_NAME, 'scaffold-layout__list-container')[0].find_elements(By.CLASS_NAME, 'jobs-search-results__list-item') 208 | if len(job_list) == 0: 209 | raise Exception("No job class elements found in page") 210 | except: 211 | raise Exception("No more jobs on this page") 212 | 213 | if len(job_list) == 0: 214 | raise Exception("No more jobs on this page") 215 | 216 | # Iterate through each job on the page 217 | for job_tile in job_list: 218 | # Extract the job information from the Tile 219 | job_title, company, job_location, link, poster, apply_method = self.extract_job_information_from_tile(job_tile) 220 | # Remember the job 221 | self.seen_jobs += link 222 | 223 | # Check if the job title is blacklisted 224 | if self.is_blacklisted(job_title, company, poster, link): 225 | print(f"Blacklisted {job_title} at {company}, skipping...") 226 | # Record the skipped job 227 | self.record_skipped_job(job_title, company, job_location, link, "", "Title Filtering") 228 | continue 229 | 230 | try: 231 | # Click on the job 232 | job_el = job_tile.find_element(By.CLASS_NAME, 'job-card-list__title') 233 | job_el.click() 234 | except: 235 | traceback.print_exc() 236 | print("Could not apply to the job!") 237 | pass 238 | 239 | time.sleep(random.uniform(3, 5)) # Small human-like pause 240 | 241 | try: 242 | # Apply to the job 243 | if not self.apply_to_job(): # Returns True if successful, false if already applied, raises exception if failed 244 | continue # If already applied, next job 245 | except: 246 | self.record_failed_application(company, job_location, job_title, link, location) 247 | continue # If failed, next job 248 | 249 | # Record the successful application 250 | self.record_successful_application(company, job_location, job_title, link, location) 251 | 252 | def record_successful_application(self, company, job_location, job_title, link, location): 253 | """ 254 | Records the successful application to the job in the csv file. 255 | """ 256 | try: 257 | self.write_to_file(company, job_title, link, job_location, location) 258 | except Exception: 259 | print("Could not write the job to the file! No special characters in the job title/company is allowed!") 260 | traceback.print_exc() 261 | 262 | def record_failed_application(self, company, job_location, job_title, link, location): 263 | """ 264 | Records the failed application to the job in the csv file. 265 | """ 266 | print("Failed to apply to job! Please submit a bug report with this link: " + link) 267 | print("Writing to the failed csv file...") 268 | try: 269 | self.write_to_file(company, job_title, link, job_location, location, file_name="failed") 270 | except: 271 | pass 272 | 273 | def extract_job_information_from_tile(self, job_tile): 274 | """ 275 | Extracts the job information from the job tile. 276 | :param job_tile: The job tile element. 277 | :return: job_title, company, job_location, link, poster, apply_method 278 | """ 279 | job_title, company, poster, job_location, apply_method, link = "", "", "", "", "", "" 280 | 281 | try: 282 | job_title = job_tile.find_element(By.CLASS_NAME, 'job-card-list__title').text 283 | link = job_tile.find_element(By.CLASS_NAME, 'job-card-list__title').get_attribute('href').split('?')[0] 284 | company = job_tile.find_element(By.CLASS_NAME, 'job-card-container__company-name').text 285 | except: 286 | pass 287 | try: 288 | # get the name of the person who posted for the position, if any is listed 289 | hiring_line = job_tile.find_element(By.XPATH, '//span[contains(.,\' is hiring for this\')]') 290 | hiring_line_text = hiring_line.text 291 | name_terminating_index = hiring_line_text.find(' is hiring for this') 292 | if name_terminating_index != -1: 293 | poster = hiring_line_text[:name_terminating_index] 294 | except: 295 | pass 296 | try: 297 | job_location = job_tile.find_element(By.CLASS_NAME, 'job-card-container__metadata-item').text 298 | except: 299 | pass 300 | try: 301 | apply_method = job_tile.find_element(By.CLASS_NAME, 'job-card-container__apply-method').text 302 | except: 303 | pass 304 | 305 | return job_title, company, job_location, link, poster, apply_method 306 | 307 | def is_blacklisted(self, job_title, company, poster, link): 308 | """ 309 | Checks if the job is blacklisted by the user. 310 | 311 | Currently, uses both the config.yaml file and the job_filters.md file to blacklist jobs. 312 | 313 | :param job_title: 314 | :param company: 315 | :param poster: 316 | :param link: 317 | :return: True if the job is blacklisted, False otherwise. 318 | """ 319 | 320 | # - Blacklist from the config.yaml file 321 | # TODO: Exclusively use GPT/job_filters.md to blacklist jobs. 322 | if job_title.lower().split(' ') in [word.lower() for word in self.title_blacklist]: 323 | return True 324 | 325 | if company.lower() in [word.lower() for word in self.company_blacklist]: 326 | return True 327 | 328 | if poster.lower() in [word.lower() for word in self.poster_blacklist]: 329 | return True 330 | 331 | if link in self.seen_jobs: 332 | return True 333 | 334 | # - GPT blacklist with the job_filters.md 335 | # TODO: Add blacklisted companies to the blacklist 336 | # The comparison doesn't need to be done with GPT. 337 | # It can be done with a simple string comparison, but it should be extracted from the same file 338 | is_gpt_blacklisted = not self.gpt_answerer.job_title_passes_filters(job_title) 339 | 340 | if is_gpt_blacklisted: 341 | return True 342 | 343 | return False 344 | 345 | def extract_job_information_from_opened_job(self): 346 | job_title, company, job_location, description = "", "", "", "" 347 | 348 | try: 349 | # Job panel element 350 | job_element = self.browser.find_elements(By.CLASS_NAME, 'jobs-search__job-details--container')[0] 351 | # Individual information 352 | job_title = job_element.find_element(By.CLASS_NAME, 'jobs-unified-top-card__job-title').text 353 | company = job_element.find_element(By.CLASS_NAME, 'jobs-unified-top-card__company-name').text 354 | job_location = job_element.find_elements(By.CLASS_NAME, 'jobs-unified-top-card__bullet')[0].text + " | " + job_element.find_elements(By.CLASS_NAME, 'jobs-unified-top-card__workplace-type')[0].text 355 | description = job_element.find_element(By.CLASS_NAME, 'jobs-description-content__text').text 356 | except Exception as e: 357 | Exception(f"Could not extract job information from the opened job! {e}") 358 | 359 | return job_title, company, job_location, description 360 | 361 | def formatted_job_information(self, job_title: str, company: str, job_location: str, description: str): 362 | """ 363 | Formats the job information as a markdown string. 364 | """ 365 | job_information = f""" 366 | # Job Description 367 | ## Job Information 368 | - Position: {job_title} 369 | - At: {company} 370 | - Location: {job_location} 371 | 372 | ## Description 373 | {description} 374 | """ 375 | return job_information 376 | 377 | def apply_to_job(self): 378 | """ 379 | Applies to the job, opened in the browser. 380 | :return: True if successful, False if already applied, raises exception if failed. 381 | """ 382 | easy_apply_button = None 383 | 384 | try: 385 | easy_apply_button = self.browser.find_element(By.CLASS_NAME, 'jobs-apply-button') 386 | except: 387 | # If the easy apply button is not found, is because is disabled. Supposedly is because the job is already applied. 388 | # There is a pre-filtering before to only search easy apply jobs. 389 | return False 390 | 391 | # Skip if the easy apply button says "Continue", this is an application that was already started, but couldn't be finished, and it won't be finished by this script. 392 | if easy_apply_button.text == "Continue": 393 | return False 394 | 395 | try: 396 | # Scroll down to the job description like a human reading the whole job description 397 | job_description_area = self.browser.find_element(By.CLASS_NAME, "jobs-search__job-details--container") 398 | self.scroll_slow(job_description_area, end=1600) 399 | self.scroll_slow(job_description_area, end=1600, step=400, reverse=True) 400 | except: 401 | pass 402 | 403 | # Load the Job description in the answerer 404 | job_title, job_company, job_location, job_description = self.extract_job_information_from_opened_job() 405 | # Put all information in a Markdown format to pass to gpt 406 | formatted_description = self.formatted_job_information(job_title, job_company, job_location, job_description) 407 | # Provide the job description to the answerer as context 408 | self.gpt_answerer.job_description = formatted_description 409 | 410 | # Check if the job is blacklisted 411 | if not self.env_config.disable_description_filter and not self.gpt_answerer.job_description_passes_filters(): 412 | print(f"Blacklisted description {job_title} at {job_company}. Skipping...") 413 | self.record_skipped_job(job_title, job_company, job_location, "unknown link", job_description, "Description Filtering") # TODO: Record the link 414 | raise Exception("Job description blacklisted") 415 | 416 | if self.env_config.skip_apply: 417 | print("ENV: Skipping apply. The SKIP_APPLY environment variable is set to True.") 418 | return False 419 | 420 | # Start the application process 421 | print("Applying to the job....") 422 | easy_apply_button.click() # Click the easy apply button 423 | submitted_application = False # Flag to check if the application was submitted successfully 424 | while not submitted_application: # Iterate filling up fields until the submit application button is found 425 | try: 426 | self.fill_up() # Fill up the fields 427 | submitted_application = self.apply_to_job_form_next_step() # Click the next button after filling up the fields 428 | except: 429 | # On any error, close the application window, save the job for later and raise a final exception. 430 | traceback.print_exc() 431 | self.browser.find_element(By.CLASS_NAME, 'artdeco-modal__dismiss').click() 432 | time.sleep(random.uniform(3, 5)) 433 | self.browser.find_elements(By.CLASS_NAME, 'artdeco-modal__confirm-dialog-btn')[1].click() 434 | time.sleep(random.uniform(3, 5)) 435 | raise Exception("Failed to apply to job!") 436 | 437 | # Successfully applied to the job, close the confirmation window. 438 | self.apply_to_job_form_close_confirmation_modal() 439 | 440 | # Return True if the job was successfully applied to. 441 | return True 442 | 443 | def apply_to_job_form_close_confirmation_modal(self): 444 | closed_notification = False 445 | time.sleep(random.uniform(3, 5)) 446 | try: 447 | self.browser.find_element(By.CLASS_NAME, 'artdeco-modal__dismiss').click() 448 | closed_notification = True 449 | except: 450 | pass 451 | try: 452 | self.browser.find_element(By.CLASS_NAME, 'artdeco-toast-item__dismiss').click() 453 | closed_notification = True 454 | except: 455 | pass 456 | time.sleep(random.uniform(3, 5)) 457 | if closed_notification is False: 458 | raise Exception("Could not close the applied confirmation window!") 459 | 460 | def apply_to_job_form_next_step(self): 461 | """ 462 | Clicks the next button in the application form / clicks the submit application button. 463 | :param submit_application_text: 464 | :return: True if the application was submitted, False otherwise. 465 | """ 466 | submit_application_text = 'submit application' 467 | 468 | # Find the next button 469 | next_button = self.browser.find_element(By.CLASS_NAME, "artdeco-button--primary") 470 | button_text = next_button.text.lower() 471 | 472 | # When the submit application button is found, there is an option to follow the company feed that needs to be unchecked. 473 | if submit_application_text in button_text: 474 | self.unfollow() 475 | 476 | # Click continuation button 477 | # - Next step in the application process 478 | # - Submit. This action will also submit the application, if the primary button is the submit application button. 479 | time.sleep(random.uniform(1.5, 2.5)) 480 | next_button.click() 481 | time.sleep(random.uniform(3.0, 5.0)) 482 | 483 | # There are errors in the current fields 484 | # if 'please enter a valid answer' in self.browser.page_source.lower() or 'file is required' in self.browser.page_source.lower(): 485 | # # TODO: Provide this feedback to GPT, so it can modify the answers. 486 | # raise Exception("Failed answering required questions or uploading required files.") 487 | 488 | # There are other errors that can appear, like "Enter a valid phone number", "Enter a whole number", etc. 489 | # Represented by the class "artdeco-inline-feedback--error" 490 | error_elements = self.browser.find_elements(By.CLASS_NAME, 'artdeco-inline-feedback--error') 491 | if len(error_elements) > 0: 492 | raise Exception(f"Failed answering required questions or uploading required files. {str([e.text for e in error_elements])}") 493 | # TODO: Provide this feedback to GPT, so it can modify the answers, according to the error message. 494 | 495 | if submit_application_text in button_text.lower(): 496 | return True 497 | 498 | return False 499 | 500 | def home_address(self, element): 501 | try: 502 | groups = element.find_elements(By.CLASS_NAME, 'jobs-easy-apply-form-section__grouping') 503 | if len(groups) > 0: 504 | for group in groups: 505 | lb = group.find_element(By.TAG_NAME, 'label').text.lower() 506 | input_field = group.find_element(By.TAG_NAME, 'input') 507 | if 'street' in lb: 508 | self.enter_text(input_field, self.personal_info['Street address']) 509 | elif 'city' in lb: 510 | self.enter_text(input_field, self.personal_info['City']) 511 | time.sleep(3) 512 | input_field.send_keys(Keys.DOWN) 513 | input_field.send_keys(Keys.RETURN) 514 | elif 'zip' in lb or 'postal' in lb: 515 | self.enter_text(input_field, self.personal_info['Zip']) 516 | elif 'state' in lb or 'province' in lb: 517 | self.enter_text(input_field, self.personal_info['State']) 518 | else: 519 | pass 520 | except: 521 | pass 522 | 523 | def get_answer(self, question): 524 | """ 525 | Sees if the key `question` is in the dictionary `checkboxes` and returns "yes" is true and "no" if false 526 | """ 527 | # TODO: This should be a boolean test, why is it a string? 528 | if self.checkboxes[question]: 529 | return 'yes' 530 | else: 531 | return 'no' 532 | 533 | def get_checkbox_answer(self, question_key): 534 | """ 535 | Sees if the key `question` is in the dictionary `checkboxes` and returns True if true and False if false. 536 | :param question_key: The question to check for in the dictionary. 537 | """ 538 | if self.checkboxes[question_key]: 539 | return True 540 | else: 541 | return False 542 | 543 | # MARK: Additional Questions 544 | def additional_questions(self): 545 | frm_el = self.browser.find_elements(By.CLASS_NAME, 'jobs-easy-apply-form-section__grouping') 546 | if len(frm_el) == 0: 547 | return 548 | 549 | for el in frm_el: 550 | # Each call will try to do its job, if they can't, they will return early 551 | # TODO: return bool indicating if the question was answered or not to continue to the next question 552 | 553 | # Checkbox check for agreeing to terms and service 554 | if self.additional_questions_agree_terms_of_service(el): # If the question is "agree to terms of service", it's resolved -> skip to next question 555 | continue 556 | 557 | # Radio check 558 | self.additional_questions_radio_gpt(el) 559 | 560 | # Questions check 561 | self.additional_questions_textbox_gpt(el) 562 | 563 | # Date Check 564 | self.additional_questions_date(el) 565 | 566 | # Dropdown check 567 | self.additional_questions_drop_down_gpt(el) 568 | 569 | def additional_questions_agree_terms_of_service(self, el) -> bool: 570 | """ 571 | Checks if the question is about agreeing to terms of service and checks the box if it is. 572 | :param el: 573 | :return: True if the question is about agreeing to terms of service, False otherwise. 574 | """ 575 | try: 576 | question = el.find_element(By.CLASS_NAME, 'jobs-easy-apply-form-element') 577 | clickable_checkbox = question.find_element(By.TAG_NAME, 'label') 578 | 579 | # Check if the question text contains the word "agree" and ("terms of service" or "privacy policy") 580 | question_text = question.text.lower() 581 | if 'terms of service' in question_text or 'privacy policy' in question_text or 'terms of use' in question_text: 582 | clickable_checkbox.click() 583 | return True 584 | except Exception as e: 585 | pass 586 | 587 | return False 588 | 589 | def additional_questions_drop_down_gpt(self, el): 590 | try: 591 | question = el.find_element(By.CLASS_NAME, 'jobs-easy-apply-form-element') 592 | question_text = question.find_element(By.TAG_NAME, 'label').text.lower() # TODO: This seems to be optional, try to answer the question without it, or use the top level title. 593 | dropdown_field = question.find_element(By.TAG_NAME, 'select') 594 | 595 | select = Select(dropdown_field) 596 | options = [options.text for options in select.options] 597 | 598 | # Hardcoded answers 599 | if 'email' in question_text: 600 | return # assume email address is filled in properly by default 601 | 602 | # Answer any other the question 603 | choice = self.gpt_answerer.answer_question_from_options(question_text, options) 604 | self.select_dropdown(dropdown_field, choice) 605 | self.record_gpt_answer("dropdown", question_text, choice) 606 | 607 | except Exception as e: 608 | pass 609 | 610 | def additional_questions_date(self, el): 611 | try: 612 | date_picker = el.find_element(By.CLASS_NAME, 'artdeco-datepicker__input ') 613 | date_picker.clear() 614 | date_picker.send_keys(date.today().strftime("%m/%d/%y")) 615 | time.sleep(3) 616 | date_picker.send_keys(Keys.RETURN) 617 | time.sleep(2) 618 | 619 | except Exception as e: 620 | pass 621 | 622 | def additional_questions_textbox_gpt(self, el): 623 | try: 624 | # Question information 625 | question = el.find_element(By.CLASS_NAME, 'jobs-easy-apply-form-element') 626 | question_text = question.find_element(By.TAG_NAME, 'label').text.lower() 627 | try: 628 | txt_field = question.find_element(By.TAG_NAME, 'input') 629 | except: 630 | try: 631 | txt_field = question.find_element(By.TAG_NAME, 'textarea') # TODO: Test textarea 632 | except: 633 | raise Exception("Could not find textarea or input tag for question") 634 | 635 | # Field type 636 | text_field_type = txt_field.get_attribute('type').lower() 637 | if not ('numeric' in text_field_type or 'text' in text_field_type): 638 | return # This function doesn't support other types, just return 639 | 640 | # Test field type 641 | is_numeric_field = False 642 | class_attribute = txt_field.get_attribute("id") 643 | if class_attribute and 'numeric' in class_attribute: 644 | is_numeric_field = True 645 | 646 | # Use GPT to answer the question 647 | to_enter = '' 648 | if is_numeric_field: 649 | to_enter = self.gpt_answerer.answer_question_numeric(question_text) 650 | else: 651 | to_enter = self.gpt_answerer.answer_question_textual_wide_range(question_text) 652 | 653 | # Record the answer 654 | self.record_gpt_answer('numeric' if is_numeric_field else 'text', question_text, to_enter) 655 | 656 | # Enter the answer 657 | self.enter_text(txt_field, to_enter) 658 | 659 | # Handle form errors 660 | self.textbox_gpt_handle_form_errors(el, question_text, to_enter, txt_field) 661 | 662 | except Exception as e: 663 | pass 664 | 665 | def textbox_gpt_handle_form_errors(self, el, question_text: str, answer_text: str, txt_field): 666 | """ 667 | After filling up the form errors might occur, this function will try to handle those errors. If there are no errors, it will return. 668 | 669 | :param el: The web element containing the form field. 670 | :param question_text: The question text. 671 | :param answer_text: The answer text. 672 | :param txt_field: The text field element. 673 | """ 674 | # TODO: Loop this thing up (max_retries)! 675 | 676 | # See if the field has an error message 677 | try: 678 | error = el.find_element(By.CLASS_NAME, 'artdeco-inline-feedback--error') 679 | error_text = error.text.lower() 680 | except NoSuchElementException: 681 | return 682 | 683 | # Try to fix the error 684 | new_answer = self.gpt_answerer.try_fix_answer(question_text, answer_text, error_text) 685 | # Enter the newest answer 686 | self.enter_text(txt_field, new_answer) 687 | 688 | def additional_questions_radio_gpt(self, el): 689 | """ 690 | This function handles radio buttons 691 | :param el: The element containing the radio buttons 692 | """ 693 | try: 694 | # Question information 695 | question = el.find_element(By.CLASS_NAME, 'jobs-easy-apply-form-element') 696 | radios = question.find_elements(By.CLASS_NAME, 'fb-text-selectable__option') 697 | if len(radios) == 0: 698 | raise Exception("No radio found in element") 699 | 700 | radio_text = el.text.lower() 701 | radio_options = [text.text.lower() for text in radios] 702 | 703 | # Ask gpt for the most likely answer 704 | answer = "yes" 705 | answer = self.gpt_answerer.answer_question_from_options(radio_text, radio_options) 706 | self.record_gpt_answer("radio", radio_text, answer) 707 | 708 | # Select the radio that matches the answer 709 | to_select = None 710 | for radio in radios: 711 | if answer in radio.text.lower(): 712 | to_select = radio 713 | break 714 | # Fallback to the last radio if no answer was found 715 | if to_select is None: 716 | to_select = radios[-1] 717 | 718 | # Select the chosen radio 719 | self.radio_select_simplified(to_select) 720 | 721 | except Exception as e: 722 | pass 723 | 724 | # MARK: - Helper Methods 725 | def unfollow(self): 726 | try: 727 | follow_checkbox = self.browser.find_element(By.XPATH, "//label[contains(.,\'to stay up to date with their page.\')]").click() 728 | follow_checkbox.click() 729 | except Exception as e: 730 | print(f"Failed to unfollow company! {e}") 731 | 732 | def is_upload_field(self, element: WebElement) -> bool: 733 | try: 734 | element.find_element(By.XPATH, ".//input[@type='file']") 735 | return True 736 | except Exception as e: 737 | return False 738 | 739 | def try_send_resume(self): 740 | try: 741 | # Resume, cover letter. 742 | file_upload_elements = self.browser.find_elements(By.XPATH, "//input[@type='file']") 743 | 744 | if len(file_upload_elements) == 0: 745 | raise Exception("No file upload elements found") 746 | 747 | for element in file_upload_elements: 748 | # Parent of element, this is where the label is (resume, cover letter) 749 | parent = element.find_element(By.XPATH, "..") 750 | # Remove the class .hidden from the element -> to show the upload button 751 | self.browser.execute_script("arguments[0].classList.remove('hidden')", element) 752 | # Send the file path to the element -> this will upload the file 753 | 754 | resumePath = str(self.resume_dir.resolve()) 755 | letterPath = str(self.cover_letter_dir.resolve()) 756 | 757 | if 'resume' in parent.text.lower(): 758 | element.send_keys(resumePath) 759 | elif 'cover' in parent.text.lower() and letterPath != '': 760 | element.send_keys(letterPath) 761 | # Got rid of 'required' test - I have never seen a required "something" 762 | 763 | except Exception as e: 764 | print(f"Failed to upload resume or cover letter! {e}") 765 | 766 | def enter_text(self, element, text): 767 | element.clear() 768 | element.send_keys(text) 769 | 770 | def select_dropdown(self, element, text): 771 | select = Select(element) 772 | select.select_by_visible_text(text) 773 | 774 | # Radio Select 775 | def radio_select(self, element, label_text, clickLast=False): 776 | label = element.find_element(By.TAG_NAME, 'label') 777 | if label_text in label.text.lower() or clickLast == True: 778 | label.click() 779 | else: 780 | pass 781 | 782 | def radio_select_simplified(self, element): 783 | label = element.find_element(By.TAG_NAME, 'label') 784 | label.click() 785 | 786 | # Contact info fill-up 787 | def contact_info(self): 788 | frm_el = self.browser.find_elements(By.CLASS_NAME, 'jobs-easy-apply-form-section__grouping') 789 | 790 | if len(frm_el) == 0: 791 | return 792 | 793 | for el in frm_el: 794 | text = el.text.lower() 795 | if 'email address' in text: 796 | continue 797 | elif 'phone number' in text: 798 | try: 799 | country_code_picker = el.find_element(By.XPATH, '//select[contains(@id,"phoneNumber")][contains(@id,"country")]') 800 | self.select_dropdown(country_code_picker, self.personal_info['Phone Country Code']) 801 | except Exception as e: 802 | print("Country code " + self.personal_info['Phone Country Code'] + " not found! Make sure it is exact.") 803 | print(e) 804 | try: 805 | phone_number_field = el.find_element(By.XPATH, '//input[contains(@id,"phoneNumber")][contains(@id,"nationalNumber")]') 806 | self.enter_text(phone_number_field, self.personal_info['Mobile Phone Number']) 807 | except Exception as e: 808 | print("Could not input phone number:") 809 | print(e) 810 | 811 | def fill_up(self): 812 | """ 813 | Fills up the form page with the resume information. 814 | """ 815 | # TODO: Too many try/excepts. Refactor this. 816 | try: 817 | easy_apply_content = self.browser.find_element(By.CLASS_NAME, 'jobs-easy-apply-content') 818 | pb4 = easy_apply_content.find_elements(By.CLASS_NAME, 'pb4') 819 | 820 | if len(pb4) == 0: 821 | raise Exception("No pb4 class elements found in element") 822 | 823 | for pb in pb4: 824 | try: 825 | label = pb.find_element(By.TAG_NAME, 'h3').text.lower() 826 | 827 | # 1. Fill up the form with the personal info if possible 828 | # TODO: Change to GPT supported? This works really well 829 | if 'home address' in label: 830 | self.home_address(pb) 831 | continue # Field is filled up, go to next 832 | 833 | if 'contact info' in label: 834 | self.contact_info() 835 | continue # Field is filled up, go to next 836 | 837 | # 2. Send the resume and cover letter 838 | if self.is_upload_field(pb): 839 | try: 840 | self.try_send_resume() 841 | continue # Field is filled up, go to next 842 | except Exception as e: 843 | pass 844 | 845 | # 3. Fill up the form with the other information 846 | try: 847 | self.additional_questions() 848 | except Exception as e: 849 | pass 850 | except: 851 | pass 852 | except: 853 | pass 854 | 855 | def write_to_file(self, company, job_title, link, location, search_location, file_name='output'): 856 | to_write = [company, job_title, link, location] 857 | file_name = file_name + '_' + search_location + ".csv" 858 | file_path = self.output_file_directory / file_name 859 | 860 | with open(file_path, 'a') as f: 861 | writer = csv.writer(f) 862 | writer.writerow(to_write) 863 | 864 | def record_gpt_answer(self, answer_type, question_text, gpt_response): 865 | to_write = [answer_type, question_text, gpt_response] 866 | file_name = "gpt_answers" + ".csv" 867 | file_path = self.output_file_directory / file_name 868 | 869 | try: 870 | with open(file_path, 'a') as f: 871 | writer = csv.writer(f) 872 | writer.writerow(to_write) 873 | except: 874 | print("Could not write the unprepared gpt question to the file! No special characters in the question is allowed: ") 875 | print(question_text) 876 | 877 | def record_skipped_job(self, job_title: str, company: str, location: str, link: str, description: str, skipped_stage: str): 878 | """ 879 | Records the skipped job to a csv file. 880 | :param job_title: 881 | :param company: 882 | :param location: 883 | :param link: 884 | :param description: The description of the job. 885 | :param skipped_stage: The stage at which the job was skipped e.g. "title filtering". "description filtering" 886 | :return: 887 | """ 888 | file_path = self.output_file_directory / 'skipped_jobs.csv' 889 | to_write = [job_title, company, location, skipped_stage, link, description] 890 | 891 | with open(file_path, 'a') as f: 892 | writer = csv.writer(f) 893 | writer.writerow(to_write) 894 | 895 | def scroll_slow(self, scrollable_element, start=0, end=3600, step=100, reverse=False): 896 | if reverse: 897 | start, end = end, start 898 | step = -step 899 | 900 | for i in range(start, end, step): 901 | self.browser.execute_script("arguments[0].scrollTo(0, {})".format(i), scrollable_element) 902 | time.sleep(random.uniform(1.0, 2.6)) 903 | 904 | def avoid_lock(self): 905 | if self.disable_lock: 906 | return 907 | 908 | pyautogui.keyDown('ctrl') 909 | pyautogui.press('esc') 910 | pyautogui.keyUp('ctrl') 911 | time.sleep(1.0) 912 | pyautogui.press('esc') 913 | 914 | def get_base_search_url(self, parameters): 915 | remote_url = "" 916 | 917 | if parameters['remote']: 918 | remote_url = "f_CF=f_WRA" 919 | 920 | level = 1 921 | experience_level = parameters.get('experienceLevel', []) 922 | experience_url = "f_E=" 923 | for key in experience_level.keys(): 924 | if experience_level[key]: 925 | experience_url += "%2C" + str(level) 926 | level += 1 927 | 928 | distance_url = "?distance=" + str(parameters['distance']) 929 | 930 | job_types_url = "f_JT=" 931 | job_types = parameters.get('experienceLevel', []) 932 | for key in job_types: 933 | if job_types[key]: 934 | job_types_url += "%2C" + key[0].upper() 935 | 936 | date_url = "" 937 | dates = {"all time": "", "month": "&f_TPR=r2592000", "week": "&f_TPR=r604800", "24 hours": "&f_TPR=r86400"} 938 | date_table = parameters.get('date', []) 939 | for key in date_table.keys(): 940 | if date_table[key]: 941 | date_url = dates[key] 942 | break 943 | 944 | easy_apply_url = "&f_LF=f_AL" 945 | 946 | extra_search_terms = [distance_url, remote_url, job_types_url, experience_url] 947 | extra_search_terms_str = '&'.join(term for term in extra_search_terms if len(term) > 0) + easy_apply_url + date_url 948 | 949 | return extra_search_terms_str 950 | 951 | def next_job_page(self, position, location, job_page): 952 | self.browser.get("https://www.linkedin.com/jobs/search/" + self.base_search_url + 953 | "&keywords=" + position + location + "&start=" + str(job_page * 25)) 954 | 955 | self.avoid_lock() 956 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import yaml 2 | import argparse 3 | from linkedineasyapply import LinkedinEasyApply 4 | from validate_email import validate_email 5 | from selenium import webdriver 6 | from webdriver_manager.chrome import ChromeDriverManager 7 | from selenium.webdriver.chrome.options import Options 8 | from pathlib import Path 9 | 10 | 11 | def init_browser(): 12 | browser_options = Options() 13 | options = ['--disable-blink-features', '--no-sandbox', '--disable-extensions', 14 | '--ignore-certificate-errors', '--disable-blink-features=AutomationControlled','--disable-gpu','--remote-debugging-port=9222'] 15 | for option in options: 16 | browser_options.add_argument(option) 17 | driver = webdriver.Chrome(options=browser_options) 18 | return driver 19 | 20 | 21 | def find_file(name_containing: str, with_extension: str, at_path: Path) -> Path: 22 | """ 23 | Finds a file in a directory, given that the name contains a certain string and has a certain extension. 24 | :param name_containing: The string that the file name must contain. Case-insensitive. 25 | :param with_extension: The extension that the file must have, including the dot. Case-insensitive. 26 | :param at_path: The path to the directory where the file is. 27 | :return: The path to the first file that matches the criteria. 28 | """ 29 | 30 | for file in at_path.iterdir(): 31 | if name_containing.lower() in file.name.lower() and file.suffix.lower() == with_extension.lower(): 32 | return file 33 | 34 | 35 | def validate_data_folder(app_data_folder: Path): 36 | """ 37 | Reads the data folder and validates that all the files are in place. 38 | 39 | Files: 40 | - config.yaml 41 | - resume.pdf 42 | - cover_letter.pdf 43 | - plain_text_resume.md 44 | - plain_text_cover_letter.md 45 | - personal_data.md 46 | 47 | :returns: config_file, resume_file, cover_letter_file, plain_text_resume_file, plain_text_cover_letter_file, personal_data_file: The file paths, job_filters_file, output_folder: The output folder path in the app_data_folder. 48 | """ 49 | 50 | config_file = app_data_folder / 'config.yaml' 51 | plain_text_resume_file = app_data_folder / 'plain_text_resume.md' 52 | plain_text_cover_letter_file = app_data_folder / 'plain_text_cover_letter.md' 53 | personal_data_file = app_data_folder / 'personal_data.md' 54 | job_filters_file = app_data_folder / 'job_filters.md' 55 | 56 | # The resume and cover letter pdf can have more complex names as `JohnDoe-Resume.pdf` or `John-Doe-Cover-Letter.pdf` 57 | resume_file = find_file('resume', '.pdf', app_data_folder) 58 | cover_letter_file = find_file('cover', '.pdf', app_data_folder) 59 | 60 | # Check all files exist 61 | if not config_file.exists() or not resume_file.exists() or not cover_letter_file.exists() or not plain_text_resume_file.exists() or not plain_text_cover_letter_file.exists() or not personal_data_file.exists(): 62 | raise Exception(f'Missing files in the data folder! You must provide:\n\t-config.yaml\n\t-resume.pdf\n\t-cover_letter.pdf\n\t-plain_text_resume.md\n\t-plain_text_cover_letter.md\n\t-personal_data.md\n\t-job_filters.md\n\nYou can find an example of these files in the example_data folder.') 63 | 64 | # Output folder 65 | output_folder = app_data_folder / 'output' 66 | # Create the output folder if it doesn't exist 67 | if not output_folder.exists(): 68 | output_folder.mkdir() 69 | 70 | # Return the file paths 71 | return config_file, resume_file, cover_letter_file, plain_text_resume_file, plain_text_cover_letter_file, personal_data_file, job_filters_file, output_folder 72 | 73 | 74 | def file_paths_to_dict(resume_file: Path, cover_letter_file: Path, plain_text_resume_file: Path, plain_text_cover_letter_file: Path, personal_data_file: Path, job_filters_file: Path) -> dict: 75 | parameters = {'resume': resume_file, 'coverLetter': cover_letter_file, 'plainTextResume': plain_text_resume_file, 'plainTextCoverLetter': plain_text_cover_letter_file, 'plainTextPersonalData': personal_data_file, 'jobFilters': job_filters_file} 76 | 77 | return parameters 78 | 79 | 80 | def validate_yaml(config_yaml_path: Path): 81 | """ 82 | Validates the yaml file, checking that all the mandatory parameters are present. 83 | :param config_yaml_path: The path to the yaml file. 84 | :return: The parameters extracted from the yaml file. 85 | """ 86 | with open(config_yaml_path, 'r') as stream: 87 | try: 88 | parameters = yaml.safe_load(stream) 89 | except yaml.YAMLError as exc: 90 | raise exc 91 | 92 | mandatory_params = ['email', 'password', 'disableAntiLock', 'remote', 'experienceLevel', 'jobTypes', 'date', 93 | 'positions', 'locations', 'distance', 'personalInfo'] 94 | 95 | for mandatory_param in mandatory_params: 96 | if mandatory_param not in parameters: 97 | raise Exception(mandatory_param + ' is not inside the yml file!') 98 | 99 | assert validate_email(parameters['email']) 100 | assert len(str(parameters['password'])) > 0 101 | 102 | assert isinstance(parameters['disableAntiLock'], bool) 103 | 104 | assert isinstance(parameters['remote'], bool) 105 | 106 | assert len(parameters['experienceLevel']) > 0 107 | experience_level = parameters.get('experienceLevel', []) 108 | at_least_one_experience = False 109 | for key in experience_level.keys(): 110 | if experience_level[key]: 111 | at_least_one_experience = True 112 | assert at_least_one_experience 113 | 114 | assert len(parameters['jobTypes']) > 0 115 | job_types = parameters.get('jobTypes', []) 116 | at_least_one_job_type = False 117 | for key in job_types.keys(): 118 | if job_types[key]: 119 | at_least_one_job_type = True 120 | assert at_least_one_job_type 121 | 122 | assert len(parameters['date']) > 0 123 | date = parameters.get('date', []) 124 | at_least_one_date = False 125 | for key in date.keys(): 126 | if date[key]: 127 | at_least_one_date = True 128 | assert at_least_one_date 129 | 130 | approved_distances = {0, 5, 10, 25, 50, 100} 131 | assert parameters['distance'] in approved_distances 132 | 133 | assert len(parameters['positions']) > 0 134 | assert len(parameters['locations']) > 0 135 | 136 | # assert len(parameters['uploads']) >= 1 and 'resume' in parameters['uploads'] 137 | 138 | assert len(parameters['personalInfo']) 139 | personal_info = parameters.get('personalInfo', []) 140 | for info in personal_info: 141 | assert personal_info[info] != '' 142 | 143 | return parameters 144 | 145 | 146 | def main(data_folder_path: Path): 147 | print(f"Using data folder path: {data_folder}") 148 | # Paths to the files inside the data folder 149 | config_file, resume_file, cover_letter_file, plain_text_resume_file, plain_text_cover_letter_file, personal_data_file, job_filters_file, output_folder = validate_data_folder(data_folder) 150 | 151 | # Extract the parameters from the yaml file 152 | parameters = validate_yaml(config_file) 153 | # Add the remaining file paths to the parameters used by the bot 154 | parameters['uploads'] = file_paths_to_dict(resume_file, cover_letter_file, plain_text_resume_file, plain_text_cover_letter_file, personal_data_file, job_filters_file) 155 | parameters['outputFileDirectory'] = output_folder 156 | 157 | # Start the bot 158 | browser = init_browser() 159 | bot = LinkedinEasyApply(parameters, browser) 160 | bot.login() 161 | bot.security_check() 162 | bot.start_applying() 163 | 164 | 165 | if __name__ == "__main__": 166 | parser = argparse.ArgumentParser(description="Process data folder path") 167 | parser.add_argument("data_folder", help="Path to the data folder") 168 | 169 | args = parser.parse_args() # Parse the arguments 170 | data_folder = Path(args.data_folder) # Convert to pathlib.Path object 171 | 172 | # Tell the user if the data folder doesn't exist or is not a folder 173 | if not data_folder.exists(): 174 | print(f"The data folder {data_folder} does not exist!") 175 | exit(1) 176 | if not data_folder.is_dir(): 177 | print(f"The data folder {data_folder} is not a folder!") 178 | exit(1) 179 | 180 | main(args.data_folder) 181 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | selenium~=4.9.1 2 | pyautogui~=0.9.53 3 | webdriver_manager 4 | PyYAML~=6.0 5 | validate_email 6 | langchain~=0.0.175 7 | Levenshtein~=0.21.0 -------------------------------------------------------------------------------- /test_gpt.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from gpt import GPTAnswerer 3 | 4 | 5 | class TestGPT(unittest.TestCase): 6 | 7 | personal_data_text = """ 8 | John Doe 9 | 123 Main Street 10 | Anytown, USA 12345 11 | 555-555-5555 12 | 13 | ## Skills: 14 | - Swift and Objective-C 15 | - iOS frameworks: UIKit, Core Data, and Core Animation 16 | - Git 17 | - Microsoft Office 18 | 19 | - Willing to relocate 20 | - Willing to travel 21 | """ 22 | 23 | demo_resume_text = """ 24 | John Doe 25 | 123 Main Street 26 | Anytown, USA 12345 27 | 555-555-5555 28 | 29 | ## Education 30 | - **Bachelor of Science in Computer Science** 31 | Ohio State University, Columbus, Ohio 32 | Year of Graduation: 2020 33 | 34 | ## Skills: 35 | - Proficient in iOS app development using Swift and Objective-C 36 | - Strong knowledge of iOS frameworks such as UIKit, Core Data, and Core Animation 37 | 38 | ## Experience: 39 | - **iOS Developer** 40 | ABC Company, Anytown, USA 41 | January 2019 - Present 42 | - Developed and maintained 4 iOS apps that are used by thousands of users 43 | - Worked with the design team to create an app that was featured in the App Store 44 | 45 | ## Projects: 46 | - **Pooping selfie app** 47 | - Created an app that allows users to take a selfie only while pooping 48 | - Image recognition algorithm detects if the user is pooping, sees the bathroom, and is wearing pants. 49 | """ 50 | 51 | demo_cover_letter_text = """ 52 | Dear Hiring Manager, 53 | 54 | I am writing to apply for the [[position]] position at [[company]]. With a Bachelor of Science in Computer Science and 2 years of experience specializing in iOS app development, I am confident in my ability to contribute to innovative mobile solutions. 55 | 56 | I have a strong command of iOS frameworks such as UIKit, Core Data, and Core Animation, and I am proficient in Swift and Objective-C. I have a proven track record of delivering high-quality products, meeting deadlines, and collaborating effectively with cross-functional teams. 57 | 58 | I am excited to bring my expertise in developing key features and resolving bugs to your team. Projects like SocialConnect and eShop demonstrate my leadership in implementing user authentication, real-time messaging, and push notifications, as well as integrating RESTful APIs and optimizing app performance with Core Data. 59 | 60 | As an Apple Certified iOS Developer, I stay up-to-date with the latest trends and technologies. I possess excellent problem-solving and communication skills, and I am committed to driving the development of cutting-edge mobile solutions. 61 | 62 | I am confident that my technical skills and motivation make me an excellent fit at [[company]]. Thank you for considering my application. I have attached my resume and look forward to the opportunity to discuss my qualifications further. 63 | 64 | Sincerely, 65 | John Doe 66 | """ 67 | 68 | demo_job_description_text = """ 69 | 70 | ## Job Description 71 | - **iOS Developer** 72 | - Company: ZXY Incorporated 73 | - Location: Sometown, USA 74 | 75 | ## Requirements 76 | - Proficient in iOS app development using Swift and Objective-C 77 | - Strong knowledge of iOS frameworks such as UIKit, Core Data, and Core Animation 78 | - Experience developing iOS apps and working with a team to create an app that was featured in the App Store 79 | - Word experience is a plus 80 | 81 | ## Soft Skills 82 | - Excellent communication skills 83 | - Ability to work in a team from home 84 | 85 | Travel up to 25% of the time. 86 | """ 87 | 88 | demo_job_description_real_text = """ 89 | Position: iOS Developer 90 | Company: August 91 | Location: United Kingdom Remote 92 | £60,000/yr - £90,000/yr · Full-time 93 | 94 | 📱iOS Apple Developer (Senior-range) 95 | 96 | 🏝 Remote Working - London office 97 | 98 | 🇬🇧 UK Based Applicants Only 99 | 100 | 💵 £60K - £90K+ (DOE - Negotiable further for extraordinary candidates) 101 | 102 | August is the disruptive platform allowing one to manage all renting needs in one app. Our All-In-One Solution is the first and only platform designed for both Landlords and Tenants, enabling modern, seamless communication, rental payments, e-contracts management, and more. 103 | 104 | We are unique because… 105 | 106 | -Open-banking integration, revolutionising rental payment and management. 107 | 108 | -User experience is our core priority, we want everything to be as pretty as you :-) 109 | 110 | -Our technology brings real automation, providing huge time savings 111 | 112 | -A few more things, We’ll tell you more if we like you ;-) 113 | 114 | 👀 We are Looking for… 115 | 116 | Front-End Apple iOS SwiftUI Developer 117 | 3+ years Professional Experience developing in Apple 118 | Solid understanding of HTTP and WebAPIs with JSON and Swagger 119 | Proficient & knowledgeable in designing a mobile experience for variable screen sizes for native iOS using SwiftUI 120 | Strong knowledge of Apple design principles, interface guidelines, patterns, and best practices 121 | Third party SDKs integration experience eg. Google Firebase, Meta and Facebook SDKs 122 | Test driven development TDD, logging and crash reporting experience 123 | 🏗 To Do… 124 | 125 | Developing, testing, deploying & maintaining applications - creating elegant Mobile UI/UX apple applications. 126 | Working from user stories and tasks 127 | Work with back end developers to consume WebAPIs as well as a range of other stakeholders 128 | Ability to understand & implement business requirements and translate them into technical requirements 129 | Create and understand secure apps and have a disciplined approach to versioning, releases and environments 130 | Produce documentation and promote to team 131 | Work to improve performance across our technological stack 132 | 🙋🏻‍♂️ And You… 133 | 134 | Ideally have demonstrable portfolio of previous App work 135 | Keen eye to detail and elegant mobile UI/UX 136 | Agile/Scrum way of working and experience, Azure DevOps (ADO) familiarity with repos, pipelines and boards 137 | Multi-functional, can-do attitude 138 | As a startup willingness to try/suggest new ideas 139 | Remote first but meet up occasionally with other team members and the organisation 140 | The Boring stuff, Benefits, Blah… 141 | 142 | 🎁Benefits 143 | 144 | Pret Coffee Subscription 145 | Pocket Money, £60 per month (Chocolate, Cigarettes… you decide it's on us!) 146 | Company Laptop/ Equipments 147 | x2 Get out of Jail free cards (Hangover/Duvet Days) 148 | Share Option Scheme 149 | Pluralsight subscription or training platform of your choice 150 | 📝The Details 151 | 152 | Annual Leave 25 days, rising to 29 days (annually) 153 | Pension Scheme 154 | Enhanced family friendly policies from day one 155 | Remote first with occasional London team meetings requirement or hybrid 156 | Training encouraged/career development from day one 157 | Regular salary performance/reviews 158 | Supportive culture with like-minded techies 159 | """ 160 | 161 | demo_job_description_real_text_summary = """ 162 | Company: August 163 | Location: United Kingdom Remote 164 | £60,000/yr - £90,000/yr · Full-time 165 | Role: iOS Developer 166 | 167 | ## Requirements 168 | | Hard Skills | experience | 169 | | ---------------- | ---------- | 170 | | Apple Developer | 3+ years Professional Experience | 171 | | HTTP and WebAPIs | Solid understanding | 172 | | SwiftUI | Proficient & knowledgeable | 173 | | Apple design principles | Strong knowledge | 174 | | Third party SDKs | Integration experience | 175 | | TDD | Test driven development | 176 | | Logging and crash reporting | experience | 177 | 178 | | Soft Skills | experience | 179 | | ----------- | ---------- | 180 | | Agile/Scrum | Way of working and experience | 181 | | Azure DevOps | Familiarity with repos, pipelines and boards | 182 | | Multi-functional | Can-do attitude | 183 | | Willingness to try/suggest new ideas | | 184 | 185 | ## More information 186 | - Developing, testing, deploying & maintaining applications - creating elegant Mobile UI/UX apple applications. 187 | - Working from user stories and tasks. 188 | - Work with back end developers to consume WebAPIs as well as a range of other stakeholders. 189 | - Ability to understand & implement business requirements and translate them into technical requirements. 190 | - Create and understand secure apps and have a disciplined approach to versioning, releases and environments. 191 | - Produce documentation and promote to team. 192 | - Work to improve performance across our technological stack. 193 | - Ideally have demonstrable portfolio of previous App work. 194 | - Keen eye to detail and elegant mobile UI/UX. 195 | - Remote first but meet up occasionally with other team members and the organisation. 196 | - Benefits include Pret Coffee Subscription, Pocket Money, Company Laptop/ Equipments, Share Option Scheme, Pluralsight subscription or training platform of your choice, Annual Leave 25 days, rising to 29 days, Pension Scheme, Enhanced family friendly policies from day one, Training encouraged/career development from day one, Regular salary performance/reviews, Supportive culture with like-minded techies. 197 | """ 198 | 199 | demo_job_description_real_text_summary_healthcare = """ 200 | Company: VitalCare 201 | 202 | Location: United Kingdom (Remote) 203 | 204 | £60,000/yr - £90,000/yr · Full-time 205 | 206 | Role: iOS Developer 207 | 208 | ## About VitalCare 209 | VitalCare is a leading provider of healthcare solutions, with a focus on improving patient outcomes. We are a fast-growing company with a strong focus on innovation and technology. We are looking for a talented iOS Developer to join our team. 210 | 211 | ## Requirements 212 | 213 | | Hard Skills | Experience | 214 | | --------------------------- | ----------------------- | 215 | | Apple Developer | 3+ years Professional Experience | 216 | | HTTP and WebAPIs | Solid understanding | 217 | | SwiftUI | Proficient & knowledgeable | 218 | | Apple design principles | Strong knowledge | 219 | | Third party SDKs | Integration experience | 220 | | TDD | Test driven development | 221 | | Logging and crash reporting | Experience | 222 | 223 | | Soft Skills | Experience | 224 | | ------------------------ | ----------------------- | 225 | | Agile/Scrum | Way of working and experience | 226 | | Azure DevOps | Familiarity with repos, pipelines and boards | 227 | | Multi-functional | Can-do attitude | 228 | | Willingness to try/suggest new ideas | | 229 | 230 | ## More information 231 | 232 | - Developing, testing, deploying & maintaining applications - creating elegant Mobile UI/UX apple applications for VitalCare, a leading healthcare company. 233 | - Working from user stories and tasks, ensuring that the developed applications meet the specific needs of the healthcare industry. 234 | - Collaborating with back-end developers to consume WebAPIs and collaborating with a range of other stakeholders, including healthcare professionals, to gather requirements and deliver high-quality applications. 235 | - Ability to understand and implement business requirements, translating them into technical requirements for healthcare applications. 236 | - Creating and understanding secure apps with a strong emphasis on patient data privacy and security. Adhering to disciplined approaches to versioning, releases, and environments. 237 | - Producing documentation and sharing knowledge with the team, promoting best practices and ensuring efficient collaboration. 238 | - Striving to improve performance across our technological stack, exploring new tools, frameworks, and methodologies to enhance the development process. 239 | - Ideally, having a demonstrable portfolio of previous healthcare-related app work, showcasing a keen eye for detail and elegant mobile UI/UX design. 240 | - Remote-first work environment, but occasional meet-ups with other team members and the organization to foster teamwork and collaboration. 241 | - Benefits include Pret Coffee Subscription, Pocket Money, Company Laptop/Equipment, Share Option Scheme, Pluralsight subscription or training platform of your choice, Annual Leave 25 days, rising to 29 days, Pension Scheme, Enhanced family-friendly policies from day one, Training encouraged/career development from day one, Regular salary performance/reviews, Supportive culture with like-minded techies. """ 242 | 243 | demo_job_titles_filters = """ 244 | # About this document 245 | On this document, explain the rules to filter through job postings. 246 | 247 | Both `# Job Title Filters` and `# Job Description Filters` sections, must be included on the document. 248 | 249 | ----- 250 | 251 | # Job Title Filters 252 | I'm looking for jobs that are close to (but not exclusively): Senior Developer, Frontend Developer, IOS Developer, Product Manager. Other roles similar to these are also acceptable. 253 | 254 | Also, I'm not interested in junior positions, I'm looking for a mid-level position or higher. 255 | 256 | Also there are some industries I'm not interested in: blockchain and healthcare. 257 | 258 | # Job Description Filters 259 | I'm looking for jobs that are close to (but not exclusively): Senior Developer, Frontend Developer, IOS Developer, Product Manager. Other roles similar to these are also acceptable. 260 | 261 | I'm not interested in junior positions. I'm looking for a mid-level positions or higher. 262 | 263 | I seek responsibilities that are close to (but not exclusively): UX design, product design, agile coding practices, animations, and web APIs usage. 264 | 265 | Ideal roles are those where UX/UI design knowledge is required, as well as, experience on agile methodologies, and startup experience. A job not meeting these requirements is still acceptable. 266 | 267 | I'm not interested on certain sectors: blockchain, healthcare, health, medicine, or accounting; these sectors are an automatic 'no'. 268 | """ 269 | 270 | # Set up the answerer 271 | answerer = GPTAnswerer(demo_resume_text, personal_data_text, demo_cover_letter_text, demo_job_titles_filters) 272 | # Use a description resume to test the answerer, so we don't have to wait for the resume summary to be generated 273 | answerer.job_description_summary = demo_job_description_real_text_summary 274 | # Correct way to do it: answerer.job_description = demo_job_description_real_text 275 | 276 | def test_answer_question_textual_wide_range_name(self): 277 | question = "What is your name?" 278 | answer = self.answerer.answer_question_textual_wide_range(question) 279 | print(f"Name: {answer}") 280 | self.assertIn("John Doe", answer) 281 | 282 | def test_answer_question_textual_wide_range_phone_number(self): 283 | question = "What is your phone number?" 284 | answer = self.answerer.answer_question_textual_wide_range(question) 285 | print(f"Phone number: {answer}") 286 | self.assertIn("555-555-5555", answer) 287 | 288 | def test_answer_question_textual_wide_range_experience(self): 289 | question = "What is the name of the last company you worked at?" 290 | answer = self.answerer.answer_question_textual_wide_range(question) 291 | print(f"Experience: {answer}") 292 | self.assertIn("ABC Company", answer) 293 | 294 | def test_answer_question_textual_wide_range_cover_letter(self): 295 | question = "Cover Letter" 296 | answer = self.answerer.answer_question_textual_wide_range(question) 297 | print(f"Cover letter: {answer}") 298 | 299 | question = "Your message to the hiring manager" 300 | answer = self.answerer.answer_question_textual_wide_range(question) 301 | print(f"Your message to the hiring manager: {answer}") 302 | 303 | def test_summarize_job_description(self): 304 | # summary = self.answerer.job_description_summary # It's a computed property 305 | summary = self.answerer.summarize_job_description(self.demo_job_description_real_text) 306 | print(f"Summary: \n{summary}") 307 | 308 | def test_answer_question_textual(self): 309 | question = "What is your name?" 310 | answer = self.answerer.answer_question_textual(question) 311 | print(f"Name: {answer}") 312 | self.assertIn("John Doe", answer) 313 | 314 | def test_answer_question_from_options(self): 315 | question = "What is your preferred version control?" 316 | options = ["git", "svn", "mercurial"] 317 | answer = self.answerer.answer_question_from_options(question, options) 318 | print(f"{question}, Options {options}. Answer: {answer}") 319 | self.assertIn("git", answer) 320 | 321 | def test_job_title_passes_filters(self): 322 | print(f"Testing job title filters. User provided {self.answerer.job_filtering_rules}") 323 | self.assertTrue(self.answerer.job_title_passes_filters("iOS Developer - MeetKai Metaverse - REMOTE")) 324 | self.assertFalse(self.answerer.job_title_passes_filters("Nurse")) 325 | self.assertFalse(self.answerer.job_title_passes_filters("Junior IOS Developer")) 326 | self.assertFalse(self.answerer.job_title_passes_filters("Junior IOS Developer - 100% Remote")) 327 | self.assertFalse(self.answerer.job_title_passes_filters("Product Manager - Healthcare")) 328 | 329 | def test_job_description_passes_filters(self): 330 | self.answerer.job_description_summary = self.demo_job_description_real_text_summary 331 | self.assertTrue(self.answerer.job_description_passes_filters()) 332 | 333 | self.answerer.job_description_summary = self.demo_job_description_real_text_summary_healthcare 334 | self.assertFalse(self.answerer.job_description_passes_filters()) 335 | 336 | 337 | if __name__ == '__main__': 338 | unittest.main() 339 | -------------------------------------------------------------------------------- /utils.py: -------------------------------------------------------------------------------- 1 | import re 2 | from pathlib import Path 3 | from itertools import takewhile 4 | 5 | 6 | class Markdown: 7 | @staticmethod 8 | def extract_content_from_markdown(markdown_text: str, title: str) -> str: 9 | """ 10 | Extracts the content from a Markdown text, starting from a title. 11 | :param markdown_text: The Markdown text. 12 | :param title: The title to start from. 13 | :return: The content of the Markdown text, starting from the title, without the title. 14 | """ 15 | content = "" 16 | found = False 17 | found_title_level = 0 # The level of the title we are looking for -> # Title -> level 1, ## Title -> level 2, etc. 18 | 19 | for line in markdown_text.split('\n'): 20 | line = line.strip() 21 | if line.startswith('#'): 22 | line_title = re.sub(r'#\s*', '', line) 23 | current_title_level = len(list(takewhile(lambda c: c == '#', line))) 24 | 25 | if line_title == title: 26 | found = True 27 | found_title_level = current_title_level 28 | continue 29 | elif found and current_title_level <= found_title_level: 30 | break 31 | if found: 32 | content += line + '\n' 33 | 34 | return content.strip() 35 | 36 | @staticmethod 37 | def extract_content_from_markdown_file(file_path: Path, title: str) -> str: 38 | """ 39 | Extracts the content from a Markdown file, starting from a title. 40 | :param file_path: The path to the Markdown file. 41 | :param title: The title to start from. 42 | :return: The content of the Markdown file, starting from the title, without the title. 43 | """ 44 | with open(file_path, 'r') as file: 45 | markdown_text = file.read() 46 | 47 | return Markdown.extract_content_from_markdown(markdown_text, title) 48 | --------------------------------------------------------------------------------