diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..08a27fb --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# Ignore everything +* +config.json +# Allow specific files +!.gitignore +!main.py +!configExample.json +!README.md +!LICENCE +!requirements.txt +!e2e_tests.py +!unit_tests.py \ No newline at end of file diff --git a/LICENCE b/LICENCE new file mode 100644 index 0000000..8df1a01 --- /dev/null +++ b/LICENCE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 dilaratznr + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index 8729077..4ba0d6a 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,125 @@ # EasyApply-Linkedin -With this tool you can easily automate the process of applying for jobs on LinkedIn! +With this tool, you can easily automate the process of applying for jobs on LinkedIn! -## Getting started +## Getting Started These instructions will get you a copy of the project up and running on your local machine for development and testing purposes. ### Prerequisites -1. Install selenium. I used `pip` to install the selenium package. - -`pip install selenium` +1. Install Selenium. Use `pip` to install the Selenium package: + ```sh + pip install selenium + ``` 2. Selenium requires a driver to interface with the chosen browser. Make sure the driver is in your path, you will need to add your `driver_path` to the `config.json` file. -I used the Chrome driver, you can download it [here](https://sites.google.com/a/chromium.org/chromedriver/downloads). You can also download [Edge](https://developer.microsoft.com/en-us/microsoft-edge/tools/webdriver/), [Firefox](https://github.com/mozilla/geckodriver/releases) or [Safari](https://webkit.org/blog/6900/webdriver-support-in-safari-10/). Depends on your preferred browser. + I used the Firefox driver, you can download it [here](https://github.com/mozilla/geckodriver/releases). You can also download drivers for [Chrome](https://sites.google.com/a/chromium.org/chromedriver/downloads), [Edge](https://developer.microsoft.com/en-us/microsoft-edge/tools/webdriver/), or [Safari](https://webkit.org/blog/6900/webdriver-support-in-safari-10/), depending on your preferred browser. + +### Installation + +1. Clone the repository: + ```sh + git clone https://github.com/Gabo-Tech/EasyApply-Linkedin.git + cd EasyApply-Linkedin + ``` + +2. Install the necessary packages: + ```sh + pip install -r requirements.txt + ``` + +3. Update the `config.json` file with your information: + ```json + { + "email": "example@example.com", + "password": "securePassword123!", + "keywords": ["Web Developer", "JavaScript", "React"], + "keywordsToAvoid": ["C++", ".NET"], + "locations": ["New York", "Los Angeles", "San Francisco"], + "driver_path": "/usr/local/bin/geckodriver", + "sortBy": "R", + "filters": { + "easy_apply": true, + "experience": ["Internship", "Entry level", "Associate", "Mid-Senior level", "Director", "Executive"], + "jobType": ["Full-time", "Part-time", "Contract", "Internship", "Temporary"], + "timePostedRange": ["Any Time", "Last Month", "Past Week", "Past 24 hours"], + "workplaceType": ["Remote", "Hybrid", "On-site"], + "less_than_10_applicants": true + } + } + ``` + +4. Update the location codes in the script: + ```python + LOCATION_MAPPING = { + "Canada": "101174742", + "Portugal": "100364837", + "Switzerland": "106693272", + "United States": "103644278", + "Belgium": "100565514", + "Netherlands": "102890719", + "DACH": "91000006", + "Benelux": "91000005", + "European Union": "91000000", + "European Economic Area": "91000002", + "Germany": "101282230", + "Spain": "105646813", + "United Kingdom": "101165590" + } + ``` + You can find the code in the `geoId` found in the LinkedIn URL after doing a job search. These are the correct ones if you don't want to search elsewhere, but there are many more. ### Usage -Fork and clone/download the repository and change the configuration file with: +1. Run the application: + ```sh + python main.py + ``` + +### Features + +- **Automated Job Applications**: Automatically apply to jobs that match your keywords and location. +- **Filter Options**: Customize filters for experience level, job type, time posted, workplace type, and more. +- **Logging**: Keep track of errors and the companies you've applied to. + +### Customization + +You can customize the job search and application process by editing the `config.json` file: +- **email**: Your LinkedIn email address. +- **password**: Your LinkedIn password. +- **keywords**: Keywords for finding specific job titles (e.g., "Machine Learning Engineer", "Data Scientist"). +- **keywordsToAvoid**: Keywords to exclude from your search. +- **locations**: Locations where you are currently looking for a position. +- **driver_path**: Path to your downloaded WebDriver. +- **sortBy**: Sort order for job listings. +- **filters**: Various filters to narrow down the job search (e.g., easy apply, experience level, job type, etc.). + +### Testing + +#### Unit Tests + +Unit tests mock the Selenium WebDriver to test methods in isolation without making actual web requests. + +Run the unit tests: +```bash +python unit_tests.py +``` + +#### E2E Tests + +End-to-end tests using `pytest` and `selenium` require an actual web browser to run. + +Run the E2E tests: +```bash +pytest e2e_tests.py +``` + +### Contributing -* Your email linked to LinkedIn. -* Your password. -* Keywords for finding specific job titles fx. Machine Learning Engineer, Data Scientist, etc. -* The location where you are currently looking for a position. -* The driver path to your downloaded webdriver. +Please feel free to comment or give suggestions/issues. Fork and submit pull requests for any enhancements or bug fixes. -Run `python main.py`. +### License -Please feel free to comment or give suggestions/issues. +This project is licensed under the MIT License - see the [LICENSE](https://github.com/Gabo-Tech/EasyApply-Linkedin/blob/master/LICENCE) file for details. \ No newline at end of file diff --git a/config.json b/config.json deleted file mode 100644 index 0b432a4..0000000 --- a/config.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "email" : "your_email", - "password" : "your_password", - "keywords" : "your_keywords", - "location" : "your_location", - "driver_path" : "your_path_to_webdriver" -} \ No newline at end of file diff --git a/configExample.json b/configExample.json new file mode 100644 index 0000000..603289f --- /dev/null +++ b/configExample.json @@ -0,0 +1,195 @@ +{ + "email": "example@domain.com", + "password": "YourSecurePassword123!", + "keywords": [ + "TypeScript", + "Angular", + "React", + "React Native", + "Node", + "JavaScript", + "Frontend Engineer", + "Full-Stack Engineer", + "Backend Engineer" + ], + "keywordsToAvoid": [ + "C++", + ".NET", + "Analyst", + "PHP", + "Python", + "C", + "Java", + "Go", + "Rust", + "Kotlin", + "Swift", + "Objective-C", + "Robotic", + "Data", + "Science", + "Cloud", + "AI", + "ML", + "DL", + "NLP", + "CV", + "DevOps", + "Solidity" + ], + "locations": [ + "Canada", + "Portugal", + "Switzerland", + "Belgium", + "Netherlands", + "DACH", + "Benelux", + "European Union", + "European Economic Area", + "Germany", + "Spain", + "United States", + "United Kingdom" + ], + "driver_path": "/path/to/geckodriver", + "sortBy": "R", + "filters": { + "easy_apply": true, + "experience": [], + "jobType": [ + "Full-time", + "Contract" + ], + "timePostedRange": [], + "workplaceType": [ + "Remote", + "Hybrid" + ], + "less_than_10_applicants": false + }, + "collection": "", + "aiContext": { + "preferences": { + "workplaceType": "Remote", + "workplaceTypeAlternative": [ + "Hybrid" + ], + "jobType": "Contract", + "jobTypeAlternative": [ + "Full-time" + ], + "prereferredEnd": "Backend", + "prereferredEndAlternative": [ + "Full-Stack", + "Frontend" + ] + }, + "currentLocation": "Fake City, Country", + "willingToRelocate": true, + "experience": [ + { + "title": "Full-Stack Developer", + "description": "Developed an e-commerce platform using MERN stack.", + "date": "Jan 2021 - Present", + "company": "Tech Solutions", + "location": "Remote", + "skills": [ + "TypeScript", + "React", + "Node.js", + "Express.js", + "MongoDB" + ] + }, + { + "title": "Frontend Engineer", + "description": "Designed and implemented user interfaces with Angular.", + "date": "Jun 2019 - Dec 2020", + "company": "Web Creators", + "location": "San Francisco, CA", + "skills": [ + "JavaScript", + "Angular", + "HTML", + "CSS" + ] + } + ], + "education": [ + { + "title": "Bachelor of Science - Computer Science", + "description": "Studied various aspects of computer science, including algorithms, data structures, and web development.", + "date": "Sep 2015 - Jun 2019", + "company": "University of Somewhere", + "skills": [ + "Algorithms", + "Data Structures", + "Web Development", + "Machine Learning" + ] + } + ], + "projects": [ + { + "name": "Project Alpha", + "description": "A project management tool developed using React and Node.js.", + "technologies": [ + "React", + "Node.js", + "Express", + "MongoDB", + "Docker" + ] + } + ], + "skills": [ + "TypeScript", + "JavaScript", + "Angular", + "React", + "Node.js", + "Express.js", + "MongoDB", + "HTML", + "CSS" + ] + }, + "user_inputs": { + "United States": { + "City\nCity": "Fake City, USA", + "What is your gender?\nWhat is your gender?": "Prefer not to say", + "Do you consider yourself to be disabled as defined by the Equality Act 2010?": "No", + "Do you require any particular arrangements to support you in the recruitment and selection process?": "No", + "What is your ethnic origin?\nWhat is your ethnic origin?": "White", + "I Agree Terms & Conditions": true, + "LinkedIn": true + }, + "Belgium": { + "What is your preferred name?": "John Doe", + "Do you now, or will you in the future, require visa sponsorship to work for our company in the country this role is advertised for?": "No", + "What are your salary expectations?": "60000", + "English": true, + "City\nCity": "Fake City, Belgium", + "I Agree Terms & Conditions": true, + "What language(s) do you speak and/or understand? What is your level?": "English, French - fluent", + "Are you legally authorized to work in the country of the job?": "Yes" + }, + "Netherlands": { + "What is your current location?": "Fake City, Netherlands", + "City\nCity": "Fake City, Netherlands", + "Legal Name (if different than above)": "John Doe", + "How did you hear about this job?": "LinkedIn", + "Do you now or will you in the future require immigration sponsorship to work at Company?": "No", + "What are your salary expectations?": "65000", + "This vacancy is for an internal position and we do not contract freelancers for this position. Do you acknowledge this statement?": "Yes" + }, + "Spain": { + "What is your level of proficiency in English?\nWhat is your level of proficiency in English?": "Native or bilingual", + "City\nCity": "Fake City, Spain", + "Indica tus expectativas salariales frente a un cambio.": "50000", + "Are you legally authorized to work in Spain?": "Yes", + "What is your salary expectation?": "50000" + } + } +} diff --git a/e2e_tests.py b/e2e_tests.py new file mode 100644 index 0000000..a17eff9 --- /dev/null +++ b/e2e_tests.py @@ -0,0 +1,113 @@ +import pytest +from selenium import webdriver +from selenium.webdriver.firefox.service import Service as FirefoxService +from main import EasyApplyLinkedin + +@pytest.fixture +def setup_browser(): + service = FirefoxService(executable_path="/usr/local/bin/geckodriver") + driver = webdriver.Firefox(service=service) + yield driver + driver.quit() + +@pytest.fixture +def setup_bot(setup_browser): + data = { + "email": "sendmessage@gabo.email", + "password": "***,****,****", + "keywords": [ + "TypeScript", + "Angular", + "React", + "React Native", + "Node", + "JavaScript", + "Frontend Engineer", + "Full-Stack Engineer", + "Backend Engineer" + ], + "keywordsToAvoid": [ + "C++", + ".NET", + "Analyst", + "PHP", + "Python", + "C", + "Java", + "Go", + "Rust", + "Kotlin", + "Swift", + "Objective-C", + "Rust", + "Kotlin", + "Swift", + "C#", + ".Net", + ".net", + "Robotic", + "Data", + "Science", + "Cloud", + "Robotics", + "AI", + "ML", + "DL", + "NLP", + "CV", + "DevOps", + "Solidity" + ], + "locations": [ + "Canada", + "Portugal", + "Switzerland", + "Belgium", + "Netherlands", + "DACH", + "Benelux", + "European Union", + "European Economic Area", + "Germany", + "Spain", + "United States", + "United Kingdom" + ], + "driver_path": "/usr/local/bin/geckodriver", + "sortBy": "R", + "filters": { + "easy_apply": True, + "experience": [], + "jobType": [ + "Full-time", + "Contract" + ], + "timePostedRange": [], + "workplaceType": [ + "Remote", + "Hybrid" + ], + "less_than_10_applicants": False + } + } + bot = EasyApplyLinkedin(data) + bot.driver = setup_browser + return bot + +def test_login_linkedin(setup_bot): + setup_bot.login_linkedin() + assert "feed" in setup_bot.driver.current_url + +def test_job_search(setup_bot): + setup_bot.login_linkedin() + setup_bot.job_search() + assert "jobs/search" in setup_bot.driver.current_url + +def test_find_offers(setup_bot): + setup_bot.login_linkedin() + setup_bot.job_search() + setup_bot.find_offers() + assert len(setup_bot.applied_companies) > 0 + +if __name__ == "__main__": + pytest.main() diff --git a/main.py b/main.py index 0232efa..fe1a178 100644 --- a/main.py +++ b/main.py @@ -1,179 +1,818 @@ +import json +import time +import urllib.parse +import logging +from datetime import datetime, timedelta +from pathlib import Path from selenium import webdriver +from selenium.webdriver.common.by import By from selenium.webdriver.common.keys import Keys from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.support.ui import WebDriverWait -from selenium.webdriver.common.by import By -from selenium.common.exceptions import NoSuchElementException, ElementClickInterceptedException, NoSuchElementException -from selenium.webdriver.common.action_chains import ActionChains -import time -import re -import json +from selenium.common.exceptions import ( + NoSuchElementException, + ElementNotInteractableException, + StaleElementReferenceException, + TimeoutException, + ElementClickInterceptedException, +) +from selenium.webdriver.firefox.service import Service as FirefoxService class EasyApplyLinkedin: + BASE_URL = "https://www.linkedin.com/jobs/search/" + COLLECTION_URLS = { + "small_business": "https://www.linkedin.com/jobs/collections/small-business", + "remote_jobs": "https://www.linkedin.com/jobs/collections/remote-jobs", + "easy_apply": "https://www.linkedin.com/jobs/collections/easy-apply", + "top_applicant": "https://www.linkedin.com/jobs/collections/top-applicant" + } + ERROR_LOG_PATH = Path("error_log.json") + APPLIED_COMPANIES_LOG_PATH = Path("applied_companies_log.json") + FAILED_APPLICATIONS_LOG_PATH = Path("failed_applications_log.json") + + TIME_POSTED_MAPPING = { + "Any Time": "", + "Last Month": "r2592000", + "Past Week": "r604800", + "Past 24 hours": "r86400", + } + + EXPERIENCE_MAPPING = { + "Internship": "1", + "Entry level": "2", + "Associate": "3", + "Mid-Senior level": "4", + "Director": "5", + "Executive": "6", + } + + WORKPLACE_TYPE_MAPPING = { + "Remote": "2", + "Hybrid": "3", + "On-site": "1", + } + + JOB_TYPE_MAPPING = { + "Full-time": "F", + "Part-time": "P", + "Contract": "C", + "Internship": "I", + "Temporary": "T", + } + + TITLE_MAPPING = { + "Engineer": "9", + "Developer": "25201", + "Manager": "25170", + "Specialist": "1456", + "Consultant": "3731", + } + + COMMITMENTS_MAPPING = { + "Full-time": "1", + "Part-time": "2", + "Contract": "3", + "Temporary": "4", + "Volunteer": "5", + } + + LOCATION_MAPPING = { + "Texas": "102748797", + "Canada": "101174742", + "Portugal": "100364837", + "Switzerland": "106693272", + "United States": "103644278", + "Belgium": "100565514", + "Netherlands": "102890719", + "DACH": "91000006", + "Benelux": "91000005", + "European Union": "91000000", + "European Economic Area": "91000002", + "Germany": "101282230", + "Spain": "105646813", + "United Kingdom": "101165590", + } def __init__(self, data): - """Parameter initialization""" + self.email = data["email"] + self.password = data["password"] + self.keywords = " OR ".join(data["keywords"]) + self.keywords_to_avoid = " NOT ".join(data["keywordsToAvoid"]) + self.locations = data["locations"] + self.filters = data["filters"] + self.collection = data.get("collection", "") + self.sort_by = data["sortBy"] + self.context_data = data + self.current_location_index = 0 + if "user_inputs" not in self.context_data: + self.context_data["user_inputs"] = {} + firefox_service = FirefoxService(executable_path=data["driver_path"]) + self.driver = webdriver.Firefox(service=firefox_service) + self.init_logging() + + def init_logging(self): + logging.basicConfig(level=logging.INFO) + self.error_logger = logging.getLogger("ErrorLogger") + self.applied_companies = self.load_json(self.APPLIED_COMPANIES_LOG_PATH) + self.failed_applications = self.load_json(self.FAILED_APPLICATIONS_LOG_PATH) + + def load_json(self, path): + if path.exists(): + try: + with path.open("r") as file: + return json.load(file) + except json.JSONDecodeError: + self.log_error(f"Error decoding JSON from {path}") + return {} + return {} + + def save_json(self, path, data): + with path.open("w") as file: + json.dump(data, file, indent=4) - self.email = data['email'] - self.password = data['password'] - self.keywords = data['keywords'] - self.location = data['location'] - self.driver = webdriver.Chrome(data['driver_path']) + def log_error(self, error_msg): + self.error_logger.error(error_msg) + errors = self.load_json(self.ERROR_LOG_PATH) + errors[str(datetime.now())] = error_msg + self.save_json(self.ERROR_LOG_PATH, errors) + self.cleanup_error_log() + + def log_info(self, message): + logging.info(message) + + def cleanup_error_log(self): + errors = self.load_json(self.ERROR_LOG_PATH) + cutoff = datetime.now() - timedelta(days=1) + errors = {k: v for k, v in errors.items() if datetime.fromisoformat(k) > cutoff} + self.save_json(self.ERROR_LOG_PATH, errors) + + def log_applied_company(self, company): + self.applied_companies[company] = str(datetime.now()) + self.save_json(self.APPLIED_COMPANIES_LOG_PATH, self.applied_companies) + self.cleanup_applied_companies_log() + + def cleanup_applied_companies_log(self): + cutoff = datetime.now() - timedelta(weeks=2) + self.applied_companies = { + k: v + for k, v in self.applied_companies.items() + if datetime.fromisoformat(v) > cutoff + } + self.save_json(self.APPLIED_COMPANIES_LOG_PATH, self.applied_companies) + + def log_failed_application(self, company): + self.failed_applications[company] = str(datetime.now()) + self.save_json(self.FAILED_APPLICATIONS_LOG_PATH, self.failed_applications) + self.cleanup_failed_applications_log() + + def cleanup_failed_applications_log(self): + cutoff = datetime.now() - timedelta(weeks=2) + self.failed_applications = { + k: v + for k, v in self.failed_applications.items() + if datetime.fromisoformat(v) > cutoff + } + self.save_json(self.FAILED_APPLICATIONS_LOG_PATH, self.failed_applications) def login_linkedin(self): - """This function logs into your personal LinkedIn profile""" - - # go to the LinkedIn login url - self.driver.get("https://www.linkedin.com/login") - - # introduce email and password and hit enter - login_email = self.driver.find_element_by_name('session_key') - login_email.clear() - login_email.send_keys(self.email) - login_pass = self.driver.find_element_by_name('session_password') - login_pass.clear() - login_pass.send_keys(self.password) - login_pass.send_keys(Keys.RETURN) - + try: + self.driver.get("https://www.linkedin.com/login") + self.driver.add_cookie({ + 'name': 'li_theme', + 'value': 'dark', + 'domain': '.linkedin.com', + 'path': '/', + 'expires': int(time.time() + 365 * 24 * 60 * 60), + 'secure': True, + 'httpOnly': False + }) + self.driver.refresh() + WebDriverWait(self.driver, 10).until( + EC.presence_of_element_located((By.NAME, "session_key")) + ) + login_email = self.driver.find_element(By.NAME, "session_key") + login_email.clear() + login_email.send_keys(self.email) + WebDriverWait(self.driver, 10).until( + EC.presence_of_element_located((By.NAME, "session_password")) + ) + login_pass = self.driver.find_element(By.NAME, "session_password") + login_pass.clear() + login_pass.send_keys(self.password) + login_pass.send_keys(Keys.RETURN) + WebDriverWait(self.driver, 30).until( + EC.presence_of_element_located((By.LINK_TEXT, "Jobs")) + ) + except Exception as e: + self.log_error(f"Login error: {e}") + def job_search(self): - """This function goes to the 'Jobs' section a looks for all the jobs that matches the keywords and location""" - - # go to Jobs - jobs_link = self.driver.find_element_by_link_text('Jobs') - jobs_link.click() - - # search based on keywords and location and hit enter - search_keywords = self.driver.find_element_by_css_selector(".jobs-search-box__text-input[aria-label='Search jobs']") - search_keywords.clear() - search_keywords.send_keys(self.keywords) - search_location = self.driver.find_element_by_css_selector(".jobs-search-box__text-input[aria-label='Search location']") - search_location.clear() - search_location.send_keys(self.location) - search_location.send_keys(Keys.RETURN) - - def filter(self): - """This function filters all the job results by 'Easy Apply'""" - - # select all filters, click on Easy Apply and apply the filter - all_filters_button = self.driver.find_element_by_xpath("//button[@data-control-name='all_filters']") - all_filters_button.click() - time.sleep(1) - easy_apply_button = self.driver.find_element_by_xpath("//label[@for='f_LF-f_AL']") - easy_apply_button.click() - time.sleep(1) - apply_filter_button = self.driver.find_element_by_xpath("//button[@data-control-name='all_filters_apply']") - apply_filter_button.click() + while self.current_location_index < len(self.locations): + try: + WebDriverWait(self.driver, 20).until( + EC.presence_of_element_located((By.LINK_TEXT, "Jobs")) + ) + jobs_link = self.driver.find_element(By.LINK_TEXT, "Jobs") + jobs_link.click() + WebDriverWait(self.driver, 20).until( + EC.presence_of_element_located( + (By.CSS_SELECTOR, "input[aria-label='Search by title, skill, or company']") + ) + ) + search_keywords = self.driver.find_element( + By.CSS_SELECTOR, "input[aria-label='Search by title, skill, or company']") + search_keywords.clear() + search_keywords.send_keys(self.keywords) + search_keywords.send_keys(" NOT ") + search_keywords.send_keys(self.keywords_to_avoid) + WebDriverWait(self.driver, 20).until( + EC.presence_of_element_located( + (By.CSS_SELECTOR, "input[aria-label='City, state, or zip code']") + ) + ) + search_location = self.driver.find_element( + By.CSS_SELECTOR, "input[aria-label='City, state, or zip code']") + search_location.clear() + search_location.send_keys(self.locations[self.current_location_index]) + search_keywords.click() + search_keywords.send_keys(Keys.RETURN) - def find_offers(self): - """This function finds all the offers through all the pages result of the search and filter""" - - # find the total amount of results (if the results are above 24-more than one page-, we will scroll trhough all available pages) - total_results = self.driver.find_element_by_class_name("display-flex.t-12.t-black--light.t-normal") - total_results_int = int(total_results.text.split(' ',1)[0].replace(",","")) - print(total_results_int) - - time.sleep(2) - # get results for the first page - current_page = self.driver.current_url - results = self.driver.find_elements_by_class_name("occludable-update.artdeco-list__item--offset-4.artdeco-list__item.p0.ember-view") - - # for each job add, submits application if no questions asked - for result in results: - hover = ActionChains(self.driver).move_to_element(result) - hover.perform() - titles = result.find_elements_by_class_name('job-card-search__title.artdeco-entity-lockup__title.ember-view') - for title in titles: - self.submit_apply(title) - - # if there is more than one page, find the pages and apply to the results of each page - if total_results_int > 24: - time.sleep(2) + time.sleep(5) + if not self.check_no_results(): + break + else: + self.log_info(f"No matching jobs found in {self.locations[self.current_location_index]}.") + self.current_location_index += 1 - # find the last page and construct url of each page based on the total amount of pages - find_pages = self.driver.find_elements_by_class_name("artdeco-pagination__indicator.artdeco-pagination__indicator--number") - total_pages = find_pages[len(find_pages)-1].text - total_pages_int = int(re.sub(r"[^\d.]", "", total_pages)) - get_last_page = self.driver.find_element_by_xpath("//button[@aria-label='Page "+str(total_pages_int)+"']") - get_last_page.send_keys(Keys.RETURN) - time.sleep(2) - last_page = self.driver.current_url - total_jobs = int(last_page.split('start=',1)[1]) - - # go through all available pages and job offers and apply - for page_number in range(25,total_jobs+25,25): - self.driver.get(current_page+'&start='+str(page_number)) - time.sleep(2) - results_ext = self.driver.find_elements_by_class_name("occludable-update.artdeco-list__item--offset-4.artdeco-list__item.p0.ember-view") - for result_ext in results_ext: - hover_ext = ActionChains(self.driver).move_to_element(result_ext) - hover_ext.perform() - titles_ext = result_ext.find_elements_by_class_name('job-card-search__title.artdeco-entity-lockup__title.ember-view') - for title_ext in titles_ext: - self.submit_apply(title_ext) - else: - self.close_session() + except TimeoutException: + self.log_info("Timeout while trying to access the Jobs page or elements on it.") + self.current_location_index += 1 + except Exception as e: + self.log_error(f"Job search error: {e}") + self.current_location_index += 1 + + def construct_url(self): + current_location = self.locations[self.current_location_index] + combined_keywords = f'{self.keywords} NOT {self.keywords_to_avoid}' + + params = { + "keywords": combined_keywords, + "origin": "JOB_SEARCH_PAGE_JOB_FILTER", + "refresh": "true", + "sortBy": self.sort_by, + } + + if self.filters.get("easy_apply"): + params["f_AL"] = "true" - def submit_apply(self,job_add): - """This function submits the application for the job add found""" + if self.filters.get("experience"): + params["f_E"] = ",".join( + [self.EXPERIENCE_MAPPING[exp] for exp in self.filters["experience"]] + ) - print('You are applying to the position of: ', job_add.text) - job_add.click() - time.sleep(2) - - # click on the easy apply button, skip if already applied to the position + if self.filters.get("jobType"): + params["f_JT"] = ",".join( + [self.JOB_TYPE_MAPPING[jt] for jt in self.filters["jobType"]] + ) + + if self.filters.get("timePostedRange"): + params["f_TPR"] = ",".join( + [self.TIME_POSTED_MAPPING[time] for time in self.filters["timePostedRange"]] + ) + + if self.filters.get("workplaceType"): + params["f_WT"] = ",".join( + [self.WORKPLACE_TYPE_MAPPING[wt] for wt in self.filters["workplaceType"]] + ) + + if self.filters.get("less_than_10_applicants"): + params["f_EA"] = "true" + + if current_location in self.LOCATION_MAPPING: + params["geoId"] = self.LOCATION_MAPPING[current_location] + + query_string = urllib.parse.urlencode(params, safe=",") + url = f"{self.BASE_URL}?{query_string}" + return url + + def apply_filters_and_search(self): + while self.current_location_index < len(self.locations): + search_url = self.construct_url() + self.driver.get(search_url) + time.sleep(5) + + if self.check_no_results(): + self.log_info(f"No matching jobs found in {self.locations[self.current_location_index]}.") + self.current_location_index += 1 + else: + break + + def check_no_results(self): try: - in_apply = self.driver.find_element_by_xpath("//button[@data-control-name='jobdetails_topcard_inapply']") - in_apply.click() + no_results_element = self.driver.find_element( + By.CSS_SELECTOR, "div.jobs-search-no-results-banner" + ) + return no_results_element.is_displayed() except NoSuchElementException: - print('You already applied to this job, go to next...') - pass - time.sleep(1) + return False + + def find_element_with_retry(self, by, value, retries=3, delay=2): + for _ in range(retries): + try: + return self.driver.find_element(by, value) + except (NoSuchElementException, StaleElementReferenceException): + time.sleep(delay) + raise NoSuchElementException(f"Element not found: {by}, {value}") + + def get_response_for_label(self, label_text): + current_location = self.locations[self.current_location_index] + if current_location in self.context_data["user_inputs"]: + location_specific_inputs = self.context_data["user_inputs"][current_location] + if label_text in location_specific_inputs: + return location_specific_inputs[label_text] + + user_input = input(f"Please provide the answer for '{label_text}': ") + if current_location not in self.context_data["user_inputs"]: + self.context_data["user_inputs"][current_location] = {} + self.context_data["user_inputs"][current_location][label_text] = user_input + self.update_config_file() + return user_input + + def get_radio_response_for_label(self, label_text, options): + current_location = self.locations[self.current_location_index] + if current_location in self.context_data["user_inputs"]: + location_specific_inputs = self.context_data["user_inputs"][current_location] + if label_text in location_specific_inputs: + return location_specific_inputs[label_text] + + while True: + print(f"Please select an option for '{label_text}':") + for i, option in enumerate(options): + print(f"{i + 1}. {option}") + user_input = input("Enter the number of your choice: ").strip() + if user_input.isdigit() and 1 <= int(user_input) <= len(options): + response = options[int(user_input) - 1] + if current_location not in self.context_data["user_inputs"]: + self.context_data["user_inputs"][current_location] = {} + self.context_data["user_inputs"][current_location][label_text] = response + self.update_config_file() + return response + else: + print("Invalid input, please try again.") + + def get_file_response_for_label(self, label_text): + current_location = self.locations[self.current_location_index] + if current_location in self.context_data["user_inputs"]: + location_specific_inputs = self.context_data["user_inputs"][current_location] + if label_text in location_specific_inputs: + return location_specific_inputs[label_text] + + user_input = input(f"Please provide the file location for '{label_text}': ") + if current_location not in self.context_data["user_inputs"]: + self.context_data["user_inputs"][current_location] = {} + self.context_data["user_inputs"][current_location][label_text] = user_input + self.update_config_file() + return user_input + + def update_config_file(self): + with open("config.json", "w") as config_file: + json.dump(self.context_data, config_file, indent=4) + + def find_offers(self): + if self.collection: + self.apply_collection() + else: + self.apply_filtered_jobs() + + def apply_filtered_jobs(self): + while self.current_location_index < len(self.locations): + self.apply_filters_and_search() + + current_page = 1 + + while True: + try: + WebDriverWait(self.driver, 10).until( + EC.presence_of_element_located((By.CLASS_NAME, "scaffold-layout__list-container")) + ) + + job_list_container = self.find_element_with_retry(By.CLASS_NAME, "scaffold-layout__list-container") + job_list_items = job_list_container.find_elements(By.TAG_NAME, "li") + + for index in range(len(job_list_items)): + try: + job_list_container = self.find_element_with_retry(By.CLASS_NAME, "scaffold-layout__list-container") + job_list_items = job_list_container.find_elements(By.TAG_NAME, "li") + + if index >= len(job_list_items): + break + + job_item = job_list_items[index] + self.driver.execute_script("arguments[0].scrollIntoView(true);", job_item) + time.sleep(1) + + try: + self.driver.execute_script("arguments[0].click();", job_item) + except ElementClickInterceptedException: + self.log_info("Element click intercepted, skipping to next job...") + continue + + time.sleep(2) + + WebDriverWait(self.driver, 10).until( + EC.presence_of_element_located( + (By.CLASS_NAME, "jobs-search__job-details--wrapper") + ) + ) + + company_name = self.get_company_name(job_item) + + if company_name in self.applied_companies: + self.log_info(f"Already applied to a job at {company_name}, skipping...") + self.close_application_modal() + continue - # try to submit if submit application is available... + job_details_wrapper = self.find_element_with_retry(By.CLASS_NAME, "jobs-search__job-details--wrapper") + + try: + apply_button = job_details_wrapper.find_element( + By.CSS_SELECTOR, "button.jobs-apply-button.artdeco-button--primary" + ) + apply_button.click() + time.sleep(2) + + WebDriverWait(self.driver, 10).until( + EC.presence_of_element_located( + (By.CSS_SELECTOR, "div.jobs-easy-apply-modal") + ) + ) + + try: + self.handle_easy_apply() + self.log_applied_company(company_name) + except Exception as e: + self.log_info(f"Failed to apply at {company_name}: {str(e)}") + self.log_failed_application(company_name) + + except NoSuchElementException: + self.log_info("No apply button found, continuing to next job...") + continue + + except (NoSuchElementException, ElementNotInteractableException, StaleElementReferenceException) as e: + self.log_info(f"Exception occurred: {e}, continuing to next job...") + self.log_error(f"Find offers error: {e}") + continue + + try: + pagination_container = self.find_element_with_retry(By.CLASS_NAME, "artdeco-pagination__pages") + next_page_button = pagination_container.find_element( + By.XPATH, + f"//button[@aria-label='Page {current_page + 1}']", + ) + self.driver.execute_script("arguments[0].click();", next_page_button) + time.sleep(2) + current_page += 1 + except NoSuchElementException: + self.log_info("No more pages left.") + break + except TimeoutException: + self.log_info("Timeout while waiting for job list container.") + self.log_error("Timeout while waiting for job list container.") + break + + self.current_location_index += 1 + + def apply_collection(self): + collection_url = self.COLLECTION_URLS.get(self.collection) + if not collection_url: + self.log_error(f"Invalid collection: {self.collection}") + return + + self.driver.get(collection_url) + time.sleep(5) + + current_page = 1 + + while True: + try: + WebDriverWait(self.driver, 10).until( + EC.presence_of_element_located((By.CLASS_NAME, "scaffold-layout__list-container")) + ) + + job_list_container = self.find_element_with_retry(By.CLASS_NAME, "scaffold-layout__list-container") + job_list_items = job_list_container.find_elements(By.TAG_NAME, "li") + + for index in range(len(job_list_items)): + try: + job_list_container = self.find_element_with_retry(By.CLASS_NAME, "scaffold-layout__list-container") + job_list_items = job_list_container.find_elements(By.TAG_NAME, "li") + + if index >= len(job_list_items): + break + + job_item = job_list_items[index] + self.driver.execute_script("arguments[0].scrollIntoView(true);", job_item) + time.sleep(1) + + try: + self.driver.execute_script("arguments[0].click();", job_item) + except ElementClickInterceptedException: + self.log_info("Element click intercepted, skipping to next job...") + continue + + time.sleep(2) + + WebDriverWait(self.driver, 10).until( + EC.presence_of_element_located( + (By.CLASS_NAME, "jobs-search__job-details--wrapper") + ) + ) + + company_name = self.get_company_name(job_item) + + if company_name in self.applied_companies: + self.log_info(f"Already applied to a job at {company_name}, skipping...") + self.close_application_modal() + continue + + job_details_wrapper = self.find_element_with_retry(By.CLASS_NAME, "jobs-search__job-details--wrapper") + + try: + apply_button = job_details_wrapper.find_element( + By.CSS_SELECTOR, "button.jobs-apply-button.artdeco-button--primary" + ) + apply_button.click() + time.sleep(2) + + WebDriverWait(self.driver, 10).until( + EC.presence_of_element_located( + (By.CSS_SELECTOR, "div.jobs-easy-apply-modal") + ) + ) + + try: + self.handle_easy_apply() + self.log_applied_company(company_name) + except Exception as e: + self.log_info(f"Failed to apply at {company_name}: {str(e)}") + self.log_failed_application(company_name) + + except NoSuchElementException: + self.log_info("No apply button found, continuing to next job...") + continue + + except (NoSuchElementException, ElementNotInteractableException, StaleElementReferenceException) as e: + self.log_info(f"Exception occurred: {e}, continuing to next job...") + self.log_error(f"Find offers error: {e}") + continue + + try: + pagination_container = self.find_element_with_retry(By.CLASS_NAME, "artdeco-pagination__pages") + next_page_button = pagination_container.find_element( + By.XPATH, + f"//button[@aria-label='Page {current_page + 1}']", + ) + self.driver.execute_script("arguments[0].click();", next_page_button) + time.sleep(2) + current_page += 1 + except NoSuchElementException: + self.log_info("No more pages left.") + break + except TimeoutException: + self.log_info("Timeout while waiting for job list container.") + self.log_error("Timeout while waiting for job list container.") + break + + def get_company_name(self, job_item): try: - submit = self.driver.find_element_by_xpath("//button[@data-control-name='submit_unify']") - submit.send_keys(Keys.RETURN) - - # ... if not available, discard application and go to next + company_element = job_item.find_element( + By.CSS_SELECTOR, + "div.artdeco-entity-lockup__subtitle span.job-card-container__primary-description", + ) + return company_element.text.strip() except NoSuchElementException: - print('Not direct application, going to next...') + return None + + def handle_easy_apply(self): + while True: + try: + modal_dialog = WebDriverWait(self.driver, 10).until( + EC.presence_of_element_located( + (By.CSS_SELECTOR, "div.artdeco-modal--layer-default.jobs-easy-apply-modal") + ) + ) + try: + next_button = modal_dialog.find_element( + By.CSS_SELECTOR, "button[data-easy-apply-next-button]" + ) + self.driver.execute_script("arguments[0].click();", next_button) + time.sleep(2) + except NoSuchElementException: + try: + review_button = modal_dialog.find_element( + By.CSS_SELECTOR, "button[aria-label='Review your application']" + ) + self.driver.execute_script("arguments[0].click();", review_button) + time.sleep(2) + except NoSuchElementException: + try: + submit_button = modal_dialog.find_element( + By.CSS_SELECTOR, "button[aria-label='Submit application']" + ) + self.driver.execute_script("arguments[0].click();", submit_button) + time.sleep(2) + self.log_info("Application submitted.") + self.handle_done_button() + break + except NoSuchElementException: + self.log_info("Submit button not found, continuing to next job...") + self.close_application_modal() + break + self.fill_form(modal_dialog) + except TimeoutException: + self.log_info("No more steps found, exiting...") + break + except Exception as e: + self.log_info(f"Error during easy apply: {e}, skipping to next job...") + self.log_error(f"Easy apply error: {e}") + self.close_application_modal() + break + + def fill_form(self, modal_dialog): + form_elements = modal_dialog.find_elements( + By.CSS_SELECTOR, "div[data-test-form-element], fieldset[data-test-form-builder-radio-button-form-component], fieldset[data-test-checkbox-form-component], div[data-test-text-entity-list-form-component]" + ) + for element in form_elements: try: - discard = self.driver.find_element_by_xpath("//button[@data-test-modal-close-btn]") - discard.send_keys(Keys.RETURN) - time.sleep(1) - discard_confirm = self.driver.find_element_by_xpath("//button[@data-test-dialog-primary-btn]") - discard_confirm.send_keys(Keys.RETURN) - time.sleep(1) + label = element.find_element(By.CSS_SELECTOR, "label, legend, span[aria-hidden='true']") + label_text = label.text.strip() + + if "data-test-checkbox-form-component" in element.get_attribute("outerHTML"): + self.handle_checkboxes(element) + elif "data-test-text-entity-list-form-component" in element.get_attribute("outerHTML"): + select_element = element.find_element(By.CSS_SELECTOR, "select") + options = [option.text for option in select_element.find_elements(By.TAG_NAME, "option")] + response = self.get_radio_response_for_label(label_text, options[1:]) + for option in select_element.find_elements(By.TAG_NAME, "option"): + if option.text == response: + option.click() + break + else: + input_field = element.find_element(By.CSS_SELECTOR, "input, select, textarea") + + if input_field.tag_name == "input" and input_field.get_attribute("type") == "text": + response = self.get_response_for_label(label_text) + if input_field.get_attribute("value") == "": + input_field.send_keys(response) + time.sleep(1) + input_field.send_keys(Keys.ARROW_DOWN) + input_field.send_keys(Keys.RETURN) + + elif input_field.tag_name == "select": + response = self.get_response_for_label(label_text) + select_options = input_field.find_elements(By.TAG_NAME, "option") + for option in select_options: + if option.get_attribute("value") == response: + option.click() + break + + elif input_field.tag_name == "textarea": + response = self.get_response_for_label(label_text) + if input_field.get_attribute("value") == "": + input_field.send_keys(response) + + elif input_field.tag_name == "input" and input_field.get_attribute("type") == "radio": + radio_buttons = element.find_elements(By.CSS_SELECTOR, "input[type='radio']") + for radio in radio_buttons: + radio_label = radio.find_element(By.XPATH, "./following-sibling::label").text.strip() + response = self.get_radio_response_for_label(label_text, [rb.find_element(By.XPATH, "./following-sibling::label").text.strip() for rb in radio_buttons]) + if response.lower() == radio_label.lower(): + try: + radio.click() + except ElementClickInterceptedException: + self.driver.execute_script("arguments[0].click();", radio) + break + + elif input_field.tag_name == "input" and input_field.get_attribute("type") == "file": + response = self.get_file_response_for_label(label_text) + input_field.send_keys(response) + time.sleep(1) + except NoSuchElementException: - pass + continue - time.sleep(1) + try: + next_button = modal_dialog.find_element(By.CSS_SELECTOR, "button[data-easy-apply-next-button]") + next_button.click() + time.sleep(2) + except NoSuchElementException: + self.log_info("Next button not found, form might be complete or there is an issue.") - def close_session(self): - """This function closes the actual session""" - - print('End of the session, see you later!') - self.driver.close() + def handle_checkboxes(self, element): + checkboxes = element.find_elements(By.CSS_SELECTOR, "input[type='checkbox']") + for index in range(len(checkboxes)): + checkbox_label = None + try: + checkbox = checkboxes[index] + checkbox_label = checkbox.find_element(By.XPATH, "./following-sibling::label").text.strip() + response = self.get_checkbox_response_for_label(checkbox_label) + if response is not None: + self.set_checkbox_state(checkbox, checkbox_label, response) + except (ElementClickInterceptedException, StaleElementReferenceException) as e: + self.log_info(f"Checkbox interaction failed for {checkbox_label}, attempting to retry. Error: {e}") + self.retry_checkbox_interaction(element, index) - def apply(self): - """Apply to job offers""" + def retry_checkbox_interaction(self, element, index): + retries = 3 + while retries > 0: + retries -= 1 + try: + checkboxes = element.find_elements(By.CSS_SELECTOR, "input[type='checkbox']") + checkbox = checkboxes[index] + checkbox_label = checkbox.find_element(By.XPATH, "./following-sibling::label").text.strip() + response = self.get_checkbox_response_for_label(checkbox_label) + if response is not None: + self.set_checkbox_state(checkbox, checkbox_label, response) + return + except (NoSuchElementException, StaleElementReferenceException) as e: + self.log_info(f"Retry failed for {checkbox_label}. Error: {e}") + if retries == 0: + self.log_info(f"Skipping {checkbox_label} after multiple retries.") - self.driver.maximize_window() - self.login_linkedin() - time.sleep(5) - self.job_search() - time.sleep(5) - self.filter() - time.sleep(2) - self.find_offers() - time.sleep(2) - self.close_session() + def set_checkbox_state(self, checkbox, checkbox_label, response): + if response and not checkbox.is_selected(): + self.driver.execute_script("arguments[0].click();", checkbox) + elif not response and checkbox.is_selected(): + self.driver.execute_script("arguments[0].click();", checkbox) + def get_checkbox_response_for_label(self, label_text): + current_location = self.locations[self.current_location_index] + if current_location not in self.context_data["user_inputs"]: + self.context_data["user_inputs"][current_location] = {} -if __name__ == '__main__': + location_specific_inputs = self.context_data["user_inputs"][current_location] + if label_text in location_specific_inputs: + return location_specific_inputs[label_text] - with open('config.json') as config_file: - data = json.load(config_file) + while True: + user_input = input(f"Do you want to check the box for '{label_text}'? (yes/no): ").strip().lower() + if user_input in ["yes", "no"]: + response = user_input == "yes" + location_specific_inputs[label_text] = response + self.update_config_file() + return response + + def handle_done_button(self): + try: + done_button = WebDriverWait(self.driver, 10).until( + EC.presence_of_element_located((By.CSS_SELECTOR, "button.artdeco-button.artdeco-button--primary")) + ) + done_button.click() + time.sleep(2) + except TimeoutException: + self.log_info("Done button not found, skipping to next job.") + + def close_application_modal(self): + try: + close_button = WebDriverWait(self.driver, 10).until( + EC.presence_of_element_located( + ( + By.CSS_SELECTOR, + "button.artdeco-button.artdeco-button--circle.artdeco-button--muted.artdeco-button--2.artdeco-button--tertiary.artdeco-modal__dismiss", + ) + ) + ) + close_button.click() + time.sleep(2) + self.handle_discard_dialog() + except TimeoutException: + self.log_info("Close button not found, skipping to next job.") + def handle_discard_dialog(self): + try: + discard_button = WebDriverWait(self.driver, 10).until( + EC.presence_of_element_located( + (By.CSS_SELECTOR, "button[data-control-name='discard_application_confirm_btn']") + ) + ) + discard_button.click() + time.sleep(2) + except TimeoutException: + self.log_info("Discard button not found, skipping to next job.") + + def close_session(self): + self.log_info("End of the session") + self.driver.close() + self.driver.quit() + + def handle_captcha(self): + input("CAPTCHA detected. Please solve the CAPTCHA manually and then press Enter to continue...") + +if __name__ == "__main__": + with open("config.json") as config_file: + data = json.load(config_file) bot = EasyApplyLinkedin(data) - bot.apply() \ No newline at end of file + bot.login_linkedin() + bot.job_search() + bot.find_offers() + bot.close_session() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..fa05aa0 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,27 @@ +attrs==23.2.0 +certifi==2024.6.2 +click==8.1.7 +exceptiongroup==1.2.1 +h11==0.14.0 +idna==3.7 +outcome==1.3.0.post0 +PySocks==1.7.1 +selenium==4.22.0 +sniffio==1.3.1 +sortedcontainers==2.4.0 +trio==0.25.1 +trio-websocket==0.11.1 +typing_extensions==4.12.2 +urllib3==2.2.2 +websocket-client==1.8.0 +wsproto==1.2.0 +iniconfig==1.1.1 +packaging==21.0 +pluggy==0.13.1 +py==1.10.0 +pyparsing==2.4.7 +pytest==6.2.4 +transformers==4.28.1 +torch==2.0.1 +langdetect==1.0.9 +googletrans==4.0.0-rc1 diff --git a/unit_tests.py b/unit_tests.py new file mode 100644 index 0000000..0401d34 --- /dev/null +++ b/unit_tests.py @@ -0,0 +1,55 @@ +import unittest +from unittest.mock import patch, MagicMock +from easy_apply_linkedin import EasyApplyLinkedin + +class TestEasyApplyLinkedin(unittest.TestCase): + def setUp(self): + self.data = { + "email": "sendmessage@gabo.email", + "password": "bp8v9fvk#?QaKe7", + "keywords": ["TypeScript", "Angular", "React"], + "keywordsToAvoid": ["C++", ".NET"], + "locations": ["Switzerland", "Belgium"], + "driver_path": "/usr/local/bin/geckodriver", + "sortBy": "R", + "filters": { + "easy_apply": True, + "experience": [], + "jobType": ["Full-time", "Contract"], + "timePostedRange": [], + "workplaceType": ["Remote", "Hybrid"], + "less_than_10_applicants": False + } + } + self.bot = EasyApplyLinkedin(self.data) + + @patch('easy_apply_linkedin.webdriver.Firefox') + def test_login_linkedin(self, MockWebDriver): + mock_driver = MockWebDriver.return_value + mock_driver.find_element.return_value = MagicMock() + self.bot.login_linkedin() + mock_driver.get.assert_called_with("https://www.linkedin.com/login") + self.assertTrue(mock_driver.find_element.called) + + @patch('easy_apply_linkedin.webdriver.Firefox') + def test_construct_url(self, MockWebDriver): + url = self.bot.construct_url() + self.assertIn("keywords=TypeScript%20OR%20Angular%20OR%20React", url) + self.assertIn("geoId=106693272", url) + self.assertIn("f_AL=true", url) + + @patch('easy_apply_linkedin.webdriver.Firefox') + def test_apply_filters_and_search_no_results(self, MockWebDriver): + mock_driver = MockWebDriver.return_value + mock_driver.find_element.side_effect = NoSuchElementException + self.bot.apply_filters_and_search() + self.assertEqual(self.bot.current_location_index, 1) + + @patch('easy_apply_linkedin.webdriver.Firefox') + def test_log_error(self, MockWebDriver): + self.bot.log_error("Test error") + errors = self.bot.load_json(self.bot.ERROR_LOG_PATH) + self.assertTrue(any("Test error" in v for v in errors.values())) + +if __name__ == "__main__": + unittest.main()