├── .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 | 
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 |
--------------------------------------------------------------------------------